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,1305 @@
1
+ /**
2
+ * Direct API client for Teams/Substrate search.
3
+ *
4
+ * Extracts auth tokens from browser session state and makes
5
+ * direct HTTP calls without needing an active browser.
6
+ */
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import { fileURLToPath } from 'url';
10
+ import { stripHtml, buildMessageLink, parseJwtProfile, parsePeopleResults, parseSearchResults, } from '../utils/parsers.js';
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const PROJECT_ROOT = path.resolve(__dirname, '../..');
13
+ const SESSION_STATE_PATH = path.join(PROJECT_ROOT, 'session-state.json');
14
+ const TOKEN_CACHE_PATH = path.join(PROJECT_ROOT, 'token-cache.json');
15
+ /**
16
+ * Gets the current user's profile from cached JWT tokens.
17
+ *
18
+ * Extracts user info from MSAL tokens stored in session state.
19
+ * No API call needed - just parses existing tokens.
20
+ */
21
+ export function getMe() {
22
+ if (!fs.existsSync(SESSION_STATE_PATH)) {
23
+ return null;
24
+ }
25
+ try {
26
+ const state = JSON.parse(fs.readFileSync(SESSION_STATE_PATH, 'utf8'));
27
+ const teamsOrigin = state.origins?.find((o) => o.origin === 'https://teams.microsoft.com');
28
+ if (!teamsOrigin)
29
+ return null;
30
+ // Look through localStorage for any JWT with user info
31
+ for (const item of teamsOrigin.localStorage) {
32
+ try {
33
+ const val = JSON.parse(item.value);
34
+ if (!val.secret || typeof val.secret !== 'string')
35
+ continue;
36
+ if (!val.secret.startsWith('ey'))
37
+ continue;
38
+ const parts = val.secret.split('.');
39
+ if (parts.length !== 3)
40
+ continue;
41
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
42
+ // Use shared parsing function
43
+ const profile = parseJwtProfile(payload);
44
+ if (profile) {
45
+ return profile;
46
+ }
47
+ }
48
+ catch {
49
+ continue;
50
+ }
51
+ }
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ return null;
57
+ }
58
+ /**
59
+ * Extracts the Substrate search token from session state.
60
+ */
61
+ export function extractSubstrateToken() {
62
+ if (!fs.existsSync(SESSION_STATE_PATH)) {
63
+ return null;
64
+ }
65
+ try {
66
+ const state = JSON.parse(fs.readFileSync(SESSION_STATE_PATH, 'utf8'));
67
+ const teamsOrigin = state.origins?.find((o) => o.origin === 'https://teams.microsoft.com');
68
+ if (!teamsOrigin)
69
+ return null;
70
+ for (const item of teamsOrigin.localStorage) {
71
+ try {
72
+ const val = JSON.parse(item.value);
73
+ if (val.target?.includes('substrate.office.com/search/SubstrateSearch')) {
74
+ const token = val.secret;
75
+ // Parse JWT to get expiry
76
+ const parts = token.split('.');
77
+ if (parts.length === 3) {
78
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
79
+ const expiry = new Date(payload.exp * 1000);
80
+ return { token, expiry };
81
+ }
82
+ }
83
+ }
84
+ catch {
85
+ continue;
86
+ }
87
+ }
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ return null;
93
+ }
94
+ /**
95
+ * Gets a valid token, either from cache or by extracting from session.
96
+ */
97
+ export function getValidToken() {
98
+ // Try cache first
99
+ if (fs.existsSync(TOKEN_CACHE_PATH)) {
100
+ try {
101
+ const cache = JSON.parse(fs.readFileSync(TOKEN_CACHE_PATH, 'utf8'));
102
+ if (cache.substrateTokenExpiry > Date.now()) {
103
+ return cache.substrateToken;
104
+ }
105
+ }
106
+ catch {
107
+ // Cache invalid, continue to extraction
108
+ }
109
+ }
110
+ // Extract from session state
111
+ const extracted = extractSubstrateToken();
112
+ if (!extracted)
113
+ return null;
114
+ // Check if not expired
115
+ if (extracted.expiry.getTime() <= Date.now()) {
116
+ return null;
117
+ }
118
+ // Cache the token
119
+ const cache = {
120
+ substrateToken: extracted.token,
121
+ substrateTokenExpiry: extracted.expiry.getTime(),
122
+ extractedAt: Date.now(),
123
+ };
124
+ fs.writeFileSync(TOKEN_CACHE_PATH, JSON.stringify(cache, null, 2));
125
+ return extracted.token;
126
+ }
127
+ /**
128
+ * Clears the token cache (forces re-extraction on next call).
129
+ */
130
+ export function clearTokenCache() {
131
+ if (fs.existsSync(TOKEN_CACHE_PATH)) {
132
+ fs.unlinkSync(TOKEN_CACHE_PATH);
133
+ }
134
+ }
135
+ /**
136
+ * Checks if we have a valid token for direct API calls.
137
+ */
138
+ export function hasValidToken() {
139
+ return getValidToken() !== null;
140
+ }
141
+ /**
142
+ * Gets token status for diagnostics.
143
+ */
144
+ export function getTokenStatus() {
145
+ const extracted = extractSubstrateToken();
146
+ if (!extracted) {
147
+ return { hasToken: false };
148
+ }
149
+ const now = Date.now();
150
+ const expiryMs = extracted.expiry.getTime();
151
+ return {
152
+ hasToken: expiryMs > now,
153
+ expiresAt: extracted.expiry.toISOString(),
154
+ minutesRemaining: Math.max(0, Math.round((expiryMs - now) / 1000 / 60)),
155
+ };
156
+ }
157
+ /**
158
+ * Makes a direct search API call to Substrate.
159
+ */
160
+ export async function directSearch(query, options = {}) {
161
+ const token = getValidToken();
162
+ if (!token) {
163
+ throw new Error('No valid token available. Browser login required.');
164
+ }
165
+ const from = options.from ?? 0;
166
+ const size = options.size ?? 25;
167
+ // Generate unique IDs for this request
168
+ const cvid = crypto.randomUUID();
169
+ const logicalId = crypto.randomUUID();
170
+ const body = {
171
+ entityRequests: [{
172
+ entityType: 'Message',
173
+ contentSources: ['Teams'],
174
+ propertySet: 'Optimized',
175
+ fields: [
176
+ 'Extension_SkypeSpaces_ConversationPost_Extension_FromSkypeInternalId_String',
177
+ 'Extension_SkypeSpaces_ConversationPost_Extension_ThreadType_String',
178
+ 'Extension_SkypeSpaces_ConversationPost_Extension_SkypeGroupId_String',
179
+ ],
180
+ query: {
181
+ queryString: `${query} AND NOT (isClientSoftDeleted:TRUE)`,
182
+ displayQueryString: query,
183
+ },
184
+ from,
185
+ size,
186
+ topResultsCount: 5,
187
+ }],
188
+ QueryAlterationOptions: {
189
+ EnableAlteration: true,
190
+ EnableSuggestion: true,
191
+ SupportedRecourseDisplayTypes: ['Suggestion'],
192
+ },
193
+ cvid,
194
+ logicalId,
195
+ scenario: {
196
+ Dimensions: [
197
+ { DimensionName: 'QueryType', DimensionValue: 'Messages' },
198
+ { DimensionName: 'FormFactor', DimensionValue: 'general.web.reactSearch' },
199
+ ],
200
+ Name: 'powerbar',
201
+ },
202
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
203
+ };
204
+ const response = await fetch('https://substrate.office.com/searchservice/api/v2/query', {
205
+ method: 'POST',
206
+ headers: {
207
+ 'Authorization': `Bearer ${token}`,
208
+ 'Content-Type': 'application/json',
209
+ 'Accept': 'application/json',
210
+ 'Origin': 'https://teams.microsoft.com',
211
+ 'Referer': 'https://teams.microsoft.com/',
212
+ },
213
+ body: JSON.stringify(body),
214
+ });
215
+ if (response.status === 401) {
216
+ clearTokenCache();
217
+ throw new Error('Token expired or invalid. Browser login required.');
218
+ }
219
+ if (!response.ok) {
220
+ throw new Error(`API error: ${response.status} ${response.statusText}`);
221
+ }
222
+ const data = await response.json();
223
+ // Use shared parsing function
224
+ const { results, total } = parseSearchResults(data.EntitySets, from, size);
225
+ const maxResults = options.maxResults ?? size;
226
+ const limitedResults = results.slice(0, maxResults);
227
+ return {
228
+ results: limitedResults,
229
+ pagination: {
230
+ from,
231
+ size,
232
+ returned: limitedResults.length,
233
+ total,
234
+ hasMore: total !== undefined
235
+ ? from + results.length < total
236
+ : results.length >= size,
237
+ },
238
+ };
239
+ }
240
+ /**
241
+ * Extracts the Teams API token and user MRI from session state.
242
+ * This is different from the Substrate token - it's used for chat APIs.
243
+ *
244
+ * The chat API requires a token with audience:
245
+ * - https://chatsvcagg.teams.microsoft.com (preferred)
246
+ * - https://api.spaces.skype.com (fallback)
247
+ */
248
+ export function extractTeamsToken() {
249
+ if (!fs.existsSync(SESSION_STATE_PATH)) {
250
+ return null;
251
+ }
252
+ try {
253
+ const state = JSON.parse(fs.readFileSync(SESSION_STATE_PATH, 'utf8'));
254
+ const teamsOrigin = state.origins?.find((o) => o.origin === 'https://teams.microsoft.com');
255
+ if (!teamsOrigin)
256
+ return null;
257
+ let chatToken = null;
258
+ let chatTokenExpiry = null;
259
+ let skypeToken = null;
260
+ let skypeTokenExpiry = null;
261
+ let userMri = null;
262
+ for (const item of teamsOrigin.localStorage) {
263
+ try {
264
+ const val = JSON.parse(item.value);
265
+ if (!val.target || !val.secret)
266
+ continue;
267
+ const secret = val.secret;
268
+ if (typeof secret !== 'string' || !secret.startsWith('ey'))
269
+ continue;
270
+ const parts = secret.split('.');
271
+ if (parts.length !== 3)
272
+ continue;
273
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
274
+ const tokenExpiry = new Date(payload.exp * 1000);
275
+ // Extract user MRI from any token
276
+ if (payload.oid && !userMri) {
277
+ userMri = `8:orgid:${payload.oid}`;
278
+ }
279
+ // Prefer chatsvcagg.teams.microsoft.com token
280
+ if (val.target.includes('chatsvcagg.teams.microsoft.com')) {
281
+ if (!chatTokenExpiry || tokenExpiry > chatTokenExpiry) {
282
+ chatToken = secret;
283
+ chatTokenExpiry = tokenExpiry;
284
+ }
285
+ }
286
+ // Fallback to api.spaces.skype.com token
287
+ if (val.target.includes('api.spaces.skype.com')) {
288
+ if (!skypeTokenExpiry || tokenExpiry > skypeTokenExpiry) {
289
+ skypeToken = secret;
290
+ skypeTokenExpiry = tokenExpiry;
291
+ }
292
+ }
293
+ }
294
+ catch {
295
+ continue;
296
+ }
297
+ }
298
+ // If we still don't have userMri, try to get it from the Substrate token
299
+ if (!userMri) {
300
+ const substrateInfo = extractSubstrateToken();
301
+ if (substrateInfo) {
302
+ try {
303
+ const parts = substrateInfo.token.split('.');
304
+ if (parts.length === 3) {
305
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
306
+ if (payload.oid) {
307
+ userMri = `8:orgid:${payload.oid}`;
308
+ }
309
+ }
310
+ }
311
+ catch {
312
+ // ignore
313
+ }
314
+ }
315
+ }
316
+ // Prefer chatsvc token, fallback to skype token
317
+ const token = chatToken || skypeToken;
318
+ const expiry = chatToken ? chatTokenExpiry : skypeTokenExpiry;
319
+ if (token && expiry && userMri && expiry.getTime() > Date.now()) {
320
+ return { token, expiry, userMri };
321
+ }
322
+ }
323
+ catch {
324
+ return null;
325
+ }
326
+ return null;
327
+ }
328
+ /**
329
+ * Extracts authentication info needed for sending messages.
330
+ * Uses cookies (skypetoken_asm) which are required for the chatsvc API.
331
+ */
332
+ export function extractMessageAuth() {
333
+ if (!fs.existsSync(SESSION_STATE_PATH)) {
334
+ return null;
335
+ }
336
+ try {
337
+ const state = JSON.parse(fs.readFileSync(SESSION_STATE_PATH, 'utf8'));
338
+ let skypeToken = null;
339
+ let authToken = null;
340
+ let userMri = null;
341
+ // Extract tokens from cookies
342
+ for (const cookie of state.cookies || []) {
343
+ if (cookie.name === 'skypetoken_asm' && cookie.domain?.includes('teams.microsoft.com')) {
344
+ skypeToken = cookie.value;
345
+ }
346
+ if (cookie.name === 'authtoken' && cookie.domain?.includes('teams.microsoft.com')) {
347
+ // Decode the URL-encoded cookie value
348
+ authToken = decodeURIComponent(cookie.value);
349
+ if (authToken.startsWith('Bearer=')) {
350
+ authToken = authToken.substring(7); // Remove "Bearer=" prefix
351
+ }
352
+ }
353
+ }
354
+ // Get userMri from token payload or localStorage
355
+ if (skypeToken) {
356
+ try {
357
+ const parts = skypeToken.split('.');
358
+ if (parts.length >= 2) {
359
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
360
+ if (payload.skypeid) {
361
+ userMri = payload.skypeid;
362
+ }
363
+ }
364
+ }
365
+ catch {
366
+ // Not a JWT format, that's fine
367
+ }
368
+ }
369
+ // Fallback to extracting userMri from authToken
370
+ if (!userMri && authToken) {
371
+ try {
372
+ const parts = authToken.split('.');
373
+ if (parts.length === 3) {
374
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
375
+ if (payload.oid) {
376
+ userMri = `8:orgid:${payload.oid}`;
377
+ }
378
+ }
379
+ }
380
+ catch {
381
+ // ignore
382
+ }
383
+ }
384
+ if (skypeToken && authToken && userMri) {
385
+ return { skypeToken, authToken, userMri };
386
+ }
387
+ }
388
+ catch {
389
+ return null;
390
+ }
391
+ return null;
392
+ }
393
+ /**
394
+ * Extracts the CSA (Chat Service Aggregator) token for the conversationFolders API.
395
+ * This token is different from the chatsvc token and is required for favorites operations.
396
+ */
397
+ export function extractCsaToken() {
398
+ if (!fs.existsSync(SESSION_STATE_PATH)) {
399
+ return null;
400
+ }
401
+ try {
402
+ const state = JSON.parse(fs.readFileSync(SESSION_STATE_PATH, 'utf8'));
403
+ // Look for the MSAL token with chatsvcagg.teams.microsoft.com audience
404
+ for (const origin of state.origins || []) {
405
+ for (const item of (origin.localStorage || [])) {
406
+ if (item.name.includes('chatsvcagg.teams.microsoft.com') && !item.name.startsWith('tmp.')) {
407
+ try {
408
+ const data = JSON.parse(item.value);
409
+ if (data.secret) {
410
+ return data.secret;
411
+ }
412
+ }
413
+ catch {
414
+ // Ignore parse errors
415
+ }
416
+ }
417
+ }
418
+ }
419
+ }
420
+ catch {
421
+ return null;
422
+ }
423
+ return null;
424
+ }
425
+ /**
426
+ * Gets user's display name from session state.
427
+ */
428
+ export function getUserDisplayName() {
429
+ if (!fs.existsSync(SESSION_STATE_PATH)) {
430
+ return null;
431
+ }
432
+ try {
433
+ const state = JSON.parse(fs.readFileSync(SESSION_STATE_PATH, 'utf8'));
434
+ const teamsOrigin = state.origins?.find((o) => o.origin === 'https://teams.microsoft.com');
435
+ if (!teamsOrigin)
436
+ return null;
437
+ for (const item of teamsOrigin.localStorage) {
438
+ try {
439
+ // Look for user profile data
440
+ if (item.value?.includes('displayName') || item.value?.includes('givenName')) {
441
+ const val = JSON.parse(item.value);
442
+ if (val.displayName)
443
+ return val.displayName;
444
+ if (val.name?.displayName)
445
+ return val.name.displayName;
446
+ }
447
+ }
448
+ catch {
449
+ continue;
450
+ }
451
+ }
452
+ // Try to get from token
453
+ const teamsToken = extractTeamsToken();
454
+ if (teamsToken) {
455
+ try {
456
+ const parts = teamsToken.token.split('.');
457
+ if (parts.length === 3) {
458
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
459
+ if (payload.name)
460
+ return payload.name;
461
+ }
462
+ }
463
+ catch {
464
+ // ignore
465
+ }
466
+ }
467
+ }
468
+ catch {
469
+ return null;
470
+ }
471
+ return null;
472
+ }
473
+ /**
474
+ * Sends a message to a Teams conversation.
475
+ *
476
+ * Uses the skypetoken_asm cookie for authentication, which is required
477
+ * by the Teams chatsvc API.
478
+ *
479
+ * @param conversationId - The conversation ID (e.g., "48:notes" for self-chat)
480
+ * @param content - Message content (HTML supported)
481
+ * @param region - Region for the API (default: "amer")
482
+ */
483
+ export async function sendMessage(conversationId, content, region = 'amer') {
484
+ const auth = extractMessageAuth();
485
+ if (!auth) {
486
+ return { success: false, error: 'No valid authentication. Browser login required.' };
487
+ }
488
+ const displayName = getUserDisplayName() || 'User';
489
+ // Generate unique message ID
490
+ const clientMessageId = Date.now().toString();
491
+ const now = new Date().toISOString();
492
+ // Wrap content in paragraph if not already HTML
493
+ const htmlContent = content.startsWith('<') ? content : `<p>${content}</p>`;
494
+ const body = {
495
+ content: htmlContent,
496
+ messagetype: 'RichText/Html',
497
+ contenttype: 'text',
498
+ imdisplayname: displayName,
499
+ clientmessageid: clientMessageId,
500
+ };
501
+ // Use the Teams messaging API with skypetoken authentication
502
+ const url = `https://teams.microsoft.com/api/chatsvc/${region}/v1/users/ME/conversations/${encodeURIComponent(conversationId)}/messages`;
503
+ try {
504
+ const response = await fetch(url, {
505
+ method: 'POST',
506
+ headers: {
507
+ 'Authentication': `skypetoken=${auth.skypeToken}`,
508
+ 'Authorization': `Bearer ${auth.authToken}`,
509
+ 'Content-Type': 'application/json',
510
+ 'Accept': 'application/json',
511
+ 'Origin': 'https://teams.microsoft.com',
512
+ 'Referer': 'https://teams.microsoft.com/',
513
+ 'X-Ms-Client-Version': '1415/1.0.0.2025010401',
514
+ },
515
+ body: JSON.stringify(body),
516
+ });
517
+ if (!response.ok) {
518
+ const errorText = await response.text();
519
+ return {
520
+ success: false,
521
+ error: `API error: ${response.status} - ${errorText}`
522
+ };
523
+ }
524
+ const data = await response.json();
525
+ return {
526
+ success: true,
527
+ messageId: clientMessageId,
528
+ timestamp: data.OriginalArrivalTime,
529
+ };
530
+ }
531
+ catch (err) {
532
+ return {
533
+ success: false,
534
+ error: err instanceof Error ? err.message : 'Unknown error',
535
+ };
536
+ }
537
+ }
538
+ /**
539
+ * Sends a message to your own notes/self-chat.
540
+ */
541
+ export async function sendNoteToSelf(content) {
542
+ return sendMessage('48:notes', content);
543
+ }
544
+ /**
545
+ * Searches for people by name or email using the Substrate suggestions API.
546
+ *
547
+ * Uses the same auth token as message search.
548
+ *
549
+ * @param query - Search term (name, email, or partial match)
550
+ * @param limit - Maximum number of results (default: 10)
551
+ */
552
+ export async function searchPeople(query, limit = 10) {
553
+ const token = getValidToken();
554
+ if (!token) {
555
+ throw new Error('No valid token available. Browser login required.');
556
+ }
557
+ // Generate unique IDs for this request (required by the API)
558
+ const cvid = crypto.randomUUID();
559
+ const logicalId = crypto.randomUUID();
560
+ const body = {
561
+ EntityRequests: [{
562
+ Query: {
563
+ QueryString: query,
564
+ DisplayQueryString: query,
565
+ },
566
+ EntityType: 'People',
567
+ Size: limit,
568
+ Fields: [
569
+ 'Id',
570
+ 'MRI',
571
+ 'DisplayName',
572
+ 'EmailAddresses',
573
+ 'GivenName',
574
+ 'Surname',
575
+ 'JobTitle',
576
+ 'Department',
577
+ 'CompanyName',
578
+ ],
579
+ }],
580
+ cvid,
581
+ logicalId,
582
+ };
583
+ const response = await fetch('https://substrate.office.com/search/api/v1/suggestions?scenario=powerbar', {
584
+ method: 'POST',
585
+ headers: {
586
+ 'Authorization': `Bearer ${token}`,
587
+ 'Content-Type': 'application/json',
588
+ 'Accept': 'application/json',
589
+ 'Origin': 'https://teams.microsoft.com',
590
+ 'Referer': 'https://teams.microsoft.com/',
591
+ },
592
+ body: JSON.stringify(body),
593
+ });
594
+ if (response.status === 401) {
595
+ clearTokenCache();
596
+ throw new Error('Token expired or invalid. Browser login required.');
597
+ }
598
+ if (!response.ok) {
599
+ throw new Error(`API error: ${response.status} ${response.statusText}`);
600
+ }
601
+ const data = await response.json();
602
+ // Use shared parsing function
603
+ const results = parsePeopleResults(data.Groups);
604
+ return {
605
+ results,
606
+ returned: results.length,
607
+ };
608
+ }
609
+ /**
610
+ * Gets the user's frequently contacted people.
611
+ *
612
+ * Uses the peoplecache scenario which returns contacts ranked by
613
+ * interaction frequency. Useful for resolving ambiguous names
614
+ * (e.g., "Rob" → "Rob Smith <rob.smith@company.com>").
615
+ *
616
+ * @param limit - Maximum number of contacts to return (default: 50)
617
+ */
618
+ export async function getFrequentContacts(limit = 50) {
619
+ const token = getValidToken();
620
+ if (!token) {
621
+ throw new Error('No valid token available. Browser login required.');
622
+ }
623
+ // Generate unique IDs for this request (required by the API)
624
+ const cvid = crypto.randomUUID();
625
+ const logicalId = crypto.randomUUID();
626
+ const body = {
627
+ EntityRequests: [{
628
+ Query: {
629
+ QueryString: '',
630
+ DisplayQueryString: '',
631
+ },
632
+ EntityType: 'People',
633
+ Size: limit,
634
+ Fields: [
635
+ 'Id',
636
+ 'MRI',
637
+ 'DisplayName',
638
+ 'EmailAddresses',
639
+ 'GivenName',
640
+ 'Surname',
641
+ 'JobTitle',
642
+ 'Department',
643
+ 'CompanyName',
644
+ ],
645
+ }],
646
+ cvid,
647
+ logicalId,
648
+ };
649
+ const response = await fetch('https://substrate.office.com/search/api/v1/suggestions?scenario=peoplecache', {
650
+ method: 'POST',
651
+ headers: {
652
+ 'Authorization': `Bearer ${token}`,
653
+ 'Content-Type': 'application/json',
654
+ 'Accept': 'application/json',
655
+ 'Origin': 'https://teams.microsoft.com',
656
+ 'Referer': 'https://teams.microsoft.com/',
657
+ },
658
+ body: JSON.stringify(body),
659
+ });
660
+ if (response.status === 401) {
661
+ clearTokenCache();
662
+ throw new Error('Token expired or invalid. Browser login required.');
663
+ }
664
+ if (!response.ok) {
665
+ throw new Error(`API error: ${response.status} ${response.statusText}`);
666
+ }
667
+ const data = await response.json();
668
+ // Use shared parsing function
669
+ const contacts = parsePeopleResults(data.Groups);
670
+ return {
671
+ contacts,
672
+ returned: contacts.length,
673
+ };
674
+ }
675
+ /**
676
+ * Extracts unique participant names from recent messages in a conversation.
677
+ * Used as a fallback when conversation properties don't include member names.
678
+ */
679
+ async function extractParticipantNamesFromMessages(conversationId, auth, region = 'amer') {
680
+ const url = `https://teams.microsoft.com/api/chatsvc/${region}/v1/users/ME/conversations/${encodeURIComponent(conversationId)}/messages?view=msnp24Equivalent&pageSize=10`;
681
+ try {
682
+ const response = await fetch(url, {
683
+ method: 'GET',
684
+ headers: {
685
+ 'Authentication': `skypetoken=${auth.skypeToken}`,
686
+ 'Authorization': `Bearer ${auth.authToken}`,
687
+ 'Accept': 'application/json',
688
+ 'Origin': 'https://teams.microsoft.com',
689
+ 'Referer': 'https://teams.microsoft.com/',
690
+ },
691
+ });
692
+ if (!response.ok) {
693
+ return undefined;
694
+ }
695
+ const data = await response.json();
696
+ const messages = data.messages;
697
+ if (!messages || messages.length === 0) {
698
+ return undefined;
699
+ }
700
+ // Extract unique sender names (excluding the current user)
701
+ const senderNames = new Set();
702
+ for (const msg of messages) {
703
+ const fromMri = msg.from || '';
704
+ const displayName = msg.imdisplayname;
705
+ // Skip messages from self and system messages
706
+ if (fromMri === auth.userMri || !displayName) {
707
+ continue;
708
+ }
709
+ senderNames.add(displayName);
710
+ }
711
+ if (senderNames.size === 0) {
712
+ return undefined;
713
+ }
714
+ const names = Array.from(senderNames);
715
+ return names.length <= 3
716
+ ? names.join(', ')
717
+ : `${names.slice(0, 3).join(', ')} + ${names.length - 3} more`;
718
+ }
719
+ catch {
720
+ return undefined;
721
+ }
722
+ }
723
+ /**
724
+ * Gets properties for a single conversation (name, type, etc.).
725
+ *
726
+ * Uses the chatsvc conversation endpoint to fetch metadata.
727
+ *
728
+ * @param conversationId - The conversation ID
729
+ * @param auth - Pre-extracted auth info (to avoid repeated extraction)
730
+ * @param region - Region for the API (default: "amer")
731
+ */
732
+ async function getConversationProperties(conversationId, auth, region = 'amer') {
733
+ const url = `https://teams.microsoft.com/api/chatsvc/${region}/v1/users/ME/conversations/${encodeURIComponent(conversationId)}?view=msnp24Equivalent`;
734
+ try {
735
+ const response = await fetch(url, {
736
+ method: 'GET',
737
+ headers: {
738
+ 'Authentication': `skypetoken=${auth.skypeToken}`,
739
+ 'Authorization': `Bearer ${auth.authToken}`,
740
+ 'Accept': 'application/json',
741
+ 'Origin': 'https://teams.microsoft.com',
742
+ 'Referer': 'https://teams.microsoft.com/',
743
+ },
744
+ });
745
+ if (!response.ok) {
746
+ return null;
747
+ }
748
+ const data = await response.json();
749
+ // Extract the thread properties which contain the conversation details
750
+ const threadProps = data.threadProperties;
751
+ const productType = threadProps?.productThreadType;
752
+ // Try to get display name from various sources
753
+ let displayName;
754
+ // For Teams channels: topicThreadTopic is the channel name, topic is fallback
755
+ if (threadProps?.topicThreadTopic) {
756
+ displayName = threadProps.topicThreadTopic;
757
+ }
758
+ // For standard channels: topic is the channel name
759
+ if (!displayName && threadProps?.topic) {
760
+ displayName = threadProps.topic;
761
+ }
762
+ // For Teams (the team itself, not a channel): spaceThreadTopic is the team name
763
+ if (!displayName && threadProps?.spaceThreadTopic) {
764
+ displayName = threadProps.spaceThreadTopic;
765
+ }
766
+ // For group chats: threadtopic may be set
767
+ if (!displayName && threadProps?.threadtopic) {
768
+ displayName = threadProps.threadtopic;
769
+ }
770
+ // For 1:1 chats and group chats without a topic: build from members
771
+ if (!displayName) {
772
+ const members = data.members;
773
+ if (members && members.length > 0) {
774
+ // Filter out the current user and build a name from the other participants
775
+ // Check multiple possible field names for the display name
776
+ const otherMembers = members
777
+ .filter(m => m.mri !== auth.userMri && m.id !== auth.userMri)
778
+ .map(m => (m.friendlyName || m.displayName || m.name))
779
+ .filter((name) => !!name);
780
+ if (otherMembers.length > 0) {
781
+ displayName = otherMembers.length <= 3
782
+ ? otherMembers.join(', ')
783
+ : `${otherMembers.slice(0, 3).join(', ')} + ${otherMembers.length - 3} more`;
784
+ }
785
+ }
786
+ }
787
+ // Determine conversation type from productThreadType or ID patterns
788
+ let conversationType;
789
+ // Use productThreadType if available (most accurate)
790
+ if (productType) {
791
+ if (productType === 'Meeting') {
792
+ conversationType = 'Meeting';
793
+ }
794
+ else if (productType.includes('Channel') || productType === 'TeamsTeam') {
795
+ conversationType = 'Channel';
796
+ }
797
+ else if (productType === 'Chat' || productType === 'OneOnOne') {
798
+ conversationType = 'Chat';
799
+ }
800
+ }
801
+ // Fallback to ID pattern detection
802
+ if (!conversationType) {
803
+ if (conversationId.includes('meeting_')) {
804
+ conversationType = 'Meeting';
805
+ }
806
+ else if (threadProps?.groupId) {
807
+ // Has a groupId means it's part of a Team
808
+ conversationType = 'Channel';
809
+ }
810
+ else if (conversationId.includes('@thread.tacv2') || conversationId.includes('@thread.v2')) {
811
+ conversationType = 'Chat';
812
+ }
813
+ else if (conversationId.startsWith('8:')) {
814
+ conversationType = 'Chat';
815
+ }
816
+ }
817
+ return { displayName, conversationType };
818
+ }
819
+ catch {
820
+ return null;
821
+ }
822
+ }
823
+ /**
824
+ * Gets the user's favourite/pinned conversations.
825
+ *
826
+ * Uses the conversationFolders API with CSA token authentication.
827
+ * Requires both the skypetoken (from cookies) and the CSA token (from MSAL).
828
+ *
829
+ * @param region - Region for the API (default: "amer")
830
+ */
831
+ export async function getFavorites(region = 'amer') {
832
+ const auth = extractMessageAuth();
833
+ const csaToken = extractCsaToken();
834
+ if (!auth?.skypeToken || !csaToken) {
835
+ return { success: false, favorites: [], error: 'No valid authentication. Browser login required.' };
836
+ }
837
+ const url = `https://teams.microsoft.com/api/csa/${region}/api/v1/teams/users/me/conversationFolders?supportsAdditionalSystemGeneratedFolders=true&supportsSliceItems=true`;
838
+ try {
839
+ // Use GET request to retrieve folders (POST is only for modifications)
840
+ const response = await fetch(url, {
841
+ method: 'GET',
842
+ headers: {
843
+ 'Authentication': `skypetoken=${auth.skypeToken}`,
844
+ 'Authorization': `Bearer ${csaToken}`,
845
+ 'Accept': 'application/json',
846
+ 'Origin': 'https://teams.microsoft.com',
847
+ 'Referer': 'https://teams.microsoft.com/',
848
+ },
849
+ });
850
+ if (!response.ok) {
851
+ const errorText = await response.text();
852
+ return {
853
+ success: false,
854
+ favorites: [],
855
+ error: `API error: ${response.status} - ${errorText}`
856
+ };
857
+ }
858
+ const data = await response.json();
859
+ // Find the Favorites folder
860
+ const folders = data.conversationFolders;
861
+ const favoritesFolder = folders?.find((f) => {
862
+ const folder = f;
863
+ return folder.folderType === 'Favorites';
864
+ });
865
+ if (!favoritesFolder) {
866
+ return {
867
+ success: true,
868
+ favorites: [],
869
+ folderHierarchyVersion: data.folderHierarchyVersion,
870
+ };
871
+ }
872
+ const items = favoritesFolder.conversationFolderItems;
873
+ const favorites = (items || []).map((item) => {
874
+ const i = item;
875
+ return {
876
+ conversationId: i.conversationId,
877
+ createdTime: i.createdTime,
878
+ lastUpdatedTime: i.lastUpdatedTime,
879
+ };
880
+ });
881
+ // Enrich favorites with display names by fetching conversation properties in parallel
882
+ // We need the message auth for the chatsvc API (different from CSA auth)
883
+ if (auth) {
884
+ const enrichmentPromises = favorites.map(async (fav) => {
885
+ const props = await getConversationProperties(fav.conversationId, auth, region);
886
+ if (props) {
887
+ fav.displayName = props.displayName;
888
+ fav.conversationType = props.conversationType;
889
+ }
890
+ // Fallback: if no display name found, try extracting from recent messages
891
+ if (!fav.displayName) {
892
+ const nameFromMessages = await extractParticipantNamesFromMessages(fav.conversationId, auth, region);
893
+ if (nameFromMessages) {
894
+ fav.displayName = nameFromMessages;
895
+ }
896
+ }
897
+ });
898
+ // Wait for all enrichment calls to complete (with a reasonable timeout)
899
+ await Promise.allSettled(enrichmentPromises);
900
+ }
901
+ return {
902
+ success: true,
903
+ favorites,
904
+ folderHierarchyVersion: data.folderHierarchyVersion,
905
+ folderId: favoritesFolder.id,
906
+ };
907
+ }
908
+ catch (err) {
909
+ return {
910
+ success: false,
911
+ favorites: [],
912
+ error: err instanceof Error ? err.message : 'Unknown error',
913
+ };
914
+ }
915
+ }
916
+ /**
917
+ * Internal helper to modify the favourites folder (add or remove items).
918
+ */
919
+ async function modifyFavorite(conversationId, action, region) {
920
+ const auth = extractMessageAuth();
921
+ const csaToken = extractCsaToken();
922
+ if (!auth?.skypeToken || !csaToken) {
923
+ return { success: false, error: 'No valid authentication. Browser login required.' };
924
+ }
925
+ // Get the current folder state to get the folderId and version
926
+ const currentState = await getFavorites(region);
927
+ if (!currentState.success) {
928
+ return { success: false, error: currentState.error };
929
+ }
930
+ if (!currentState.folderId) {
931
+ return { success: false, error: 'Could not find Favorites folder' };
932
+ }
933
+ const url = `https://teams.microsoft.com/api/csa/${region}/api/v1/teams/users/me/conversationFolders?supportsAdditionalSystemGeneratedFolders=true&supportsSliceItems=true`;
934
+ try {
935
+ const response = await fetch(url, {
936
+ method: 'POST',
937
+ headers: {
938
+ 'Authentication': `skypetoken=${auth.skypeToken}`,
939
+ 'Authorization': `Bearer ${csaToken}`,
940
+ 'Content-Type': 'application/json',
941
+ 'Accept': 'application/json',
942
+ 'Origin': 'https://teams.microsoft.com',
943
+ 'Referer': 'https://teams.microsoft.com/',
944
+ },
945
+ body: JSON.stringify({
946
+ folderHierarchyVersion: currentState.folderHierarchyVersion,
947
+ actions: [
948
+ {
949
+ action,
950
+ folderId: currentState.folderId,
951
+ itemId: conversationId,
952
+ },
953
+ ],
954
+ }),
955
+ });
956
+ if (!response.ok) {
957
+ const errorText = await response.text();
958
+ return {
959
+ success: false,
960
+ error: `API error: ${response.status} - ${errorText}`
961
+ };
962
+ }
963
+ return { success: true };
964
+ }
965
+ catch (err) {
966
+ return {
967
+ success: false,
968
+ error: err instanceof Error ? err.message : 'Unknown error',
969
+ };
970
+ }
971
+ }
972
+ /**
973
+ * Adds a conversation to the user's favourites.
974
+ *
975
+ * @param conversationId - The conversation ID to add
976
+ * @param region - Region for the API (default: "amer")
977
+ */
978
+ export async function addFavorite(conversationId, region = 'amer') {
979
+ return modifyFavorite(conversationId, 'AddItem', region);
980
+ }
981
+ /**
982
+ * Removes a conversation from the user's favourites.
983
+ *
984
+ * @param conversationId - The conversation ID to remove
985
+ * @param region - Region for the API (default: "amer")
986
+ */
987
+ export async function removeFavorite(conversationId, region = 'amer') {
988
+ return modifyFavorite(conversationId, 'RemoveItem', region);
989
+ }
990
+ /**
991
+ * Saves (bookmarks) a message.
992
+ *
993
+ * @param conversationId - The conversation ID containing the message
994
+ * @param messageId - The message ID to save (numeric string)
995
+ * @param region - Region for the API (default: "amer")
996
+ */
997
+ export async function saveMessage(conversationId, messageId, region = 'amer') {
998
+ return setMessageSavedState(conversationId, messageId, true, region);
999
+ }
1000
+ /**
1001
+ * Unsaves (removes bookmark from) a message.
1002
+ *
1003
+ * @param conversationId - The conversation ID containing the message
1004
+ * @param messageId - The message ID to unsave (numeric string)
1005
+ * @param region - Region for the API (default: "amer")
1006
+ */
1007
+ export async function unsaveMessage(conversationId, messageId, region = 'amer') {
1008
+ return setMessageSavedState(conversationId, messageId, false, region);
1009
+ }
1010
+ /**
1011
+ * Gets messages from a Teams conversation/thread.
1012
+ *
1013
+ * This retrieves messages from a conversation, which can be:
1014
+ * - A 1:1 or group chat
1015
+ * - A channel thread
1016
+ * - Self-notes (48:notes)
1017
+ *
1018
+ * @param conversationId - The conversation ID (e.g., "19:abc@thread.tacv2")
1019
+ * @param options - Optional parameters for pagination
1020
+ * @param options.limit - Maximum number of messages to return (default: 50)
1021
+ * @param options.startTime - Only get messages after this timestamp (epoch ms)
1022
+ * @param region - Region for the API (default: "amer")
1023
+ */
1024
+ export async function getThreadMessages(conversationId, options = {}, region = 'amer') {
1025
+ const auth = extractMessageAuth();
1026
+ if (!auth) {
1027
+ return { success: false, error: 'No valid authentication. Browser login required.' };
1028
+ }
1029
+ const limit = options.limit ?? 50;
1030
+ // Build URL with query parameters
1031
+ let url = `https://teams.microsoft.com/api/chatsvc/${region}/v1/users/ME/conversations/${encodeURIComponent(conversationId)}/messages?view=msnp24Equivalent&pageSize=${limit}`;
1032
+ if (options.startTime) {
1033
+ url += `&startTime=${options.startTime}`;
1034
+ }
1035
+ try {
1036
+ const response = await fetch(url, {
1037
+ method: 'GET',
1038
+ headers: {
1039
+ 'Authentication': `skypetoken=${auth.skypeToken}`,
1040
+ 'Authorization': `Bearer ${auth.authToken}`,
1041
+ 'Accept': 'application/json',
1042
+ 'Origin': 'https://teams.microsoft.com',
1043
+ 'Referer': 'https://teams.microsoft.com/',
1044
+ },
1045
+ });
1046
+ if (!response.ok) {
1047
+ const errorText = await response.text();
1048
+ return {
1049
+ success: false,
1050
+ error: `API error: ${response.status} - ${errorText}`
1051
+ };
1052
+ }
1053
+ const data = await response.json();
1054
+ // Parse messages from the response
1055
+ // The API returns { messages: [...] } array
1056
+ const rawMessages = data.messages;
1057
+ if (!Array.isArray(rawMessages)) {
1058
+ return {
1059
+ success: true,
1060
+ conversationId,
1061
+ messages: [],
1062
+ };
1063
+ }
1064
+ const messages = [];
1065
+ for (const raw of rawMessages) {
1066
+ const msg = raw;
1067
+ // Skip non-message types (e.g., typing indicators, control messages)
1068
+ const messageType = msg.messagetype;
1069
+ if (!messageType || messageType.startsWith('Control/') || messageType === 'ThreadActivity/AddMember') {
1070
+ continue;
1071
+ }
1072
+ const id = msg.id || msg.originalarrivaltime;
1073
+ if (!id)
1074
+ continue;
1075
+ const content = msg.content || '';
1076
+ const contentType = msg.messagetype || 'Text';
1077
+ // Parse sender info
1078
+ const fromMri = msg.from || '';
1079
+ const displayName = msg.imdisplayname || msg.displayName;
1080
+ const timestamp = msg.originalarrivaltime ||
1081
+ msg.composetime ||
1082
+ new Date(parseInt(id, 10)).toISOString();
1083
+ // Build message link - id is already the timestamp in milliseconds
1084
+ const messageLink = /^\d+$/.test(id)
1085
+ ? buildMessageLink(conversationId, id)
1086
+ : undefined;
1087
+ messages.push({
1088
+ id,
1089
+ content: stripHtml(content),
1090
+ contentType,
1091
+ sender: {
1092
+ mri: fromMri,
1093
+ displayName,
1094
+ },
1095
+ timestamp,
1096
+ conversationId,
1097
+ clientMessageId: msg.clientmessageid,
1098
+ isFromMe: fromMri === auth.userMri,
1099
+ messageLink,
1100
+ });
1101
+ }
1102
+ // Sort by timestamp (oldest first)
1103
+ messages.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
1104
+ return {
1105
+ success: true,
1106
+ conversationId,
1107
+ messages,
1108
+ };
1109
+ }
1110
+ catch (err) {
1111
+ return {
1112
+ success: false,
1113
+ error: err instanceof Error ? err.message : 'Unknown error',
1114
+ };
1115
+ }
1116
+ }
1117
+ // stripHtml is imported from ../utils/parsers.js
1118
+ /**
1119
+ * Internal function to set the saved state of a message.
1120
+ */
1121
+ async function setMessageSavedState(conversationId, messageId, saved, region) {
1122
+ const auth = extractMessageAuth();
1123
+ if (!auth) {
1124
+ return { success: false, error: 'No valid authentication. Browser login required.' };
1125
+ }
1126
+ const url = `https://teams.microsoft.com/api/chatsvc/${region}/v1/users/ME/conversations/${encodeURIComponent(conversationId)}/rcmetadata/${messageId}`;
1127
+ try {
1128
+ const response = await fetch(url, {
1129
+ method: 'PUT',
1130
+ headers: {
1131
+ 'Authentication': `skypetoken=${auth.skypeToken}`,
1132
+ 'Authorization': `Bearer ${auth.authToken}`,
1133
+ 'Content-Type': 'application/json',
1134
+ 'Accept': 'application/json',
1135
+ 'Origin': 'https://teams.microsoft.com',
1136
+ 'Referer': 'https://teams.microsoft.com/',
1137
+ },
1138
+ body: JSON.stringify({
1139
+ s: saved ? 1 : 0,
1140
+ mid: parseInt(messageId, 10),
1141
+ }),
1142
+ });
1143
+ if (!response.ok) {
1144
+ const errorText = await response.text();
1145
+ return {
1146
+ success: false,
1147
+ error: `API error: ${response.status} - ${errorText}`
1148
+ };
1149
+ }
1150
+ return {
1151
+ success: true,
1152
+ conversationId,
1153
+ messageId,
1154
+ saved,
1155
+ };
1156
+ }
1157
+ catch (err) {
1158
+ return {
1159
+ success: false,
1160
+ error: err instanceof Error ? err.message : 'Unknown error',
1161
+ };
1162
+ }
1163
+ }
1164
+ // parsePersonSuggestion is imported from ../utils/parsers.js
1165
+ // Re-export buildMessageLink for backward compatibility
1166
+ export { buildMessageLink };
1167
+ /**
1168
+ * Gets all teams and channels the user is a member of.
1169
+ *
1170
+ * Uses the CSA v3 teams/users/me endpoint which returns the full
1171
+ * teams and channels structure.
1172
+ *
1173
+ * @param region - Region for the API (default: "amer")
1174
+ */
1175
+ export async function getJoinedTeams(region = 'amer') {
1176
+ const auth = extractMessageAuth();
1177
+ const csaToken = extractCsaToken();
1178
+ if (!auth?.skypeToken || !csaToken) {
1179
+ return { success: false, error: 'No valid authentication. Browser login required.' };
1180
+ }
1181
+ const url = `https://teams.microsoft.com/api/csa/${region}/api/v3/teams/users/me?isPrefetch=false&enableMembershipSummary=true&supportsAdditionalSystemGeneratedFolders=true&supportsSliceItems=true&enableEngageCommunities=false`;
1182
+ try {
1183
+ const response = await fetch(url, {
1184
+ method: 'GET',
1185
+ headers: {
1186
+ 'Authentication': `skypetoken=${auth.skypeToken}`,
1187
+ 'Authorization': `Bearer ${csaToken}`,
1188
+ 'Accept': 'application/json',
1189
+ 'Origin': 'https://teams.microsoft.com',
1190
+ 'Referer': 'https://teams.microsoft.com/',
1191
+ },
1192
+ });
1193
+ if (!response.ok) {
1194
+ const errorText = await response.text();
1195
+ return {
1196
+ success: false,
1197
+ error: `API error: ${response.status} - ${errorText}`
1198
+ };
1199
+ }
1200
+ const data = await response.json();
1201
+ // Parse the teams array from the response
1202
+ const rawTeams = data.teams;
1203
+ if (!Array.isArray(rawTeams)) {
1204
+ return {
1205
+ success: true,
1206
+ teams: [],
1207
+ };
1208
+ }
1209
+ const teams = rawTeams.map((t) => {
1210
+ const team = t;
1211
+ const rawChannels = team.channels;
1212
+ const channels = (rawChannels || []).map((c) => {
1213
+ const channel = c;
1214
+ return {
1215
+ id: channel.id,
1216
+ displayName: channel.displayName || 'Unknown Channel',
1217
+ description: channel.description,
1218
+ isFavorite: channel.isFavorite,
1219
+ membershipType: channel.membershipType,
1220
+ };
1221
+ });
1222
+ return {
1223
+ id: team.threadId || team.id,
1224
+ displayName: team.displayName || 'Unknown Team',
1225
+ description: team.description,
1226
+ pictureETag: team.pictureETag,
1227
+ isFavorite: team.isFavorite,
1228
+ channels,
1229
+ };
1230
+ });
1231
+ return {
1232
+ success: true,
1233
+ teams,
1234
+ };
1235
+ }
1236
+ catch (err) {
1237
+ return {
1238
+ success: false,
1239
+ error: err instanceof Error ? err.message : 'Unknown error',
1240
+ };
1241
+ }
1242
+ }
1243
+ /**
1244
+ * Gets channels for a specific team.
1245
+ *
1246
+ * This is a convenience wrapper that calls getJoinedTeams and filters
1247
+ * to the specified team.
1248
+ *
1249
+ * @param teamId - The team ID to get channels for
1250
+ * @param region - Region for the API (default: "amer")
1251
+ */
1252
+ export async function getTeamChannels(teamId, region = 'amer') {
1253
+ const result = await getJoinedTeams(region);
1254
+ if (!result.success) {
1255
+ return { success: false, error: result.error };
1256
+ }
1257
+ const team = result.teams?.find(t => t.id === teamId);
1258
+ if (!team) {
1259
+ return { success: false, error: `Team not found: ${teamId}` };
1260
+ }
1261
+ return {
1262
+ success: true,
1263
+ channels: team.channels,
1264
+ };
1265
+ }
1266
+ /**
1267
+ * Finds channels by name across all teams the user is a member of.
1268
+ *
1269
+ * Performs a case-insensitive substring match on channel names.
1270
+ *
1271
+ * @param query - The channel name to search for (partial match)
1272
+ * @param options - Optional filters
1273
+ * @param options.teamName - Filter to a specific team name (partial match)
1274
+ * @param region - Region for the API (default: "amer")
1275
+ */
1276
+ export async function findChannel(query, options = {}, region = 'amer') {
1277
+ const result = await getJoinedTeams(region);
1278
+ if (!result.success) {
1279
+ return { success: false, error: result.error };
1280
+ }
1281
+ const queryLower = query.toLowerCase();
1282
+ const teamNameLower = options.teamName?.toLowerCase();
1283
+ const matches = [];
1284
+ for (const team of result.teams || []) {
1285
+ // Skip if team name filter is specified and doesn't match
1286
+ if (teamNameLower && !team.displayName.toLowerCase().includes(teamNameLower)) {
1287
+ continue;
1288
+ }
1289
+ for (const channel of team.channels) {
1290
+ if (channel.displayName.toLowerCase().includes(queryLower)) {
1291
+ matches.push({
1292
+ channel,
1293
+ team: {
1294
+ id: team.id,
1295
+ displayName: team.displayName,
1296
+ },
1297
+ });
1298
+ }
1299
+ }
1300
+ }
1301
+ return {
1302
+ success: true,
1303
+ channels: matches,
1304
+ };
1305
+ }