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>
218 lines
No EOL
9.2 KiB
JavaScript
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; |