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