// 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; } } // 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: ; 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;