Multi-integration Webex chat/HTTP bot that unifies phone, AV, and network status for retail store support. Consolidates data from Webex Calling, Meraki, Workspace ONE (MDM), Atlas AMP, RED digital signage, and OptiSigns into rich per-store status commands. Key surfaces: - /phonestatus, /avstatus — per-store phone & AV device reports with clickable Meraki deep-links and per-port detail. - /webexhost — check/assign Webex Meetings host licenses via the Service App; adaptive-card confirmation flow, HTTP-API-gated. - /offboarduser — full Webex Admin offboarding (auth revoke, device wipe, license removal); adaptive-card confirmation. - /jirapoll — on-demand trigger for the hourly Jira poller. - /bulkavstatuscsv — bulk store CSV export with concurrency limits. Automation: - Hourly Jira poller (node-cron) with an X.AI (Grok) ticket classifier that categorizes unassigned tickets as phone/av/skip, extracts store numbers from free-text, and enriches Jira with the same detailed markdown the chat commands emit (converted to Jira ADF, preserves bold + Meraki links). Idempotent via a `bot-enriched` Jira label. Architecture: - Node.js 20+, ESM, Express 5, webex-node-bot-framework. - Layered integrations (integrations/*), services (services/*), commands (commands/*), utils (utils/*). - Shared markdown renderers (services/renderers/*) feed both chat handlers and the Jira poller so the two surfaces stay in sync. - Hand-rolled markdown-to-ADF converter (utils/markdownToAdf.js) — no new npm dependency. - Node built-in test runner (`node --test tests/*.test.js`), 30 tests covering the converter, renderers, and poller ADF assembly. Docker + docker-compose deployment. Config via .env (see .env.example for the full option surface).
187 lines
No EOL
8 KiB
JavaScript
187 lines
No EOL
8 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;
|
|
}
|
|
}
|
|
|
|
// 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; |