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,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
|
+
}
|