@michaelschnyder/teams-cli 0.1.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/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/SECURITY.md +21 -0
- package/dist/auth.js +337 -0
- package/dist/cli.js +647 -0
- package/dist/commands/skills.js +70 -0
- package/dist/commands/version.js +16 -0
- package/dist/config.js +106 -0
- package/dist/constants.js +8 -0
- package/dist/data.js +56 -0
- package/dist/diagnostics.js +60 -0
- package/dist/jwt.js +42 -0
- package/dist/oauth.js +146 -0
- package/dist/policy.js +328 -0
- package/dist/skills/teams-authentication/SKILL.md +30 -0
- package/dist/skills/teams-cli/SKILL.md +34 -0
- package/dist/skills/teams-messaging-policies/SKILL.md +26 -0
- package/dist/skills/teams-reading/SKILL.md +29 -0
- package/dist/skills.js +156 -0
- package/dist/storage.js +147 -0
- package/dist/teams-auth.js +54 -0
- package/dist/teams-client.js +604 -0
- package/dist/update.js +137 -0
- package/dist/upgrade.js +45 -0
- package/dist/version.js +7 -0
- package/dist/yaml.js +33 -0
- package/docs/releasing.md +48 -0
- package/docs/use/authentication.md +25 -0
- package/docs/use/policies.md +136 -0
- package/docs/use/profiles.md +57 -0
- package/package.json +64 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { OUTLOOK_SEARCH_URL, TEAMS_WEB_ORIGIN } from "./constants.js";
|
|
3
|
+
import { readJwtMetadata } from "./jwt.js";
|
|
4
|
+
import { observedFetch } from "./diagnostics.js";
|
|
5
|
+
export class TeamsApiError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
tokenTarget;
|
|
8
|
+
constructor(status, message, tokenTarget) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.tokenTarget = tokenTarget;
|
|
12
|
+
this.name = "TeamsApiError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function stringValue(value) {
|
|
16
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
17
|
+
}
|
|
18
|
+
function booleanValue(value, fallback) {
|
|
19
|
+
return typeof value === "boolean" ? value : fallback;
|
|
20
|
+
}
|
|
21
|
+
function nullableBoolean(value) {
|
|
22
|
+
return typeof value === "boolean" ? value : null;
|
|
23
|
+
}
|
|
24
|
+
function stringArray(value) {
|
|
25
|
+
return Array.isArray(value)
|
|
26
|
+
? value.filter((entry) => typeof entry === "string" && entry.length > 0)
|
|
27
|
+
: [];
|
|
28
|
+
}
|
|
29
|
+
function firstEmailAddress(value) {
|
|
30
|
+
if (!Array.isArray(value))
|
|
31
|
+
return null;
|
|
32
|
+
for (const entry of value) {
|
|
33
|
+
if (typeof entry === "string" && entry.length > 0)
|
|
34
|
+
return entry;
|
|
35
|
+
if (!entry || typeof entry !== "object")
|
|
36
|
+
continue;
|
|
37
|
+
const email = entry;
|
|
38
|
+
const address = stringValue(email.Address) ?? stringValue(email.address) ??
|
|
39
|
+
stringValue(email.EmailAddress) ?? stringValue(email.emailAddress);
|
|
40
|
+
if (address)
|
|
41
|
+
return address;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function personIdentifiers(person) {
|
|
46
|
+
const mri = stringValue(person.mri) ?? stringValue(person.MRI);
|
|
47
|
+
const objectId = stringValue(person.objectId) ?? stringValue(person.ObjectId) ??
|
|
48
|
+
stringValue(person.Id) ?? stringValue(person.id);
|
|
49
|
+
const id = objectId ?? (mri?.startsWith("8:orgid:") ? mri.slice("8:orgid:".length) : mri);
|
|
50
|
+
return id ? { id, mri } : null;
|
|
51
|
+
}
|
|
52
|
+
function normalizePersonSummary(value) {
|
|
53
|
+
if (!value || typeof value !== "object")
|
|
54
|
+
return null;
|
|
55
|
+
const person = value;
|
|
56
|
+
const identifiers = personIdentifiers(person);
|
|
57
|
+
if (!identifiers)
|
|
58
|
+
return null;
|
|
59
|
+
return {
|
|
60
|
+
...identifiers,
|
|
61
|
+
displayName: stringValue(person.displayName) ?? stringValue(person.DisplayName) ??
|
|
62
|
+
stringValue(person.friendlyName),
|
|
63
|
+
email: stringValue(person.email) ?? stringValue(person.Email) ??
|
|
64
|
+
stringValue(person.mail) ?? stringValue(person.Mail) ??
|
|
65
|
+
stringValue(person.emailAddress) ?? stringValue(person.EmailAddress) ??
|
|
66
|
+
firstEmailAddress(person.emailAddresses) ?? firstEmailAddress(person.EmailAddresses) ??
|
|
67
|
+
stringValue(person.userPrincipalName) ?? stringValue(person.UserPrincipalName),
|
|
68
|
+
jobTitle: stringValue(person.jobTitle) ?? stringValue(person.JobTitle),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function normalizePhones(value) {
|
|
72
|
+
if (!Array.isArray(value))
|
|
73
|
+
return [];
|
|
74
|
+
const phones = [];
|
|
75
|
+
for (const entry of value) {
|
|
76
|
+
if (!entry || typeof entry !== "object")
|
|
77
|
+
continue;
|
|
78
|
+
const phone = entry;
|
|
79
|
+
const number = stringValue(phone.number) ?? stringValue(phone.Number);
|
|
80
|
+
if (!number)
|
|
81
|
+
continue;
|
|
82
|
+
phones.push({ type: stringValue(phone.type) ?? stringValue(phone.Type), number });
|
|
83
|
+
}
|
|
84
|
+
return phones;
|
|
85
|
+
}
|
|
86
|
+
function normalizePersonProfile(value) {
|
|
87
|
+
if (!value || typeof value !== "object")
|
|
88
|
+
return null;
|
|
89
|
+
const person = value;
|
|
90
|
+
const identifiers = personIdentifiers(person);
|
|
91
|
+
if (!identifiers)
|
|
92
|
+
return null;
|
|
93
|
+
const mail = stringValue(person.mail) ?? stringValue(person.Mail);
|
|
94
|
+
const userPrincipalName = stringValue(person.userPrincipalName) ??
|
|
95
|
+
stringValue(person.UserPrincipalName);
|
|
96
|
+
const email = stringValue(person.email) ?? stringValue(person.Email) ?? mail ?? userPrincipalName;
|
|
97
|
+
const skypeTeamsInfo = person.skypeTeamsInfo ?? person.SkypeTeamsInfo;
|
|
98
|
+
const teamsEnabled = skypeTeamsInfo && typeof skypeTeamsInfo === "object"
|
|
99
|
+
? nullableBoolean(skypeTeamsInfo.isSkypeTeamsUser ??
|
|
100
|
+
skypeTeamsInfo.IsSkypeTeamsUser)
|
|
101
|
+
: null;
|
|
102
|
+
return {
|
|
103
|
+
...identifiers,
|
|
104
|
+
displayName: stringValue(person.displayName) ?? stringValue(person.DisplayName) ??
|
|
105
|
+
stringValue(person.friendlyName),
|
|
106
|
+
givenName: stringValue(person.givenName) ?? stringValue(person.GivenName),
|
|
107
|
+
surname: stringValue(person.surname) ?? stringValue(person.Surname),
|
|
108
|
+
email,
|
|
109
|
+
mail,
|
|
110
|
+
userPrincipalName,
|
|
111
|
+
smtpAddresses: stringArray(person.smtpAddresses ?? person.SmtpAddresses),
|
|
112
|
+
jobTitle: stringValue(person.jobTitle) ?? stringValue(person.JobTitle),
|
|
113
|
+
department: stringValue(person.department) ?? stringValue(person.Department),
|
|
114
|
+
officeLocation: stringValue(person.physicalDeliveryOfficeName) ??
|
|
115
|
+
stringValue(person.PhysicalDeliveryOfficeName) ??
|
|
116
|
+
stringValue(person.userLocation) ?? stringValue(person.UserLocation),
|
|
117
|
+
mobile: stringValue(person.mobile) ?? stringValue(person.Mobile),
|
|
118
|
+
telephoneNumber: stringValue(person.telephoneNumber) ?? stringValue(person.TelephoneNumber),
|
|
119
|
+
phones: normalizePhones(person.phones ?? person.Phones),
|
|
120
|
+
tenantName: stringValue(person.tenantName) ?? stringValue(person.TenantName),
|
|
121
|
+
userType: stringValue(person.userType) ?? stringValue(person.UserType),
|
|
122
|
+
accountEnabled: nullableBoolean(person.accountEnabled ?? person.AccountEnabled),
|
|
123
|
+
teamsEnabled,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function normalizeParticipant(value) {
|
|
127
|
+
if (typeof value === "string") {
|
|
128
|
+
return { id: value, displayName: null, tenantId: null, objectId: null, role: null };
|
|
129
|
+
}
|
|
130
|
+
if (!value || typeof value !== "object")
|
|
131
|
+
return null;
|
|
132
|
+
const participant = value;
|
|
133
|
+
const id = stringValue(participant.mri) ?? stringValue(participant.MRI) ?? stringValue(participant.id);
|
|
134
|
+
if (!id)
|
|
135
|
+
return null;
|
|
136
|
+
return {
|
|
137
|
+
id,
|
|
138
|
+
displayName: stringValue(participant.friendlyName) ??
|
|
139
|
+
stringValue(participant.displayName) ??
|
|
140
|
+
stringValue(participant.DisplayName),
|
|
141
|
+
tenantId: stringValue(participant.tenantId) ?? stringValue(participant.TenantId),
|
|
142
|
+
objectId: stringValue(participant.objectId) ?? stringValue(participant.ExternalDirectoryObjectId),
|
|
143
|
+
role: stringValue(participant.role) ?? stringValue(participant.Role),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function normalizeChat(value, userNames = new Map()) {
|
|
147
|
+
if (!value || typeof value !== "object")
|
|
148
|
+
return null;
|
|
149
|
+
const chat = value;
|
|
150
|
+
const id = stringValue(chat.id) ?? stringValue(chat.ThreadId);
|
|
151
|
+
if (!id)
|
|
152
|
+
return null;
|
|
153
|
+
const rawParticipants = Array.isArray(chat.members)
|
|
154
|
+
? chat.members
|
|
155
|
+
: Array.isArray(chat.ChatMembers)
|
|
156
|
+
? chat.ChatMembers
|
|
157
|
+
: [];
|
|
158
|
+
// Search suggestions separate the matched person from the sampled chat roster.
|
|
159
|
+
// Put matches first so the reason for the server result remains visible.
|
|
160
|
+
const matchingParticipants = Array.isArray(chat.MatchingMembers) ? chat.MatchingMembers : [];
|
|
161
|
+
const participants = [...matchingParticipants, ...rawParticipants]
|
|
162
|
+
.map(normalizeParticipant)
|
|
163
|
+
.filter((participant) => participant !== null);
|
|
164
|
+
const seenParticipantIds = new Set();
|
|
165
|
+
const uniqueParticipants = participants.filter((participant) => {
|
|
166
|
+
if (seenParticipantIds.has(participant.id))
|
|
167
|
+
return false;
|
|
168
|
+
seenParticipantIds.add(participant.id);
|
|
169
|
+
return true;
|
|
170
|
+
});
|
|
171
|
+
for (const participant of uniqueParticipants) {
|
|
172
|
+
participant.displayName ??= userNames.get(participant.id) ?? null;
|
|
173
|
+
}
|
|
174
|
+
const explicitTitle = stringValue(chat.title) ?? stringValue(chat.Name);
|
|
175
|
+
const participantTitle = uniqueParticipants
|
|
176
|
+
.map((participant) => participant.displayName)
|
|
177
|
+
.filter((name) => name !== null)
|
|
178
|
+
.join(", ");
|
|
179
|
+
const lastActivity = stringValue(chat.LastMessageTime) ??
|
|
180
|
+
stringValue(chat.lastMessage?.composeTime) ??
|
|
181
|
+
stringValue(chat.lastMessage?.originalArrivalTime);
|
|
182
|
+
return {
|
|
183
|
+
id,
|
|
184
|
+
title: explicitTitle ?? (participantTitle || id),
|
|
185
|
+
type: stringValue(chat.chatType) ?? stringValue(chat.ThreadType),
|
|
186
|
+
oneOnOne: booleanValue(chat.isOneOnOne, false),
|
|
187
|
+
hidden: booleanValue(chat.hidden, false),
|
|
188
|
+
disabled: booleanValue(chat.isDisabled, false),
|
|
189
|
+
read: typeof chat.isRead === "boolean" ? chat.isRead : null,
|
|
190
|
+
lastActivity,
|
|
191
|
+
participants: uniqueParticipants,
|
|
192
|
+
participantCount: typeof chat.TotalChatMembersCount === "number"
|
|
193
|
+
? chat.TotalChatMembersCount
|
|
194
|
+
: uniqueParticipants.length,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function normalizeChannels(value) {
|
|
198
|
+
if (!Array.isArray(value))
|
|
199
|
+
return [];
|
|
200
|
+
const channels = [];
|
|
201
|
+
for (const entry of value) {
|
|
202
|
+
if (!entry || typeof entry !== "object")
|
|
203
|
+
continue;
|
|
204
|
+
const team = entry;
|
|
205
|
+
const teamId = stringValue(team.id) ?? stringValue(team.threadId);
|
|
206
|
+
const teamName = stringValue(team.name) ?? stringValue(team.displayName) ?? teamId;
|
|
207
|
+
if (!teamId || !teamName || !Array.isArray(team.channels))
|
|
208
|
+
continue;
|
|
209
|
+
for (const item of team.channels) {
|
|
210
|
+
if (!item || typeof item !== "object")
|
|
211
|
+
continue;
|
|
212
|
+
const channel = item;
|
|
213
|
+
const id = stringValue(channel.id) ?? stringValue(channel.threadId);
|
|
214
|
+
const name = stringValue(channel.name) ?? stringValue(channel.displayName) ?? id;
|
|
215
|
+
if (!id || !name)
|
|
216
|
+
continue;
|
|
217
|
+
channels.push({
|
|
218
|
+
id,
|
|
219
|
+
name,
|
|
220
|
+
description: stringValue(channel.description),
|
|
221
|
+
team: { id: teamId, name: teamName },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return channels;
|
|
226
|
+
}
|
|
227
|
+
function normalizeMessage(value, fallbackChatId) {
|
|
228
|
+
if (!value || typeof value !== "object")
|
|
229
|
+
return null;
|
|
230
|
+
const message = value;
|
|
231
|
+
const id = stringValue(message.id);
|
|
232
|
+
if (!id)
|
|
233
|
+
return null;
|
|
234
|
+
const properties = message.properties && typeof message.properties === "object" &&
|
|
235
|
+
!Array.isArray(message.properties)
|
|
236
|
+
? message.properties
|
|
237
|
+
: {};
|
|
238
|
+
const numericOrString = (input) => typeof input === "number" || typeof input === "string" ? input : null;
|
|
239
|
+
return {
|
|
240
|
+
id,
|
|
241
|
+
chatId: stringValue(message.conversationid) ?? fallbackChatId,
|
|
242
|
+
sequenceId: numericOrString(message.sequenceId),
|
|
243
|
+
version: numericOrString(message.version),
|
|
244
|
+
type: stringValue(message.type),
|
|
245
|
+
messageType: stringValue(message.messagetype),
|
|
246
|
+
contentType: stringValue(message.contenttype),
|
|
247
|
+
content: typeof message.content === "string" ? message.content : null,
|
|
248
|
+
sender: {
|
|
249
|
+
id: stringValue(message.from),
|
|
250
|
+
displayName: stringValue(message.imdisplayname),
|
|
251
|
+
},
|
|
252
|
+
composedAt: stringValue(message.composetime),
|
|
253
|
+
originalArrivalAt: stringValue(message.originalarrivaltime),
|
|
254
|
+
properties,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function encodeCursor(cursor) {
|
|
258
|
+
return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
|
|
259
|
+
}
|
|
260
|
+
function decodeCursor(encoded) {
|
|
261
|
+
let parsed;
|
|
262
|
+
try {
|
|
263
|
+
parsed = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
throw new Error("Invalid paging cursor");
|
|
267
|
+
}
|
|
268
|
+
if (!parsed || typeof parsed !== "object")
|
|
269
|
+
throw new Error("Invalid paging cursor");
|
|
270
|
+
const cursor = parsed;
|
|
271
|
+
if (cursor.version !== 1 || (cursor.kind !== "chats" && cursor.kind !== "messages")) {
|
|
272
|
+
throw new Error("Unsupported paging cursor");
|
|
273
|
+
}
|
|
274
|
+
if (typeof cursor.tenantId !== "string")
|
|
275
|
+
throw new Error("Invalid paging cursor");
|
|
276
|
+
if (cursor.kind === "chats" && typeof cursor.syncToken === "string")
|
|
277
|
+
return cursor;
|
|
278
|
+
if (cursor.kind === "messages" &&
|
|
279
|
+
typeof cursor.chatId === "string" &&
|
|
280
|
+
typeof cursor.url === "string")
|
|
281
|
+
return cursor;
|
|
282
|
+
throw new Error("Invalid paging cursor");
|
|
283
|
+
}
|
|
284
|
+
function requireCursorTenant(cursor, session) {
|
|
285
|
+
if (cursor.tenantId !== session.tenantId) {
|
|
286
|
+
throw new Error("Paging cursor belongs to a different Teams tenant");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function csaUrl(updates) {
|
|
290
|
+
const url = new URL(`/api/csa/api/v1/teams/users/me${updates ? "/updates" : ""}`, TEAMS_WEB_ORIGIN);
|
|
291
|
+
url.searchParams.set("isPrefetch", "false");
|
|
292
|
+
url.searchParams.set("enableMembershipSummary", "true");
|
|
293
|
+
url.searchParams.set("supportsAdditionalSystemGeneratedFolders", "true");
|
|
294
|
+
url.searchParams.set("supportsSliceItems", "true");
|
|
295
|
+
return url;
|
|
296
|
+
}
|
|
297
|
+
async function jsonResponse(response, operation, tokenTarget) {
|
|
298
|
+
const raw = await response.text();
|
|
299
|
+
if (!response.ok) {
|
|
300
|
+
throw new TeamsApiError(response.status, `${operation} failed (${response.status} ${response.statusText})`, tokenTarget);
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
return JSON.parse(raw);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
throw new Error(`${operation} returned invalid JSON`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
export async function listChats(session, cursorValue, fetchImplementation = fetch) {
|
|
310
|
+
let syncToken;
|
|
311
|
+
if (cursorValue) {
|
|
312
|
+
const cursor = decodeCursor(cursorValue);
|
|
313
|
+
if (cursor.kind !== "chats")
|
|
314
|
+
throw new Error("Expected a chat paging cursor");
|
|
315
|
+
requireCursorTenant(cursor, session);
|
|
316
|
+
syncToken = cursor.syncToken;
|
|
317
|
+
}
|
|
318
|
+
const response = await observedFetch(fetchImplementation, csaUrl(Boolean(syncToken)), {
|
|
319
|
+
headers: {
|
|
320
|
+
authorization: `Bearer ${session.chatToken.value}`,
|
|
321
|
+
"x-skypetoken": session.skypeToken.value,
|
|
322
|
+
...(syncToken ? { "x-ms-synctoken": syncToken } : {}),
|
|
323
|
+
accept: "application/json",
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
const payload = await jsonResponse(response, "Chat discovery", "chat");
|
|
327
|
+
const userNames = new Map();
|
|
328
|
+
if (Array.isArray(payload.users)) {
|
|
329
|
+
for (const value of payload.users) {
|
|
330
|
+
if (!value || typeof value !== "object")
|
|
331
|
+
continue;
|
|
332
|
+
const user = value;
|
|
333
|
+
const id = stringValue(user.mri) ?? stringValue(user.MRI);
|
|
334
|
+
const name = stringValue(user.displayName) ?? stringValue(user.DisplayName) ??
|
|
335
|
+
stringValue(user.email) ?? stringValue(user.userPrincipalName);
|
|
336
|
+
if (id && name)
|
|
337
|
+
userNames.set(id, name);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const chats = Array.isArray(payload.chats)
|
|
341
|
+
? payload.chats
|
|
342
|
+
.map((chat) => normalizeChat(chat, userNames))
|
|
343
|
+
.filter((chat) => chat !== null)
|
|
344
|
+
: [];
|
|
345
|
+
const nextSyncToken = stringValue(payload.metadata?.syncToken);
|
|
346
|
+
const nextCursor = payload.metadata?.hasMoreChats === true && nextSyncToken
|
|
347
|
+
? encodeCursor({ version: 1, kind: "chats", tenantId: session.tenantId, syncToken: nextSyncToken })
|
|
348
|
+
: null;
|
|
349
|
+
return { chats, page: { nextCursor } };
|
|
350
|
+
}
|
|
351
|
+
export async function searchPeople(session, query, fetchImplementation = fetch) {
|
|
352
|
+
const trimmed = query.trim();
|
|
353
|
+
if (!trimmed)
|
|
354
|
+
throw new Error("Person search query must not be empty");
|
|
355
|
+
const url = new URL(OUTLOOK_SEARCH_URL);
|
|
356
|
+
url.searchParams.set("scenario", "powerbar");
|
|
357
|
+
const response = await observedFetch(fetchImplementation, url, {
|
|
358
|
+
method: "POST",
|
|
359
|
+
headers: {
|
|
360
|
+
authorization: `Bearer ${session.searchToken.value}`,
|
|
361
|
+
"content-type": "application/json",
|
|
362
|
+
accept: "application/json",
|
|
363
|
+
},
|
|
364
|
+
body: JSON.stringify({
|
|
365
|
+
EntityRequests: [{
|
|
366
|
+
Query: {
|
|
367
|
+
QueryString: trimmed,
|
|
368
|
+
DisplayQueryString: trimmed,
|
|
369
|
+
NormalizedQueryString: trimmed,
|
|
370
|
+
},
|
|
371
|
+
EntityType: "People",
|
|
372
|
+
Size: 25,
|
|
373
|
+
}],
|
|
374
|
+
Scenario: { Name: "powerbar", Dimensions: [] },
|
|
375
|
+
Cvid: randomUUID(),
|
|
376
|
+
AppName: "Microsoft Teams",
|
|
377
|
+
LogicalId: randomUUID(),
|
|
378
|
+
dataSource: "personScoped",
|
|
379
|
+
}),
|
|
380
|
+
});
|
|
381
|
+
const payload = await jsonResponse(response, "People search", "search");
|
|
382
|
+
const group = payload.Groups?.find((candidate) => candidate.Type === "People");
|
|
383
|
+
const people = Array.isArray(group?.Suggestions)
|
|
384
|
+
? group.Suggestions
|
|
385
|
+
.map(normalizePersonSummary)
|
|
386
|
+
.filter((person) => person !== null)
|
|
387
|
+
: [];
|
|
388
|
+
return { query: trimmed, people };
|
|
389
|
+
}
|
|
390
|
+
function middleTierBaseUrl(session) {
|
|
391
|
+
if (!session.endpoints.middleTier) {
|
|
392
|
+
return new URL(`/api/mt/${encodeURIComponent(session.region)}/beta/`, TEAMS_WEB_ORIGIN);
|
|
393
|
+
}
|
|
394
|
+
const url = new URL(session.endpoints.middleTier);
|
|
395
|
+
if (url.protocol !== "https:" ||
|
|
396
|
+
(url.hostname !== "teams.microsoft.com" && !url.hostname.endsWith(".teams.microsoft.com"))) {
|
|
397
|
+
throw new Error("Stored Teams middle-tier endpoint is not trusted");
|
|
398
|
+
}
|
|
399
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
400
|
+
url.pathname = `${path.endsWith("/beta") ? path : `${path}/beta`}/`;
|
|
401
|
+
url.search = "";
|
|
402
|
+
url.hash = "";
|
|
403
|
+
return url;
|
|
404
|
+
}
|
|
405
|
+
function personUrl(session, identifier, suffix = "") {
|
|
406
|
+
const trimmed = identifier.trim();
|
|
407
|
+
if (!trimmed)
|
|
408
|
+
throw new Error("Person identifier must not be empty");
|
|
409
|
+
const url = new URL(`users/${encodeURIComponent(trimmed)}/${suffix}`, middleTierBaseUrl(session));
|
|
410
|
+
url.searchParams.set("isMailAddress", String(trimmed.includes("@")));
|
|
411
|
+
url.searchParams.set("enableGuest", "true");
|
|
412
|
+
url.searchParams.set("includeIBBarredUsers", "true");
|
|
413
|
+
url.searchParams.set("skypeTeamsInfo", "true");
|
|
414
|
+
return url;
|
|
415
|
+
}
|
|
416
|
+
export async function getPerson(session, identifier, fetchImplementation = fetch) {
|
|
417
|
+
const response = await observedFetch(fetchImplementation, personUrl(session, identifier), {
|
|
418
|
+
headers: {
|
|
419
|
+
authorization: `Bearer ${session.accessToken.value}`,
|
|
420
|
+
accept: "application/json",
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
const payload = await jsonResponse(response, "Person lookup", "access");
|
|
424
|
+
const person = normalizePersonProfile(payload.value ?? payload);
|
|
425
|
+
if (!person)
|
|
426
|
+
throw new Error("Person lookup returned no person");
|
|
427
|
+
return { person };
|
|
428
|
+
}
|
|
429
|
+
function decodeBase64Image(raw) {
|
|
430
|
+
const encoded = raw.toString("utf8").trim();
|
|
431
|
+
if (!encoded || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded) || encoded.length % 4 === 1) {
|
|
432
|
+
throw new Error("Person image lookup returned invalid image data");
|
|
433
|
+
}
|
|
434
|
+
const decoded = Buffer.from(encoded.padEnd(Math.ceil(encoded.length / 4) * 4, "="), "base64");
|
|
435
|
+
if (!decoded.length)
|
|
436
|
+
throw new Error("Person image lookup returned an empty image");
|
|
437
|
+
return decoded;
|
|
438
|
+
}
|
|
439
|
+
export async function getPersonImage(session, identifier, size = "max", fetchImplementation = fetch) {
|
|
440
|
+
const url = personUrl(session, identifier, "profilepicture");
|
|
441
|
+
url.searchParams.set("displayname", identifier.trim());
|
|
442
|
+
url.searchParams.set("size", `HR${size === "max" ? "648" : size}x${size === "max" ? "648" : size}`);
|
|
443
|
+
const response = await observedFetch(fetchImplementation, url, {
|
|
444
|
+
headers: { authorization: `Bearer ${session.accessToken.value}` },
|
|
445
|
+
});
|
|
446
|
+
if (response.status === 404) {
|
|
447
|
+
throw new TeamsApiError(404, `No profile image found for: ${identifier}`, "access");
|
|
448
|
+
}
|
|
449
|
+
if (!response.ok) {
|
|
450
|
+
throw new TeamsApiError(response.status, `Person image lookup failed (${response.status} ${response.statusText})`, "access");
|
|
451
|
+
}
|
|
452
|
+
const raw = Buffer.from(await response.arrayBuffer());
|
|
453
|
+
if (!raw.length)
|
|
454
|
+
throw new Error("Person image lookup returned an empty image");
|
|
455
|
+
const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim();
|
|
456
|
+
return contentType?.startsWith("image/")
|
|
457
|
+
? { data: raw, contentType }
|
|
458
|
+
: { data: decodeBase64Image(raw), contentType: "image/jpeg" };
|
|
459
|
+
}
|
|
460
|
+
async function discoveryPayload(session, fetchImplementation) {
|
|
461
|
+
const response = await observedFetch(fetchImplementation, csaUrl(false), {
|
|
462
|
+
headers: {
|
|
463
|
+
authorization: `Bearer ${session.chatToken.value}`,
|
|
464
|
+
"x-skypetoken": session.skypeToken.value,
|
|
465
|
+
accept: "application/json",
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
return await jsonResponse(response, "Teams discovery", "chat");
|
|
469
|
+
}
|
|
470
|
+
export async function getChat(session, chatId, fetchImplementation = fetch) {
|
|
471
|
+
let cursor;
|
|
472
|
+
do {
|
|
473
|
+
const result = await listChats(session, cursor, fetchImplementation);
|
|
474
|
+
const chat = result.chats.find((candidate) => candidate.id === chatId);
|
|
475
|
+
if (chat)
|
|
476
|
+
return { chat };
|
|
477
|
+
cursor = result.page.nextCursor ?? undefined;
|
|
478
|
+
} while (cursor);
|
|
479
|
+
throw new Error(`Chat not found: ${chatId}`);
|
|
480
|
+
}
|
|
481
|
+
export async function listChannels(session, fetchImplementation = fetch) {
|
|
482
|
+
const payload = await discoveryPayload(session, fetchImplementation);
|
|
483
|
+
return { channels: normalizeChannels(payload.teams) };
|
|
484
|
+
}
|
|
485
|
+
export async function getChannel(session, channelId, fetchImplementation = fetch) {
|
|
486
|
+
const result = await listChannels(session, fetchImplementation);
|
|
487
|
+
const channel = result.channels.find((candidate) => candidate.id === channelId);
|
|
488
|
+
if (!channel)
|
|
489
|
+
throw new Error(`Channel not found: ${channelId}`);
|
|
490
|
+
return { channel };
|
|
491
|
+
}
|
|
492
|
+
function messagePath(chatId) {
|
|
493
|
+
return `/v1/users/ME/conversations/${encodeURIComponent(chatId)}/messages`;
|
|
494
|
+
}
|
|
495
|
+
function initialMessageUrl(session, chatId, options) {
|
|
496
|
+
const url = new URL(messagePath(chatId), session.endpoints.chatService);
|
|
497
|
+
url.searchParams.set("view", "msnp24Equivalent|supportsMessageProperties");
|
|
498
|
+
url.searchParams.set("pageSize", String(options.pageSize ?? 200));
|
|
499
|
+
url.searchParams.set("startTime", "1");
|
|
500
|
+
return url;
|
|
501
|
+
}
|
|
502
|
+
function continuedMessageUrl(session, chatId, encoded) {
|
|
503
|
+
const cursor = decodeCursor(encoded);
|
|
504
|
+
if (cursor.kind !== "messages")
|
|
505
|
+
throw new Error("Expected a message paging cursor");
|
|
506
|
+
requireCursorTenant(cursor, session);
|
|
507
|
+
if (cursor.chatId !== chatId)
|
|
508
|
+
throw new Error("Paging cursor belongs to a different chat");
|
|
509
|
+
const url = new URL(cursor.url);
|
|
510
|
+
const endpoint = new URL(session.endpoints.chatService);
|
|
511
|
+
if (url.protocol !== "https:" || url.origin !== endpoint.origin) {
|
|
512
|
+
throw new Error("Message paging cursor points to an untrusted host");
|
|
513
|
+
}
|
|
514
|
+
if (decodeURIComponent(url.pathname) !== decodeURIComponent(messagePath(chatId))) {
|
|
515
|
+
throw new Error("Message paging cursor has an invalid path");
|
|
516
|
+
}
|
|
517
|
+
return url;
|
|
518
|
+
}
|
|
519
|
+
export async function listMessages(session, target, options, fetchImplementation = fetch) {
|
|
520
|
+
const url = options.cursor
|
|
521
|
+
? continuedMessageUrl(session, target.id, options.cursor)
|
|
522
|
+
: initialMessageUrl(session, target.id, options);
|
|
523
|
+
const response = await observedFetch(fetchImplementation, url, {
|
|
524
|
+
headers: {
|
|
525
|
+
authentication: `skypetoken=${session.skypeToken.value}`,
|
|
526
|
+
accept: "application/json",
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
const payload = await jsonResponse(response, "Message listing", "skype");
|
|
530
|
+
const messages = Array.isArray(payload.messages)
|
|
531
|
+
? payload.messages
|
|
532
|
+
.map((message) => normalizeMessage(message, target.id))
|
|
533
|
+
.filter((message) => message !== null)
|
|
534
|
+
: [];
|
|
535
|
+
const backwardLink = stringValue(payload._metadata?.backwardLink);
|
|
536
|
+
const nextCursor = backwardLink
|
|
537
|
+
? encodeCursor({
|
|
538
|
+
version: 1,
|
|
539
|
+
kind: "messages",
|
|
540
|
+
tenantId: session.tenantId,
|
|
541
|
+
chatId: target.id,
|
|
542
|
+
url: backwardLink,
|
|
543
|
+
})
|
|
544
|
+
: null;
|
|
545
|
+
return { target, messages, page: { nextCursor } };
|
|
546
|
+
}
|
|
547
|
+
export async function getMessage(session, target, messageId, fetchImplementation = fetch) {
|
|
548
|
+
const url = new URL(`${messagePath(target.id)}/${encodeURIComponent(messageId)}`, session.endpoints.chatService);
|
|
549
|
+
const response = await observedFetch(fetchImplementation, url, {
|
|
550
|
+
headers: {
|
|
551
|
+
authentication: `skypetoken=${session.skypeToken.value}`,
|
|
552
|
+
accept: "application/json",
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
const payload = await jsonResponse(response, "Message lookup", "skype");
|
|
556
|
+
const message = normalizeMessage(payload.message ?? payload, target.id);
|
|
557
|
+
if (!message)
|
|
558
|
+
throw new Error("Message lookup returned no message");
|
|
559
|
+
return { target, message };
|
|
560
|
+
}
|
|
561
|
+
export async function sendMessage(session, target, content, requestId, sessionId, authorize, fetchImplementation = fetch) {
|
|
562
|
+
const plainTextAsHtml = content
|
|
563
|
+
.replaceAll("&", "&")
|
|
564
|
+
.replaceAll("<", "<")
|
|
565
|
+
.replaceAll(">", ">")
|
|
566
|
+
.replaceAll('"', """)
|
|
567
|
+
.replaceAll("'", "'")
|
|
568
|
+
.replaceAll(/\r?\n/g, "<br>");
|
|
569
|
+
const displayName = readJwtMetadata(session.accessToken.value).name ?? "";
|
|
570
|
+
await authorize();
|
|
571
|
+
const response = await observedFetch(fetchImplementation, new URL(messagePath(target.id), session.endpoints.chatService), {
|
|
572
|
+
method: "POST",
|
|
573
|
+
headers: {
|
|
574
|
+
authentication: `skypetoken=${session.skypeToken.value}`,
|
|
575
|
+
"x-ms-session-id": sessionId,
|
|
576
|
+
"content-type": "application/json",
|
|
577
|
+
accept: "application/json",
|
|
578
|
+
},
|
|
579
|
+
body: JSON.stringify({
|
|
580
|
+
clientmessageid: requestId,
|
|
581
|
+
content: `<p>${plainTextAsHtml}</p>`,
|
|
582
|
+
contenttype: "text",
|
|
583
|
+
messagetype: "RichText/Html",
|
|
584
|
+
amsreferences: [],
|
|
585
|
+
imdisplayname: displayName,
|
|
586
|
+
properties: { importance: "", subject: "" },
|
|
587
|
+
}),
|
|
588
|
+
});
|
|
589
|
+
if (!response.ok) {
|
|
590
|
+
throw new TeamsApiError(response.status, `Message send failed (${response.status} ${response.statusText})`, "skype");
|
|
591
|
+
}
|
|
592
|
+
const raw = await response.text();
|
|
593
|
+
if (!raw.trim())
|
|
594
|
+
return { target, message: null };
|
|
595
|
+
let payload;
|
|
596
|
+
try {
|
|
597
|
+
payload = JSON.parse(raw);
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
return { target, message: null };
|
|
601
|
+
}
|
|
602
|
+
const message = normalizeMessage(payload.message ?? payload, target.id);
|
|
603
|
+
return { target, message };
|
|
604
|
+
}
|