// Webex REST client wrappers. Every function that hits webexapis.com lives // here so index.js doesn't have to juggle URLs, auth headers, or the 429 // retry loop. State (token getters, logger) is injected via // createWebexClient() so the module itself stays free of module-level // mutable state — that keeps it importable from anywhere without // initialization-order landmines. // // Usage: // import { createWebexClient } from './lib/webex.js'; // const webex = createWebexClient({ // getBotToken: (app) => botTokens[app] && botTokens[app].token, // getServiceAccountAccessToken: () => serviceAccountToken.access_token, // getServiceAccountRefreshToken: () => serviceAccountToken.refresh_token, // saveServiceAccountToken: (t) => { /* persist to disk */ }, // env: process.env, // logger, // }); import fs from 'fs'; export function createWebexClient(deps) { var getBotToken = deps.getBotToken; var getServiceAccountAccessToken = deps.getServiceAccountAccessToken; var getServiceAccountRefreshToken = deps.getServiceAccountRefreshToken; var saveServiceAccountToken = deps.saveServiceAccountToken; var env = deps.env || {}; var logger = deps.logger || function () {}; // ---- low-level fetch helpers ----------------------------------------- async function fetchWithRateLimit(url, requestOptions) { const response = await fetch(url, requestOptions); if (response.status === 429) { const secondsToWait = Number(response.headers.get('retry-after')); logger('fetchWithRateLimit', 'Waiting ' + secondsToWait + 's on 429 for ' + url); await new Promise(resolve => setTimeout(resolve, secondsToWait * 1000)); return await fetchWithRateLimit(url, requestOptions); } return response; } function getMillisToSleep(retryHeaderString) { let millisToSleep = Math.round(parseFloat(retryHeaderString) * 1000); if (isNaN(millisToSleep)) { millisToSleep = Math.max(0, new Date(retryHeaderString) - new Date()); } return millisToSleep; } function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } // Variant of fetchWithRateLimit that takes a callable so the caller can // rebuild a fresh request per retry (needed for POSTs whose body is a // stream — the body is consumed on the first attempt). async function fetchAndRetryIfNecessary(callAPIFn) { const response = await callAPIFn(); if (response.status === 429) { const retryAfter = response.headers.get('retry-after'); const millisToSleep = getMillisToSleep(retryAfter); await sleep(millisToSleep); return fetchAndRetryIfNecessary(callAPIFn); } return response; } // ---- people ---------------------------------------------------------- // GET /people/me — resolves the caller identified by `bearerToken`. // Used by loadBotProfile (with the bot token) and the OAuth callback // (with the freshly issued user access token). function whoAmI(bearerToken) { return new Promise(function (resolve, reject) { logger('whoAmI', 'Fetching /people/me.'); var requestOptions = { method: 'GET', headers: { 'Authorization': 'Bearer ' + bearerToken, 'Content-Type': 'application/json' }, redirect: 'follow' }; fetchWithRateLimit('https://webexapis.com/v1/people/me', requestOptions) .then(async function (response) { resolve(await response.json()); }) .catch(reject); }); } // GET /people?email= via the service account. Resolves to the raw // Webex person record (id, displayName, emails, avatar, ...) or // rejects on !ok. Used by the CSV recipient parser. function getPersonInfo(email) { return new Promise(function (resolve, reject) { var url = 'https://webexapis.com/v1/people?email=' + encodeURIComponent(email) + '&max=100'; var requestOptions = { method: 'GET', headers: { 'Authorization': 'Bearer ' + getServiceAccountAccessToken(), 'Content-Type': 'application/json' }, redirect: 'follow' }; fetchWithRateLimit(url, requestOptions) .then(async function (response) { logger('getPersonInfo', email + ': ' + response.status + ' ' + response.statusText); if (!response.ok) return reject(new Error('HTTP ' + response.status)); var userInfo = await response.json(); resolve(userInfo.items && userInfo.items[0]); }) .catch(reject); }); } // GET /people?email= via the service account, normalized for the admin // UI (id/displayName/email/avatar or null when no match). Wraps // getPersonInfo behavior in the shape the /admin/users route wants. function findPersonByEmail(email) { return new Promise(function (resolve, reject) { var url = 'https://webexapis.com/v1/people?email=' + encodeURIComponent(email); var requestOptions = { method: 'GET', headers: { 'Authorization': 'Bearer ' + getServiceAccountAccessToken(), 'Content-Type': 'application/json' } }; fetchWithRateLimit(url, requestOptions) .then(function (response) { if (!response.ok) { return reject(new Error('HTTP ' + response.status + ' from Webex /people')); } return response.json(); }) .then(function (body) { var items = body && body.items; if (!items || !items.length) return resolve(null); var person = items[0]; resolve({ id: person.id, displayName: person.displayName, email: (person.emails && person.emails[0]) || email, avatar: person.avatar || null }); }) .catch(reject); }); } // ---- groups ---------------------------------------------------------- // Paginated GET /groups. Returns the flat list of every group in the // org (typically ~10k). Uses the service account token because bot // tokens can't read the SCIM group directory. function findWebexGroup() { return new Promise(async function (resolve, reject) { var groups = []; var startIndex = 1; var count = 500; var groupSize = 10000; var url = new URL('https://webexapis.com/v1/groups'); url.searchParams.append('attributes', 'displayName'); url.searchParams.append('sortBy', 'displayName'); url.searchParams.append('sortOrder', 'ascending'); url.searchParams.append('includeMembers', 'false'); url.searchParams.append('startIndex', startIndex); url.searchParams.append('count', count); var requestOptions = { method: 'GET', headers: { 'Authorization': 'Bearer ' + getServiceAccountAccessToken(), 'Content-Type': 'application/json' } }; while (groupSize > groups.length) { try { var fetchedGroups = await fetchWithRateLimit(url, requestOptions); if (fetchedGroups.ok) { var groupData = await fetchedGroups.json(); groupSize = groupData.totalResults; for (var group of groupData.groups) groups.push(group); startIndex = startIndex + count; url.searchParams.delete('startIndex'); url.searchParams.append('startIndex', startIndex); } else { return reject(fetchedGroups.status + ': ' + fetchedGroups.statusText); } } catch (error) { logger('findWebexGroup', 'page fetch failed: ' + (error && error.message || error)); return reject(error); } } resolve(groups); }); } // Paginated GET /groups/:id/members. Returns { members: [...], groups: // [...] } separated by whether the child is a person or a nested // group. Callers must recurse into nested groups themselves. function getGroupMembers(groupId) { return new Promise(async function (resolve, reject) { var groupMembers = { members: [], groups: [] }; var startIndex = 1; var count = 500; var memberSize = 10000; var requestOptions = { method: 'GET', headers: { 'Authorization': 'Bearer ' + getServiceAccountAccessToken(), 'Content-Type': 'application/json' } }; while (memberSize > (groupMembers.members.length + groupMembers.groups.length)) { var pageUrl = 'https://webexapis.com/v1/groups/' + groupId + '/members?startIndex=' + startIndex + '&count=' + count; try { var groupResponse = await fetchWithRateLimit(pageUrl, requestOptions); if (groupResponse.ok) { var groupData = await groupResponse.json(); groupMembers.displayName = groupData.displayName; memberSize = groupData.memberSize; for (var member of groupData.members) { if (member.type == 'group') { groupMembers.groups.push({ id: member.id, name: member.displayName }); } else { groupMembers.members.push(member); } } startIndex = startIndex + count; } else { return reject(groupResponse.status + ': ' + groupResponse.statusText); } } catch (error) { logger('getGroupMembers', 'page fetch failed: ' + (error && error.message || error)); return reject(error); } } resolve(groupMembers); }); } // ---- messages -------------------------------------------------------- // POST /messages as a bot. Attaches an image from ./uploads/ // when supplied. Resolves with the parsed Webex message record // (including id) so callers can retract later via deleteMessage. function sendDirectMessage(toPersonId, message, imageName, appName) { return new Promise(function (resolve, reject) { var myHeaders = new Headers(); myHeaders.append('Authorization', 'Bearer ' + getBotToken(appName)); var formdata = new FormData(); formdata.append('toPersonId', toPersonId); formdata.append('markdown', message); if (imageName) { var stream = fs.createReadStream('./uploads/' + imageName); formdata.append('files', stream, imageName); } var requestOptions = { method: 'POST', headers: myHeaders, body: formdata, redirect: 'follow' }; fetchWithRateLimit('https://webexapis.com/v1/messages', requestOptions) .then(response => response.text()) .then(result => { try { resolve(JSON.parse(result)); } catch (err) { reject(err); } }) .catch(reject); }); } // POST /messages carrying an Adaptive Card attachment. Fire-and-forget // for job-completion cards. function sendDirectCard(toPersonId, cardInfo, appName) { return new Promise(function (resolve, reject) { var body = { toPersonId: toPersonId, markdown: cardInfo.message, attachments: [ { contentType: 'application/vnd.microsoft.card.adaptive', content: cardInfo.card } ] }; var requestOptions = { method: 'POST', headers: { 'Authorization': 'Bearer ' + getBotToken(appName), 'Content-Type': 'application/json' }, body: JSON.stringify(body), redirect: 'follow' }; fetchWithRateLimit('https://webexapis.com/v1/messages', requestOptions) .then(async function (response) { await response.text(); resolve(response); }) .catch(reject); }); } // POST /messages via the retry-friendly path (streamed body). Used by // the send queue where transient 429s are worth retrying. function sendMessageWithRetry(toPersonId, message, imageName, appName) { var url = 'https://webexapis.com/v1/messages'; return fetchAndRetryIfNecessary(async () => { var myHeaders = new Headers(); myHeaders.append('Authorization', 'Bearer ' + getBotToken(appName)); var formdata = new FormData(); formdata.append('toPersonId', toPersonId); formdata.append('markdown', message); if (imageName) { var stream = fs.createReadStream('./uploads/' + imageName); formdata.append('files', stream, imageName); } return fetch(url, { method: 'POST', headers: myHeaders, body: formdata, redirect: 'follow' }); }); } // Best-effort DELETE /messages/:id. Never rejects — the callers (right // now: preview-DM retraction) always want the flow to continue whether // this succeeds or not. Resolves to true on 2xx, false otherwise. function deleteMessage(messageId, appName) { return new Promise(function (resolve) { var url = 'https://webexapis.com/v1/messages/' + encodeURIComponent(messageId); var requestOptions = { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + getBotToken(appName) } }; fetchWithRateLimit(url, requestOptions) .then(function (response) { if (!response.ok) { logger('deleteMessage', 'HTTP ' + response.status + ' for ' + messageId); } resolve(response.ok); }) .catch(function (err) { logger('deleteMessage', 'error for ' + messageId + ': ' + (err && err.message || err)); resolve(false); }); }); } // ---- OAuth refresh --------------------------------------------------- // Renews the service-account access token using its refresh token. // Also computes / persists renewBy + refreshBy timestamps in // America/New_York for the cron that reads them. function refreshToken() { return new Promise(function (resolve, reject) { var params = new URLSearchParams(); params.append('grant_type', 'refresh_token'); params.append('client_id', env.WEBEX_SERVICE_ACCOUNT_CLIENT_ID || ''); params.append('client_secret', env.WEBEX_SERVICE_ACCOUNT_CLIENT_SECRET || ''); params.append('refresh_token', getServiceAccountRefreshToken()); fetchWithRateLimit('https://webexapis.com/v1/access_token', { method: 'post', body: params }) .then(function (res) { logger('refreshToken', 'HTTP ' + res.status); return res.json(); }) .then(function (json) { var opts = { timeZoneName: 'short', timeZone: 'America/New_York' }; var updated = Object.assign({}, json, { created: new Date().toLocaleString('en-US', opts), renewBy: new Date(Date.now() + (json.expires_in * 1000)).toLocaleString('en-US', opts), refreshBy: new Date(Date.now() + (json.refresh_token_expires_in * 1000)).toLocaleString('en-US', opts) }); saveServiceAccountToken(updated); resolve('Refresh Success!\nExpires: ' + updated.renewBy + '\nRefreshBy: ' + updated.refreshBy); }) .catch(reject); }); } return { fetchWithRateLimit, fetchAndRetryIfNecessary, getMillisToSleep, sleep, whoAmI, getPersonInfo, findPersonByEmail, findWebexGroup, getGroupMembers, sendDirectMessage, sendDirectCard, sendMessageWithRetry, deleteMessage, refreshToken, }; }