collabSupport/integrations/webex/WebexClient.js
jmcqueen 1eaabbcee1 Add /voicereport store digest and harden Webex CDR feed handling.
Groups cdr_feed legs by Correlation ID for call-level summaries, fixes report-column field parsing and Docker proxy routing, and adds /calltest post-call Twilio and CDR enrichment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 13:43:11 -04:00

218 lines
No EOL
9.2 KiB
JavaScript

// integrations/webex/WebexClient.js
import axios from 'axios';
import WebexServiceAppAuth from './WebexServiceAppAuth.js';
import { logger } from '../../utils/logger.js'; // ← Your new custom logger
class WebexClient {
constructor() {
this.auth = new WebexServiceAppAuth();
this.baseURL = 'https://webexapis.com/v1';
logger('webex:client', 'WebexClient initialized with Service App auth');
}
async request(method, endpoint, data = null, params = null) {
const { data: body } = await this.requestRaw(method, endpoint, data, params);
return body;
}
// Like `request()` but returns `{ data, headers, status }`. Use this when
// callers need response headers — most notably the `Link` header for
// Webex's cursor-based pagination (`Link: <…?next=cursor>; rel="next"`).
// If `endpointOrUrl` looks like an absolute URL (e.g. a `Link: <…>; rel="next"`
// value extracted from a previous page), it's used as-is and `params` are
// ignored — the URL already carries the cursor. Otherwise it's treated as
// a path relative to `this.baseURL`.
async requestRaw(method, endpointOrUrl, data = null, params = null) {
const token = await this.auth.getAccessToken();
const isAbsolute = /^https?:\/\//i.test(endpointOrUrl);
const url = isAbsolute ? endpointOrUrl : `${this.baseURL}/${endpointOrUrl}`;
try {
const response = await axios({
method,
url,
headers: { Authorization: `Bearer ${token}` },
data,
params: isAbsolute ? undefined : params,
});
return { data: response.data, headers: response.headers, status: response.status };
} catch (err) {
if (err.response?.status === 401) {
logger('webex:client', '401 received from Webex — forcing token refresh', 'warn');
await this.auth.forceRefresh(); // serialized through auth mutex
return this.requestRaw(method, endpointOrUrl, data, params); // retry once
}
logger('webex:client', `API error on ${endpointOrUrl}: ${err.message}`, 'error');
if (err.response?.data) {
logger('webex:client', `Response data: ${JSON.stringify(err.response.data)}`, 'error');
}
throw err;
}
}
/**
* HTTP to analytics-calling / analytics.webexapis.com (cdr_feed, etc.).
* Bypasses HTTP(S)_PROXY by default — axios proxy-from-env often sends analytics
* subdomains through a corp proxy while webexapis.com is listed in NO_PROXY.
* Set WEBEX_CDR_USE_PROXY=true to honor the process proxy instead.
*/
async analyticsRequestRaw(method, url, { data = null, params = null, timeout = 15000 } = {}) {
const useProxy = String(process.env.WEBEX_CDR_USE_PROXY || '').toLowerCase() === 'true';
const token = await this.auth.getAccessToken();
try {
const response = await axios({
method,
url,
headers: { Authorization: `Bearer ${token}` },
data,
params,
timeout,
proxy: useProxy ? undefined : false,
});
return { data: response.data, headers: response.headers, status: response.status };
} catch (err) {
if (err.response?.status === 401) {
logger('webex:client', '401 on analytics host — forcing token refresh', 'warn');
await this.auth.forceRefresh();
return this.analyticsRequestRaw(method, url, { data, params, timeout });
}
throw err;
}
}
// Convenience wrappers
async getMe() {
return this.request('GET', 'people/me');
}
async listMessages(roomId, options = {}) {
return this.request('GET', 'messages', null, { roomId, ...options });
}
async createMessage(roomId, textOrObject) {
const payload = typeof textOrObject === 'string'
? { roomId, text: textOrObject }
: { roomId, ...textOrObject };
return this.request('POST', 'messages', payload);
}
// Add more as needed
async listRooms(max = 100) {
return this.request('GET', 'rooms', null, { max });
}
async getRoom(roomId) {
return this.request('GET', `rooms/${roomId}`);
}
// ── People lookup ──────────────────────────────────────────────────────────
// Returns the first person matching the given email, or null if none found.
// Routes through this.request() so it inherits auth + mutex + 401 retry.
//
// CAUTION: the list endpoint (`GET /v1/people?email=…`) returns a
// *partial* person record — admin-only fields such as `licenses`, `roles`,
// and `siteUrls` are only populated when fetching a single person via
// `GET /v1/people/{id}` (see `getPerson` below). If you need any of those
// fields, call `getPerson(returnedUser.id)` instead of trusting this
// result. The Webex API does this on purpose for list-endpoint
// performance.
async findPersonByEmail(email) {
const data = await this.request('GET', 'people', null, { email });
return data.items?.[0] || null;
}
// ── Authorizations (admin-only) ────────────────────────────────────────────
// List and revoke a user's OAuth authorizations. Requires the service app to
// have the `identity:tokens_read` + `identity:tokens_write` scopes *and* the
// signed-in admin to have Full / User / Device Admin role. See
// https://developer.webex.com/admin/docs/api/v1/authorizations
async listAuthorizations(personId) {
return this.request('GET', 'authorizations', null, { personId });
}
async deleteAuthorization(authorizationId) {
return this.request('DELETE', `authorizations/${authorizationId}`);
}
// ── People (admin) ─────────────────────────────────────────────────────────
// Full person record including the `licenses` array (license IDs the user
// currently holds). Used to determine whether a user already has a meeting
// license on a given site.
async getPerson(personId) {
return this.request('GET', `people/${personId}`);
}
// ── Licenses (admin) ───────────────────────────────────────────────────────
// List org licenses. Each item carries `{ id, name, totalUnits,
// consumedUnits, subscriptionId, siteUrl, siteType }`. Requires the service
// app to hold the `spark-admin:licenses_read` scope.
async listLicenses(orgId = null) {
return this.request('GET', 'licenses', null, orgId ? { orgId } : null);
}
// Assign / remove licenses on a single user. Endpoint is `licenses/users`
// (not `/licenses/people` — confirmed via the wxc_sdk source). Body shape:
// {
// personId: '...', // OR email
// licenses: [{ id, operation: 'add'|'remove', properties? }],
// siteUrls: [{ siteUrl, accountType: 'attendee', operation }],
// orgId?: '...',
// }
// Returns 200 (full success) or 206 (partial) with `{ licenses[],
// pendingLicenses[], siteUrls[], pendingSiteUrls[] }`. Requires
// `spark-admin:people_write`.
async assignLicensesToUser({ personId, email, licenses, siteUrls, orgId } = {}) {
const body = {};
if (email) body.email = email;
if (personId) body.personId = personId;
if (orgId) body.orgId = orgId;
if (licenses) body.licenses = licenses;
if (siteUrls) body.siteUrls = siteUrls;
return this.request('PATCH', 'licenses/users', body);
}
// Returns the full deduplicated list of users assigned to a license,
// following Webex's `Link: <…>; rel="next"` cursor pagination. Each entry:
// { id, type: 'INTERNAL'|'EXTERNAL', displayName?, email? }
// This is the reliable way to determine whether a specific user holds a
// specific license — the per-person `licenses` field on `/v1/people/{id}`
// is unreliable for service-app tokens (returns empty even for assigned
// users). Requires `spark-admin:licenses_read`.
async listLicenseAssignees(licenseId, { pageSize = 300 } = {}) {
const all = [];
let { data, headers } = await this.requestRaw(
'GET',
`licenses/${licenseId}`,
null,
{ includeAssignedTo: 'user', limit: pageSize },
);
if (Array.isArray(data?.users)) all.push(...data.users);
let nextUrl = parseLinkNext(headers?.link || headers?.Link);
while (nextUrl) {
({ data, headers } = await this.requestRaw('GET', nextUrl));
if (Array.isArray(data?.users)) all.push(...data.users);
nextUrl = parseLinkNext(headers?.link || headers?.Link);
}
return all;
}
}
// Parses a Webex `Link` header (RFC 5988) and returns the URL of the `next`
// page, or null. Webex headers look like:
// Link: <https://webexapis.com/v1/licenses/X?...&next=CURSOR>; rel="next"
function parseLinkNext(linkHeader) {
if (!linkHeader || typeof linkHeader !== 'string') return null;
// Tolerate multiple link entries (comma-separated) by splitting and matching each.
for (const part of linkHeader.split(',')) {
const m = part.match(/<([^>]+)>\s*;\s*rel\s*=\s*"?next"?/i);
if (m) return m[1];
}
return null;
}
// Create and export the singleton instance
const webex = new WebexClient();
export default webex;