latchkey 3.3.0 → 3.4.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/dist/skills/generic/latchkey/SKILL.md +1 -1
- package/dist/skills/openclaw/latchkey/SKILL.md +1 -1
- package/dist/src/apiCredentials/serialization.d.ts +21 -0
- package/dist/src/apiCredentials/serialization.d.ts.map +1 -1
- package/dist/src/apiCredentials/serialization.js +7 -0
- package/dist/src/apiCredentials/serialization.js.map +1 -1
- package/dist/src/serviceRegistry.d.ts.map +1 -1
- package/dist/src/serviceRegistry.js +2 -1
- package/dist/src/serviceRegistry.js.map +1 -1
- package/dist/src/services/aws.d.ts.map +1 -1
- package/dist/src/services/aws.js +1 -2
- package/dist/src/services/aws.js.map +1 -1
- package/dist/src/services/index.d.ts +1 -0
- package/dist/src/services/index.d.ts.map +1 -1
- package/dist/src/services/index.js +1 -0
- package/dist/src/services/index.js.map +1 -1
- package/dist/src/services/ngrok.d.ts +38 -0
- package/dist/src/services/ngrok.d.ts.map +1 -0
- package/dist/src/services/ngrok.js +129 -0
- package/dist/src/services/ngrok.js.map +1 -0
- package/dist/src/services/zoom.d.ts +118 -5
- package/dist/src/services/zoom.d.ts.map +1 -1
- package/dist/src/services/zoom.js +692 -7
- package/dist/src/services/zoom.js.map +1 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/dist/tests/apiCredentials.test.js +5 -0
- package/dist/tests/apiCredentials.test.js.map +1 -1
- package/dist/tests/servicesAgainstRecordings.test.js +8 -2
- package/dist/tests/servicesAgainstRecordings.test.js.map +1 -1
- package/dist/tests/zoom.test.d.ts +2 -0
- package/dist/tests/zoom.test.d.ts.map +1 -0
- package/dist/tests/zoom.test.js +110 -0
- package/dist/tests/zoom.test.js.map +1 -0
- package/package.json +2 -2
|
@@ -1,33 +1,718 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zoom service implementation.
|
|
3
|
+
*
|
|
4
|
+
* Browser login creates a Server-to-Server OAuth app in the Zoom App
|
|
5
|
+
* Marketplace. Such an app has no user context: its credentials are an account
|
|
6
|
+
* id plus a client id/secret, which are exchanged for a short-lived access
|
|
7
|
+
* token with the `account_credentials` grant whenever the previous token
|
|
8
|
+
* expires.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { ApiCredentialsUsageError } from '../apiCredentials/base.js';
|
|
1
12
|
import { fetchAccountFromEndpoint, tryParseJson } from '../apiCredentials/account.js';
|
|
2
|
-
import {
|
|
13
|
+
import { runCapturedAsync } from '../curl.js';
|
|
14
|
+
import { typeLikeHuman } from '../playwrightUtils.js';
|
|
15
|
+
import { BrowserFollowupServiceSession, FollowupWork, isBrowserClosedError, LoginCancelledError, LoginFailedError, Service, } from './core/base.js';
|
|
16
|
+
const ZOOM_TOKEN_ENDPOINT = 'https://zoom.us/oauth/token';
|
|
17
|
+
// Sign-in happens on Zoom itself rather than on the App Marketplace, which
|
|
18
|
+
// bounces visitors through a less predictable chain of pages.
|
|
19
|
+
const ZOOM_SIGN_IN_URL = 'https://zoom.us/signin#/login';
|
|
20
|
+
// Where Zoom sends the user once signed in: their home page, served by whichever
|
|
21
|
+
// regional host runs their account (https://us05web.zoom.us/myhome, ...) and
|
|
22
|
+
// possibly with a query or a subpath behind it. The redirect there names the
|
|
23
|
+
// page either as an absolute URL or as a path.
|
|
24
|
+
const SIGNED_IN_HOME_URL_PATTERN = /^(https:\/\/([\w-]+\.)*zoom\.us)?\/myhome\b/i;
|
|
25
|
+
/**
|
|
26
|
+
* Whether a URL names the home page Zoom sends a user to once they are signed
|
|
27
|
+
* in. Also accepts the bare path, the form a redirect to it may take.
|
|
28
|
+
*/
|
|
29
|
+
export function isZoomSignedInHomeUrl(url) {
|
|
30
|
+
return SIGNED_IN_HOME_URL_PATTERN.test(url);
|
|
31
|
+
}
|
|
32
|
+
// The Marketplace page that lists the user's apps and carries the "Develop"
|
|
33
|
+
// menu the app creation flow starts from.
|
|
34
|
+
const ZOOM_MARKETPLACE_BUILD_URL = 'https://marketplace.zoom.us/user/build';
|
|
35
|
+
// Zoom's access tokens live for an hour; renew slightly early so a token does
|
|
36
|
+
// not expire between the check and the request that uses it.
|
|
37
|
+
const ACCESS_TOKEN_EXPIRY_MARGIN_MS = 60_000;
|
|
38
|
+
const DEFAULT_ACCESS_TOKEN_LIFETIME_SECONDS = 3600;
|
|
39
|
+
export class ZoomCredentialError extends Error {
|
|
40
|
+
constructor(message) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = 'ZoomCredentialError';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Raised when the signed-in user is not allowed to create apps in their Zoom
|
|
47
|
+
* account. Extends {@link LoginFailedError} so the CLI reports the message on
|
|
48
|
+
* stderr instead of a stack trace.
|
|
49
|
+
*/
|
|
50
|
+
export class ZoomAppCreationNotPermittedError extends LoginFailedError {
|
|
51
|
+
constructor() {
|
|
52
|
+
super('Your Zoom user is not allowed to create apps in the Zoom App Marketplace. Ask an administrator ' +
|
|
53
|
+
'of your Zoom account to grant you the developer privilege, then try again.');
|
|
54
|
+
this.name = 'ZoomAppCreationNotPermittedError';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Credentials of a Zoom Server-to-Server OAuth app: the long-lived triple that
|
|
59
|
+
* identifies the app, plus the short-lived access token minted from it.
|
|
60
|
+
*/
|
|
61
|
+
export const ZoomServerToServerCredentialsSchema = z.object({
|
|
62
|
+
objectType: z.literal('zoomServerToServer'),
|
|
63
|
+
accountId: z.string(),
|
|
64
|
+
clientId: z.string(),
|
|
65
|
+
clientSecret: z.string(),
|
|
66
|
+
accessToken: z.string().optional(),
|
|
67
|
+
accessTokenExpiresAt: z.string().optional(),
|
|
68
|
+
});
|
|
69
|
+
export class ZoomServerToServerCredentials {
|
|
70
|
+
objectType = 'zoomServerToServer';
|
|
71
|
+
accountId;
|
|
72
|
+
clientId;
|
|
73
|
+
clientSecret;
|
|
74
|
+
accessToken;
|
|
75
|
+
accessTokenExpiresAt;
|
|
76
|
+
accountEmail;
|
|
77
|
+
constructor(accountId, clientId, clientSecret, accessToken, accessTokenExpiresAt, accountEmail) {
|
|
78
|
+
this.accountId = accountId;
|
|
79
|
+
this.clientId = clientId;
|
|
80
|
+
this.clientSecret = clientSecret;
|
|
81
|
+
this.accessToken = accessToken;
|
|
82
|
+
this.accessTokenExpiresAt = accessTokenExpiresAt;
|
|
83
|
+
this.accountEmail = accountEmail;
|
|
84
|
+
}
|
|
85
|
+
withAccessToken(accessToken, accessTokenExpiresAt) {
|
|
86
|
+
return new ZoomServerToServerCredentials(this.accountId, this.clientId, this.clientSecret, accessToken, accessTokenExpiresAt, this.accountEmail);
|
|
87
|
+
}
|
|
88
|
+
injectIntoCurlCall(curlArguments) {
|
|
89
|
+
if (this.accessToken === undefined) {
|
|
90
|
+
throw new ApiCredentialsUsageError('Zoom server-to-server credentials have no access token yet. ' +
|
|
91
|
+
'Run a Zoom request again to have one minted, or re-run the login.');
|
|
92
|
+
}
|
|
93
|
+
return Promise.resolve(['-H', `Authorization: Bearer ${this.accessToken}`, ...curlArguments]);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Missing tokens count as expired: the client id and secret are enough to
|
|
97
|
+
* mint a new one, so refreshing is always the right response.
|
|
98
|
+
*/
|
|
99
|
+
isExpired() {
|
|
100
|
+
if (this.accessToken === undefined || this.accessTokenExpiresAt === undefined) {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
const expiresAt = new Date(this.accessTokenExpiresAt).getTime();
|
|
104
|
+
return Date.now() >= expiresAt - ACCESS_TOKEN_EXPIRY_MARGIN_MS;
|
|
105
|
+
}
|
|
106
|
+
toJSON() {
|
|
107
|
+
return {
|
|
108
|
+
objectType: this.objectType,
|
|
109
|
+
accountId: this.accountId,
|
|
110
|
+
clientId: this.clientId,
|
|
111
|
+
clientSecret: this.clientSecret,
|
|
112
|
+
accessToken: this.accessToken,
|
|
113
|
+
accessTokenExpiresAt: this.accessTokenExpiresAt,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
static fromJSON(data) {
|
|
117
|
+
return new ZoomServerToServerCredentials(data.accountId, data.clientId, data.clientSecret, data.accessToken, data.accessTokenExpiresAt);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Exchange the app's client id/secret for an account-level access token.
|
|
122
|
+
* Returns null when Zoom refuses to issue one (for instance while the app is
|
|
123
|
+
* not activated yet).
|
|
124
|
+
*/
|
|
125
|
+
async function mintAccessToken(credentials) {
|
|
126
|
+
const body = new URLSearchParams({
|
|
127
|
+
grant_type: 'account_credentials',
|
|
128
|
+
account_id: credentials.accountId,
|
|
129
|
+
});
|
|
130
|
+
const result = await runCapturedAsync([
|
|
131
|
+
'-s',
|
|
132
|
+
'-X',
|
|
133
|
+
'POST',
|
|
134
|
+
'-u',
|
|
135
|
+
`${credentials.clientId}:${credentials.clientSecret}`,
|
|
136
|
+
'-H',
|
|
137
|
+
'Content-Type: application/x-www-form-urlencoded',
|
|
138
|
+
'-d',
|
|
139
|
+
body.toString(),
|
|
140
|
+
ZOOM_TOKEN_ENDPOINT,
|
|
141
|
+
], 30);
|
|
142
|
+
if (result.returncode !== 0) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const response = tryParseJson(result.stdout);
|
|
146
|
+
if (response?.access_token === undefined) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const lifetimeSeconds = response.expires_in ?? DEFAULT_ACCESS_TOKEN_LIFETIME_SECONDS;
|
|
150
|
+
return credentials.withAccessToken(response.access_token, new Date(Date.now() + lifetimeSeconds * 1000).toISOString());
|
|
151
|
+
}
|
|
152
|
+
// --- Browser automation ------------------------------------------------------
|
|
153
|
+
const DEFAULT_TIMEOUT_MS = 8000;
|
|
154
|
+
// The app pages are a single-page app: steps change the URL without a document
|
|
155
|
+
// load, so progress is observed by polling rather than by navigation events.
|
|
156
|
+
const URL_POLL_INTERVAL_MS = 200;
|
|
157
|
+
const APP_CREATION_TIMEOUT_MS = 30_000;
|
|
158
|
+
// Time given to a dialog or page to render after a click.
|
|
159
|
+
const PAGE_SETTLE_MS = 1000;
|
|
160
|
+
// Time given to a dialog or menu to finish animating into place.
|
|
161
|
+
const ANIMATION_SETTLE_MS = 300;
|
|
162
|
+
// The "Build app" dialog may be preceded by the API terms of use, which the
|
|
163
|
+
// user has to accept before Zoom lets the flow continue, so the click may have
|
|
164
|
+
// to be repeated.
|
|
165
|
+
const BUILD_APP_ATTEMPTS = 4;
|
|
166
|
+
// Time allowed for the user to accept Zoom's API License and Terms of Use.
|
|
167
|
+
const TERMS_OF_USE_USER_INTERACTION_TIMEOUT_MS = 10 * 60 * 1000;
|
|
168
|
+
// Zoom walks the app setup through several pages (credentials, information,
|
|
169
|
+
// feature, scopes), each behind its own "Continue" button, and rejects
|
|
170
|
+
// incomplete pages by staying put.
|
|
171
|
+
const MAX_CONTINUE_CLICKS = 8;
|
|
172
|
+
// A freshly activated app takes a moment to become usable for token minting.
|
|
173
|
+
const TOKEN_MINTING_ATTEMPTS = 5;
|
|
174
|
+
const TOKEN_MINTING_RETRY_DELAY_MS = 2000;
|
|
175
|
+
// Marketplace pages expose the signed-in user, their privileges and their
|
|
176
|
+
// account in a global object, which is both more reliable and more
|
|
177
|
+
// language-independent than reading the rendered page.
|
|
178
|
+
const ZoomMarketplaceUserInfoSchema = z.object({
|
|
179
|
+
email: z.string().optional(),
|
|
180
|
+
userName: z.string().optional(),
|
|
181
|
+
canBuildServerToServerOAuthApp: z.boolean().optional(),
|
|
182
|
+
});
|
|
183
|
+
const APP_CREDENTIALS_URL_PATTERN = /\/develop\/apps\/([^/?#]+)\/credentials/;
|
|
184
|
+
// The two controls that carry nothing but their label: the app kind radio
|
|
185
|
+
// buttons all have an empty value, and the "Build app" menu item has neither an
|
|
186
|
+
// id nor a test attribute. Everything else is addressed through `data-ta`
|
|
187
|
+
// attributes, input names, icons or structural classes, none of which depend on
|
|
188
|
+
// the interface language.
|
|
189
|
+
const SERVER_TO_SERVER_APP_KIND_PATTERN = /server\s*to\s*server/i;
|
|
190
|
+
const BUILD_APP_MENU_ITEM_PATTERN = /build\s*app/i;
|
|
191
|
+
// The "Add Scopes" button and the "Select All" menu of the scope dialog have no
|
|
192
|
+
// attributes to go by either, but they are the only elements on their page drawn
|
|
193
|
+
// with a plus icon respectively a drop-down triangle.
|
|
194
|
+
const ADD_SCOPES_BUTTON_SELECTOR = 'button:has(svg mask[id^="icon_PlusSmallOutline"])';
|
|
195
|
+
const SELECT_ALL_SCOPES_GLYPH = '\u25be';
|
|
196
|
+
// The dialogs of the two design systems Zoom's Marketplace mixes mark their
|
|
197
|
+
// confirming button ("Create", "Done") as the primary one, which avoids having
|
|
198
|
+
// to match its label.
|
|
199
|
+
const ENABLED_PRIMARY_BUTTON_SELECTOR = 'button:is(.MuiButton-primary, .ui-Button-primary):not([disabled]):not([aria-disabled="true"])';
|
|
200
|
+
const ENABLED_PRIMARY_DIALOG_BUTTON_SELECTOR = `[role="dialog"]:visible ${ENABLED_PRIMARY_BUTTON_SELECTOR}`;
|
|
201
|
+
// The scope dialog counts the selection next to the button that confirms it;
|
|
202
|
+
// only the number is read, so the wording does not matter.
|
|
203
|
+
const SELECTED_SCOPE_COUNT_PATTERN = /\d+/;
|
|
204
|
+
// How long to give the dialog to work a group's scopes into the selection.
|
|
205
|
+
const SCOPE_SELECTION_TIMEOUT_MS = 5000;
|
|
206
|
+
// The scope dialog groups the scopes by product, one tab each, and its "Select
|
|
207
|
+
// All" only ever reaches the group on display — so every group worth having is
|
|
208
|
+
// visited in turn. The tabs are named after Zoom's products, which is all there
|
|
209
|
+
// is to tell them apart; anchoring the names keeps "Contacts" from picking
|
|
210
|
+
// "Contact Center". Groups an account does not have are simply absent.
|
|
211
|
+
const SCOPE_GROUP_PATTERNS = [
|
|
212
|
+
/^Team Chat$/i,
|
|
213
|
+
/^Meetings$/i,
|
|
214
|
+
/^Recording$/i,
|
|
215
|
+
/^User$/i,
|
|
216
|
+
/^Calendar$/i,
|
|
217
|
+
/^ZOOM$/i,
|
|
218
|
+
/^Dashboard$/i,
|
|
219
|
+
/^Contacts$/i,
|
|
220
|
+
/^Message$/i,
|
|
221
|
+
];
|
|
222
|
+
const DEVELOP_MENU_BUTTON_SELECTOR = 'button[data-ta="develop"]';
|
|
223
|
+
// Menus are modals: they cover the page with an invisible backdrop that
|
|
224
|
+
// swallows pointer events until the menu is dismissed.
|
|
225
|
+
const OPEN_MENU_BACKDROP_SELECTOR = '.MuiMenu-root .MuiModal-backdrop:visible';
|
|
226
|
+
// The API terms dialog is recognized by its link to the license document, which
|
|
227
|
+
// does not depend on the interface language. The app kind dialog is the one
|
|
228
|
+
// asking to pick between radio buttons.
|
|
229
|
+
const API_TERMS_DIALOG_SELECTOR = '[role="dialog"]:visible:has(a[href*="zoom_api_license_and_tou"])';
|
|
230
|
+
const APP_KIND_DIALOG_SELECTOR = '[role="dialog"]:visible:has(input[type="radio"])';
|
|
231
|
+
// The inputs of the app information page are named after the fields of Zoom's
|
|
232
|
+
// app model (as are those of the credentials page). Matching the name as a
|
|
233
|
+
// substring absorbs the spellings Zoom uses for the developer contact across
|
|
234
|
+
// versions of the page, and covers pages that ask for a second contact.
|
|
235
|
+
const COMPANY_NAME_INPUT_SELECTOR = 'input[name*="company" i]';
|
|
236
|
+
const DEVELOPER_NAME_INPUT_SELECTOR = 'input[name*="developername" i], input[name*="contactname" i], input[name*="supportname" i]';
|
|
237
|
+
const DEVELOPER_EMAIL_INPUT_SELECTOR = 'input[name*="email" i]';
|
|
238
|
+
function visibleDialog(page) {
|
|
239
|
+
// The Marketplace keeps dialogs of pages already visited in the DOM, so the
|
|
240
|
+
// one to act on is the last visible one.
|
|
241
|
+
return page.locator('[role="dialog"]:visible').last();
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Wait for the Marketplace header to be there and settle: it hydrates after the
|
|
245
|
+
* page load and re-renders shortly after. Clicking into it before that opens a
|
|
246
|
+
* menu whose backdrop then stays behind, blocking every later click.
|
|
247
|
+
*/
|
|
248
|
+
async function waitForMarketplaceHeader(page) {
|
|
249
|
+
await page.locator(DEVELOP_MENU_BUTTON_SELECTOR).waitFor({ timeout: DEFAULT_TIMEOUT_MS });
|
|
250
|
+
await page.waitForTimeout(PAGE_SETTLE_MS);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Read the signed-in user, their privileges and their account off a Marketplace
|
|
254
|
+
* page. Returns null when the page carries no user, which is how a Marketplace
|
|
255
|
+
* page rendered for a visitor who is not (or no longer) signed in looks.
|
|
256
|
+
*/
|
|
257
|
+
async function readMarketplaceUserInfo(page) {
|
|
258
|
+
const rawUserInfo = await page.evaluate(() => {
|
|
259
|
+
const appConf = globalThis.appConf;
|
|
260
|
+
return appConf?.userInfo ?? null;
|
|
261
|
+
});
|
|
262
|
+
const parsed = ZoomMarketplaceUserInfoSchema.safeParse(rawUserInfo);
|
|
263
|
+
if (!parsed.success) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
const userInfo = parsed.data;
|
|
267
|
+
return userInfo.email === undefined && userInfo.userName === undefined ? null : userInfo;
|
|
268
|
+
}
|
|
269
|
+
async function clickEnabledPrimaryDialogButton(page) {
|
|
270
|
+
await clickControl(page.locator(ENABLED_PRIMARY_DIALOG_BUTTON_SELECTOR).last());
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Click a control of the Marketplace's interface.
|
|
274
|
+
*
|
|
275
|
+
* Its buttons and menu entries swap modal layers in and out, and a backdrop
|
|
276
|
+
* appearing while the mouse button is still down is what Playwright reads as
|
|
277
|
+
* its own click having been intercepted: it discards the click and retries —
|
|
278
|
+
* only now the backdrop that the first click brought up blocks every attempt,
|
|
279
|
+
* until the action times out with a menu or dialog sitting open. Forcing the
|
|
280
|
+
* click sends the same mouse events without that hit-target check, which is
|
|
281
|
+
* why every click of this flow goes through here.
|
|
282
|
+
*/
|
|
283
|
+
async function clickControl(locator) {
|
|
284
|
+
await locator.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
|
|
285
|
+
// A forced click skips the wait for the element to stop moving, so a dialog
|
|
286
|
+
// or menu still animating into place gets its time here instead.
|
|
287
|
+
await locator.page().waitForTimeout(ANIMATION_SETTLE_MS);
|
|
288
|
+
await locator.click({ force: true });
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Close a menu that is (still) open. A menu covers the whole page with an
|
|
292
|
+
* invisible backdrop that swallows every click, so one left open blocks the
|
|
293
|
+
* rest of the flow — including a second attempt at opening it.
|
|
294
|
+
*/
|
|
295
|
+
async function dismissOpenMenu(page) {
|
|
296
|
+
const menuBackdrop = page.locator(OPEN_MENU_BACKDROP_SELECTOR).first();
|
|
297
|
+
if ((await menuBackdrop.count()) === 0) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// Escape rather than a click on the backdrop, which would land on whatever is
|
|
301
|
+
// underneath should the menu close on its own in the meantime.
|
|
302
|
+
await page.keyboard.press('Escape');
|
|
303
|
+
await menuBackdrop.waitFor({ state: 'hidden', timeout: DEFAULT_TIMEOUT_MS });
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Ask for the app creation dialog through the header's "Develop" menu.
|
|
307
|
+
*
|
|
308
|
+
* The menu opens on hover, so the button is hovered rather than clicked: a click
|
|
309
|
+
* arrives at a menu that is already open and closes it again, which is why
|
|
310
|
+
* clicking here gets nowhere. Its entry is then chosen with Enter, since the
|
|
311
|
+
* menu takes the focus as it opens — that is what leaves its first entry
|
|
312
|
+
* highlighted — and a keypress needs no coordinates, unlike a click, which the
|
|
313
|
+
* invisible backdrop MUI spreads beside the menu can swallow.
|
|
314
|
+
*/
|
|
315
|
+
async function chooseDevelopBuildApp(page) {
|
|
316
|
+
await dismissOpenMenu(page);
|
|
317
|
+
const developMenuButton = page.locator(DEVELOP_MENU_BUTTON_SELECTOR);
|
|
318
|
+
await developMenuButton.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
|
|
319
|
+
await developMenuButton.hover({ force: true });
|
|
320
|
+
const buildAppEntry = page.getByRole('menuitem', { name: BUILD_APP_MENU_ITEM_PATTERN }).first();
|
|
321
|
+
await buildAppEntry.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
|
|
322
|
+
// Let the menu settle and take the focus before the keypress.
|
|
323
|
+
await page.waitForTimeout(ANIMATION_SETTLE_MS);
|
|
324
|
+
await page.keyboard.press('Enter');
|
|
325
|
+
await page.waitForTimeout(ANIMATION_SETTLE_MS);
|
|
326
|
+
if (await buildAppEntry.isVisible()) {
|
|
327
|
+
// The menu stayed open, so the focus was elsewhere: click the entry after
|
|
328
|
+
// all, and clear the menu if that leaves it behind.
|
|
329
|
+
await clickControl(buildAppEntry);
|
|
330
|
+
await dismissOpenMenu(page);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
async function selectServerToServerAppKind(appKindDialog) {
|
|
334
|
+
if ((await appKindDialog.locator('input[type="radio"]:not([disabled])').count()) === 0) {
|
|
335
|
+
throw new ZoomAppCreationNotPermittedError();
|
|
336
|
+
}
|
|
337
|
+
const serverToServerOption = appKindDialog
|
|
338
|
+
.locator('label')
|
|
339
|
+
.filter({ hasText: SERVER_TO_SERVER_APP_KIND_PATTERN })
|
|
340
|
+
.locator('input[type="radio"]')
|
|
341
|
+
.first();
|
|
342
|
+
if ((await serverToServerOption.count()) === 0) {
|
|
343
|
+
throw new LoginFailedError('Zoom does not offer the "Server to Server OAuth" app type to your user. ' +
|
|
344
|
+
'Ask an administrator of your Zoom account to enable it for you.');
|
|
345
|
+
}
|
|
346
|
+
if (!(await serverToServerOption.isEnabled())) {
|
|
347
|
+
throw new ZoomAppCreationNotPermittedError();
|
|
348
|
+
}
|
|
349
|
+
await serverToServerOption.check();
|
|
350
|
+
}
|
|
351
|
+
async function nameAndCreateApp(page, appName) {
|
|
352
|
+
const appNameInput = page.locator('input[data-ta="app-name"]');
|
|
353
|
+
await appNameInput.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
|
|
354
|
+
await typeLikeHuman(page, appNameInput, appName);
|
|
355
|
+
await clickEnabledPrimaryDialogButton(page);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Wait until Zoom opens the credentials page of the new app and return the app
|
|
359
|
+
* id taken from its URL.
|
|
360
|
+
*/
|
|
361
|
+
async function waitForCreatedAppId(page) {
|
|
362
|
+
const deadline = Date.now() + APP_CREATION_TIMEOUT_MS;
|
|
363
|
+
while (Date.now() < deadline) {
|
|
364
|
+
const appId = APP_CREDENTIALS_URL_PATTERN.exec(page.url())?.[1];
|
|
365
|
+
if (appId !== undefined) {
|
|
366
|
+
return appId;
|
|
367
|
+
}
|
|
368
|
+
await page.waitForTimeout(URL_POLL_INTERVAL_MS);
|
|
369
|
+
}
|
|
370
|
+
throw new LoginFailedError(`Zoom did not open the credentials page of the new app (last URL: ${page.url()}).`);
|
|
371
|
+
}
|
|
372
|
+
async function readCredentialField(page, fieldName) {
|
|
373
|
+
const input = page.locator(`input[name="${fieldName}"]`);
|
|
374
|
+
await input.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
|
|
375
|
+
const value = await input.inputValue();
|
|
376
|
+
if (value === '') {
|
|
377
|
+
throw new LoginFailedError(`Zoom left the app credential field '${fieldName}' empty.`);
|
|
378
|
+
}
|
|
379
|
+
return value;
|
|
380
|
+
}
|
|
381
|
+
async function readAppCredentials(page, accountEmail) {
|
|
382
|
+
return new ZoomServerToServerCredentials(await readCredentialField(page, 'devAccountId'), await readCredentialField(page, 'devClientId'), await readCredentialField(page, 'devClientSecret'), undefined, undefined, accountEmail);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Type the value into every input the selector matches that is still empty and
|
|
386
|
+
* open for editing, so fields Zoom prefilled (or the user filled) are left as
|
|
387
|
+
* they are. Matching nothing is fine: which fields a page asks for varies.
|
|
388
|
+
*/
|
|
389
|
+
async function fillEmptyInputs(page, selector, value) {
|
|
390
|
+
for (const input of await page.locator(selector).all()) {
|
|
391
|
+
if (!(await input.isVisible()) || !(await input.isEditable())) {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if ((await input.inputValue()) !== '') {
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
await typeLikeHuman(page, input, value);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Fill the developer contact details Zoom requires before an app can go live:
|
|
402
|
+
* the signed-in user's own name and e-mail address, and as the company the name
|
|
403
|
+
* the app is created under, which says where the app came from.
|
|
404
|
+
*/
|
|
405
|
+
async function fillDeveloperInformation(page, userInfo, companyName) {
|
|
406
|
+
await fillEmptyInputs(page, COMPANY_NAME_INPUT_SELECTOR, companyName);
|
|
407
|
+
if (userInfo.userName !== undefined) {
|
|
408
|
+
await fillEmptyInputs(page, DEVELOPER_NAME_INPUT_SELECTOR, userInfo.userName);
|
|
409
|
+
}
|
|
410
|
+
if (userInfo.email !== undefined) {
|
|
411
|
+
await fillEmptyInputs(page, DEVELOPER_EMAIL_INPUT_SELECTOR, userInfo.email);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
async function clickContinueButton(page) {
|
|
415
|
+
await clickControl(page.locator('button[data-ta="continue-button"]'));
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Walk the app setup pages from the credentials page to the scopes page,
|
|
419
|
+
* filling in the developer information Zoom asks for along the way. Zoom
|
|
420
|
+
* refuses to move on from an incomplete page, so each step retries until the
|
|
421
|
+
* scopes page is reached.
|
|
422
|
+
*/
|
|
423
|
+
async function continueToScopesPage(page, appId, userInfo, companyName) {
|
|
424
|
+
const scopesPath = `/develop/apps/${appId}/scope`;
|
|
425
|
+
for (let attempt = 0; attempt < MAX_CONTINUE_CLICKS; attempt++) {
|
|
426
|
+
if (new URL(page.url()).pathname === scopesPath) {
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
await fillDeveloperInformation(page, userInfo, companyName);
|
|
430
|
+
await clickContinueButton(page);
|
|
431
|
+
await page.waitForTimeout(PAGE_SETTLE_MS);
|
|
432
|
+
}
|
|
433
|
+
throw new LoginFailedError(`Zoom's app setup did not reach the scopes page (stuck at ${page.url()}). ` +
|
|
434
|
+
'Complete the app setup in the browser window to finish the login.');
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Grant the app every admin scope its account offers, so a single set of
|
|
438
|
+
* credentials can serve any Zoom API call the account is entitled to.
|
|
439
|
+
*/
|
|
440
|
+
/**
|
|
441
|
+
* How many scopes the dialog reports as selected, or null when it shows no
|
|
442
|
+
* count at all.
|
|
443
|
+
*/
|
|
444
|
+
async function readSelectedScopeCount(scopesDialog) {
|
|
445
|
+
// The count sits beside the confirming button, in the footer of the dialog.
|
|
446
|
+
const footer = scopesDialog.locator(ENABLED_PRIMARY_BUTTON_SELECTOR).last().locator('xpath=..');
|
|
447
|
+
const countMatch = SELECTED_SCOPE_COUNT_PATTERN.exec((await footer.textContent()) ?? '');
|
|
448
|
+
return countMatch === null ? null : Number(countMatch[0]);
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Wait for the dialog's count of selected scopes to move on from what it was,
|
|
452
|
+
* which working a group into the selection takes it a moment to do. A group
|
|
453
|
+
* that holds no scopes for this account never moves it, so running out of time
|
|
454
|
+
* is not an error.
|
|
455
|
+
*/
|
|
456
|
+
async function waitForSelectedScopeCountChange(page, scopesDialog, previousCount) {
|
|
457
|
+
const deadline = Date.now() + SCOPE_SELECTION_TIMEOUT_MS;
|
|
458
|
+
while (Date.now() < deadline) {
|
|
459
|
+
if ((await readSelectedScopeCount(scopesDialog)) !== previousCount) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
await page.waitForTimeout(URL_POLL_INTERVAL_MS);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Add every scope of the group on display to the selection. The entries of the
|
|
467
|
+
* "Select All" menu widen from account level outwards ("Select All (Admin)",
|
|
468
|
+
* then "(Master)", then "(All)"), so the account-level one is the first.
|
|
469
|
+
*/
|
|
470
|
+
async function selectAllScopesOfGroup(page, scopesDialog) {
|
|
471
|
+
const previousCount = await readSelectedScopeCount(scopesDialog);
|
|
472
|
+
await clickControl(scopesDialog.getByText(SELECT_ALL_SCOPES_GLYPH).first());
|
|
473
|
+
// The menu opens outside the dialog, so it is looked up on the page. Unlike
|
|
474
|
+
// the header's, this menu opens on click and its entries take one.
|
|
475
|
+
await clickControl(page.locator('[role="menu"]:visible').last().locator('[role="menuitem"]').first());
|
|
476
|
+
await waitForSelectedScopeCountChange(page, scopesDialog, previousCount);
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Grant the app the scopes of every product group worth having, so a single set
|
|
480
|
+
* of credentials can serve the Zoom APIs an account is entitled to.
|
|
481
|
+
*
|
|
482
|
+
* Confirming a selection that stayed empty would leave the app created,
|
|
483
|
+
* activated and unable to call a single API, so that is refused outright.
|
|
484
|
+
*/
|
|
485
|
+
async function addAllScopes(page) {
|
|
486
|
+
await clickControl(page.locator(ADD_SCOPES_BUTTON_SELECTOR).first());
|
|
487
|
+
const scopesDialog = visibleDialog(page);
|
|
488
|
+
for (const groupPattern of SCOPE_GROUP_PATTERNS) {
|
|
489
|
+
const groupTab = scopesDialog.getByRole('tab', { name: groupPattern }).first();
|
|
490
|
+
if ((await groupTab.count()) === 0) {
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
await clickControl(groupTab);
|
|
494
|
+
await selectAllScopesOfGroup(page, scopesDialog);
|
|
495
|
+
}
|
|
496
|
+
const selectedCount = await readSelectedScopeCount(scopesDialog);
|
|
497
|
+
if (selectedCount === 0) {
|
|
498
|
+
throw new LoginFailedError('Zoom selected no scopes for the app, which would leave it unable to call ' +
|
|
499
|
+
'any API. Add the scopes in the browser window, or ask an administrator of ' +
|
|
500
|
+
'your Zoom account for the privileges to do so.');
|
|
501
|
+
}
|
|
502
|
+
// Confirm the selection ("Done").
|
|
503
|
+
await clickEnabledPrimaryDialogButton(page);
|
|
504
|
+
await page.waitForTimeout(PAGE_SETTLE_MS);
|
|
505
|
+
}
|
|
506
|
+
async function activateApp(page) {
|
|
507
|
+
await clickControl(page.locator('button[data-ta="activeAppBtn"]'));
|
|
508
|
+
await page.waitForTimeout(PAGE_SETTLE_MS);
|
|
509
|
+
// Some accounts have to confirm the activation in a dialog.
|
|
510
|
+
if ((await page.locator(ENABLED_PRIMARY_DIALOG_BUTTON_SELECTOR).count()) > 0) {
|
|
511
|
+
await clickEnabledPrimaryDialogButton(page);
|
|
512
|
+
await page.waitForTimeout(PAGE_SETTLE_MS);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Mint the first access token of the new app, retrying because activation takes
|
|
517
|
+
* a moment to take effect.
|
|
518
|
+
*/
|
|
519
|
+
async function mintFirstAccessToken(page, credentials) {
|
|
520
|
+
for (let attempt = 0; attempt < TOKEN_MINTING_ATTEMPTS; attempt++) {
|
|
521
|
+
const withToken = await mintAccessToken(credentials);
|
|
522
|
+
if (withToken !== null) {
|
|
523
|
+
return withToken;
|
|
524
|
+
}
|
|
525
|
+
await page.waitForTimeout(TOKEN_MINTING_RETRY_DELAY_MS);
|
|
526
|
+
}
|
|
527
|
+
throw new LoginFailedError('Zoom did not issue an access token for the new app. Check in the browser ' +
|
|
528
|
+
'window whether the app was activated, and try again.');
|
|
529
|
+
}
|
|
530
|
+
class ZoomServiceSession extends BrowserFollowupServiceSession {
|
|
531
|
+
followupWork = FollowupWork.CreateApp;
|
|
532
|
+
isSignedIn = false;
|
|
533
|
+
/**
|
|
534
|
+
* Sign-in is over once Zoom sends the user to their home page, which it does
|
|
535
|
+
* after the last step of the sign-in (whatever two-factor or consent steps
|
|
536
|
+
* came before). Being sent there is enough — waiting for the home page to
|
|
537
|
+
* answer, let alone to render, would only cost a round trip, since the flow
|
|
538
|
+
* navigates away from it to the Marketplace anyway.
|
|
539
|
+
*/
|
|
540
|
+
onResponse(response) {
|
|
541
|
+
if (this.isSignedIn) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (!response.request().isNavigationRequest()) {
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const redirectTarget = response.headers().location;
|
|
548
|
+
if (isZoomSignedInHomeUrl(response.request().url()) ||
|
|
549
|
+
(redirectTarget !== undefined && isZoomSignedInHomeUrl(redirectTarget))) {
|
|
550
|
+
this.isSignedIn = true;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
isLoginComplete() {
|
|
554
|
+
return this.isSignedIn;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Accounts that have not accepted Zoom's API License and Terms of Use yet see
|
|
558
|
+
* a dialog blocking the app creation. Accepting the terms is the user's
|
|
559
|
+
* decision, so the page is surfaced and the flow waits until the dialog is
|
|
560
|
+
* gone rather than clicking "Agree" on the user's behalf.
|
|
561
|
+
*/
|
|
562
|
+
async waitForApiTermsOfUseDecision(page) {
|
|
563
|
+
const termsDialog = page.locator(API_TERMS_DIALOG_SELECTOR).first();
|
|
564
|
+
if ((await termsDialog.count()) === 0) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
await page.bringToFront();
|
|
569
|
+
// Either button of the dialog closes it: declining leaves the terms
|
|
570
|
+
// unsigned, which shows the dialog again on the next attempt.
|
|
571
|
+
await termsDialog.waitFor({
|
|
572
|
+
state: 'hidden',
|
|
573
|
+
timeout: TERMS_OF_USE_USER_INTERACTION_TIMEOUT_MS,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
catch (error) {
|
|
577
|
+
if (error instanceof Error && isBrowserClosedError(error)) {
|
|
578
|
+
throw new LoginCancelledError();
|
|
579
|
+
}
|
|
580
|
+
throw error;
|
|
581
|
+
}
|
|
582
|
+
await this.spinnerPage?.bringToFront();
|
|
583
|
+
await page.waitForTimeout(PAGE_SETTLE_MS);
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Open the dialog that asks what kind of app to create, letting the user
|
|
587
|
+
* accept the API terms of use on the way if the account has not signed them
|
|
588
|
+
* yet.
|
|
589
|
+
*/
|
|
590
|
+
async openAppKindDialog(page) {
|
|
591
|
+
const appKindDialog = page.locator(APP_KIND_DIALOG_SELECTOR).last();
|
|
592
|
+
for (let attempt = 0; attempt < BUILD_APP_ATTEMPTS; attempt++) {
|
|
593
|
+
await this.waitForApiTermsOfUseDecision(page);
|
|
594
|
+
if (await appKindDialog.isVisible()) {
|
|
595
|
+
return appKindDialog;
|
|
596
|
+
}
|
|
597
|
+
await chooseDevelopBuildApp(page);
|
|
598
|
+
// Zoom fetches the dialog rather than having it ready, so wait for
|
|
599
|
+
// whichever it puts up: the app kinds, or the terms of use that have to
|
|
600
|
+
// be accepted first.
|
|
601
|
+
await page
|
|
602
|
+
.locator(`${APP_KIND_DIALOG_SELECTOR}, ${API_TERMS_DIALOG_SELECTOR}`)
|
|
603
|
+
.first()
|
|
604
|
+
.waitFor({ timeout: DEFAULT_TIMEOUT_MS })
|
|
605
|
+
.catch(() => {
|
|
606
|
+
// Neither showed up; the next attempt asks for the dialog again.
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
throw new LoginFailedError('Zoom\'s "Build app" dialog did not open. Zoom asks for its API License ' +
|
|
610
|
+
'and Terms of Use to be accepted before an app can be created; without ' +
|
|
611
|
+
'that, the app has to be created manually.');
|
|
612
|
+
}
|
|
613
|
+
async performBrowserFollowup(context, _oldCredentials) {
|
|
614
|
+
const page = context.pages()[0];
|
|
615
|
+
if (!page) {
|
|
616
|
+
throw new LoginFailedError('No page available in browser context.');
|
|
617
|
+
}
|
|
618
|
+
// Fail fast on anything unreachable: without this, a click blocked by a
|
|
619
|
+
// leftover modal backdrop retries for Playwright's default half minute
|
|
620
|
+
// before it reports a timeout.
|
|
621
|
+
page.setDefaultTimeout(DEFAULT_TIMEOUT_MS);
|
|
622
|
+
// Sign-in happened on Zoom itself; the app is created on the Marketplace,
|
|
623
|
+
// which the same session carries over to.
|
|
624
|
+
await page.goto(ZOOM_MARKETPLACE_BUILD_URL);
|
|
625
|
+
await waitForMarketplaceHeader(page);
|
|
626
|
+
const userInfo = await readMarketplaceUserInfo(page);
|
|
627
|
+
if (userInfo === null) {
|
|
628
|
+
throw new LoginFailedError('The Zoom App Marketplace did not recognize the signed-in user. ' +
|
|
629
|
+
'Sign in to https://marketplace.zoom.us/ in the browser window and try again.');
|
|
630
|
+
}
|
|
631
|
+
if (userInfo.canBuildServerToServerOAuthApp === false) {
|
|
632
|
+
throw new ZoomAppCreationNotPermittedError();
|
|
633
|
+
}
|
|
634
|
+
const appKindDialog = await this.openAppKindDialog(page);
|
|
635
|
+
await selectServerToServerAppKind(appKindDialog);
|
|
636
|
+
await clickEnabledPrimaryDialogButton(page);
|
|
637
|
+
await nameAndCreateApp(page, this.generateAppName());
|
|
638
|
+
const appId = await waitForCreatedAppId(page);
|
|
639
|
+
const credentials = await readAppCredentials(page, userInfo.email);
|
|
640
|
+
await continueToScopesPage(page, appId, userInfo, this.appNamePrefix);
|
|
641
|
+
await addAllScopes(page);
|
|
642
|
+
await clickContinueButton(page);
|
|
643
|
+
await activateApp(page);
|
|
644
|
+
const credentialsWithToken = await mintFirstAccessToken(page, credentials);
|
|
645
|
+
await page.close();
|
|
646
|
+
return credentialsWithToken;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
3
649
|
export class Zoom extends Service {
|
|
4
650
|
name = 'zoom';
|
|
5
651
|
displayName = 'Zoom';
|
|
6
652
|
baseApiUrls = ['https://api.zoom.us/v2/'];
|
|
7
|
-
loginUrl =
|
|
8
|
-
info = 'https://developers.zoom.us/docs/api/.'
|
|
653
|
+
loginUrl = ZOOM_SIGN_IN_URL;
|
|
654
|
+
info = 'https://developers.zoom.us/docs/api/. ' +
|
|
655
|
+
'Browser login creates a Server-to-Server OAuth app with all admin scopes; ' +
|
|
656
|
+
'its access token is renewed automatically.';
|
|
9
657
|
credentialCheckCurlArguments = [
|
|
10
658
|
'-H',
|
|
11
659
|
'Content-Type: application/json',
|
|
12
660
|
'https://api.zoom.us/v2/users?page_size=1',
|
|
13
661
|
];
|
|
14
662
|
setCredentialsExample(serviceName) {
|
|
15
|
-
return `latchkey auth set ${serviceName} -
|
|
663
|
+
return `latchkey auth set-nocurl ${serviceName} <account-id> <client-id> <client-secret>`;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Accept the credentials of a Server-to-Server OAuth app the user created
|
|
667
|
+
* themselves, in the order the Zoom App Marketplace shows them.
|
|
668
|
+
*/
|
|
669
|
+
getCredentialsNoCurl(arguments_) {
|
|
670
|
+
const [accountId, clientId, clientSecret] = arguments_;
|
|
671
|
+
if (arguments_.length !== 3 ||
|
|
672
|
+
accountId === undefined ||
|
|
673
|
+
clientId === undefined ||
|
|
674
|
+
clientSecret === undefined) {
|
|
675
|
+
throw new ZoomCredentialError('Expected exactly three arguments: <account-id> <client-id> <client-secret>.\n' +
|
|
676
|
+
'They are the credentials of a Zoom Server-to-Server OAuth app.\n' +
|
|
677
|
+
`Example: ${this.setCredentialsExample(this.name)}`);
|
|
678
|
+
}
|
|
679
|
+
return new ZoomServerToServerCredentials(accountId, clientId, clientSecret);
|
|
680
|
+
}
|
|
681
|
+
getSession(appNamePrefix) {
|
|
682
|
+
return new ZoomServiceSession(this, appNamePrefix);
|
|
16
683
|
}
|
|
17
684
|
/**
|
|
18
685
|
* The account comes from /users/me rather than the user-list endpoint used
|
|
19
686
|
* by the credential check, which carries no identity. Server-to-server
|
|
20
687
|
* tokens have no user context and error on /users/me, in which case the
|
|
21
|
-
* account
|
|
688
|
+
* account is named after the user the app was created by, and failing that
|
|
689
|
+
* by the app's (opaque) Zoom account id.
|
|
22
690
|
*/
|
|
23
|
-
getAccount(apiCredentials) {
|
|
24
|
-
|
|
691
|
+
async getAccount(apiCredentials) {
|
|
692
|
+
const account = await fetchAccountFromEndpoint(apiCredentials, ['https://api.zoom.us/v2/users/me'], (responseBody) => {
|
|
25
693
|
const data = tryParseJson(responseBody);
|
|
26
694
|
if (data === null || data.code !== undefined) {
|
|
27
695
|
return null;
|
|
28
696
|
}
|
|
29
697
|
return data.email ?? data.id ?? null;
|
|
30
698
|
});
|
|
699
|
+
if (account !== null) {
|
|
700
|
+
return account;
|
|
701
|
+
}
|
|
702
|
+
if (!(apiCredentials instanceof ZoomServerToServerCredentials)) {
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
return apiCredentials.accountEmail ?? apiCredentials.accountId;
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Server-to-server credentials carry no refresh token: a fresh access token
|
|
709
|
+
* is minted from the client id and secret whenever the old one expires.
|
|
710
|
+
*/
|
|
711
|
+
async refreshCredentials(apiCredentials) {
|
|
712
|
+
if (!(apiCredentials instanceof ZoomServerToServerCredentials)) {
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
return await mintAccessToken(apiCredentials);
|
|
31
716
|
}
|
|
32
717
|
}
|
|
33
718
|
export const ZOOM = new Zoom();
|