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).
209 lines
No EOL
6.8 KiB
JavaScript
209 lines
No EOL
6.8 KiB
JavaScript
// src/integrations/webex/WebexServiceAppAuth.js
|
||
import axios from 'axios';
|
||
import { promises as fs } from 'node:fs';
|
||
import path from 'node:path';
|
||
import { Mutex } from 'async-mutex';
|
||
import { logger } from '../../utils/logger.js'; // ← Your new custom logger
|
||
|
||
class WebexServiceAppAuth {
|
||
static #instance = null;
|
||
|
||
constructor({
|
||
clientId = process.env.WEBEX_CLIENT_ID,
|
||
clientSecret = process.env.WEBEX_CLIENT_SECRET,
|
||
tokensFilePath = process.env.WEBEX_TOKENS_PATH
|
||
|| path.join(process.cwd(), 'tokens', 'webex-service-tokens.json'),
|
||
} = {}) {
|
||
// Singleton pattern
|
||
if (WebexServiceAppAuth.#instance) {
|
||
return WebexServiceAppAuth.#instance;
|
||
}
|
||
|
||
// Required validation
|
||
if (!clientId) {
|
||
throw new Error('WEBEX_CLIENT_ID is required (set it in environment variables)');
|
||
}
|
||
if (!clientSecret) {
|
||
throw new Error('WEBEX_CLIENT_SECRET is required (set it in environment variables)');
|
||
}
|
||
|
||
this.clientId = clientId;
|
||
this.clientSecret = clientSecret;
|
||
// Always resolve to an absolute path (based on cwd at startup).
|
||
// This makes error logs and fs operations unambiguous whether running locally or in Docker.
|
||
// .env should prefer a *relative* path like ./config/webex-service-tokens.json
|
||
// so the same .env works both on host (cwd=project root) and inside container (cwd=/app + volume mount).
|
||
this.tokensFilePath = path.resolve(tokensFilePath);
|
||
|
||
// Token state
|
||
this.accessToken = null;
|
||
this.refreshToken = null;
|
||
this.expiresAt = 0; // Unix timestamp in ms
|
||
|
||
// Serializes loadTokens() + refresh() so concurrent callers (cron, webhook,
|
||
// HTTP request, framework event firing at the same time) don't issue
|
||
// parallel refresh requests. Cisco rotates the refresh_token on every use,
|
||
// so a race here would invalidate one of the in-flight refreshes and we'd
|
||
// lose our credentials until manual re-bootstrap.
|
||
this._authMutex = new Mutex();
|
||
|
||
WebexServiceAppAuth.#instance = this;
|
||
|
||
logger('webex:auth', 'WebexServiceAppAuth initialized with environment variables');
|
||
}
|
||
|
||
/**
|
||
* Load persisted tokens from file
|
||
*/
|
||
async loadTokens() {
|
||
try {
|
||
const data = await fs.readFile(this.tokensFilePath, 'utf8');
|
||
const tokens = JSON.parse(data);
|
||
|
||
this.accessToken = tokens.accessToken;
|
||
this.refreshToken = tokens.refreshToken;
|
||
this.expiresAt = tokens.expiresAt || 0;
|
||
|
||
logger('webex:auth', 'Webex tokens successfully loaded from file');
|
||
} catch (err) {
|
||
if (err.code === 'ENOENT') {
|
||
logger('webex:auth', `No tokens file found at ${this.tokensFilePath}`, 'warn');
|
||
logger('webex:auth', 'You need to bootstrap initial tokens once (see documentation)', 'warn');
|
||
} else {
|
||
logger('webex:auth', `Failed to load tokens file: ${err.message}`, 'error');
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Save current token state to file
|
||
*/
|
||
async saveTokens() {
|
||
const payload = {
|
||
accessToken: this.accessToken,
|
||
refreshToken: this.refreshToken,
|
||
expiresAt: this.expiresAt,
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
|
||
try {
|
||
await fs.mkdir(path.dirname(this.tokensFilePath), { recursive: true });
|
||
await fs.writeFile(this.tokensFilePath, JSON.stringify(payload, null, 2), 'utf8');
|
||
logger('webex:auth', `Webex tokens saved to ${this.tokensFilePath}`);
|
||
} catch (err) {
|
||
logger('webex:auth', `Failed to save tokens: ${err.message}`, 'error');
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Refresh access token using the current refresh token
|
||
*/
|
||
async refresh() {
|
||
if (!this.refreshToken) {
|
||
throw new Error(
|
||
'No refresh token available. ' +
|
||
'Bootstrap initial access_token + refresh_token first ' +
|
||
'(via Developer Portal or Applications Token API).'
|
||
);
|
||
}
|
||
|
||
try {
|
||
const params = new URLSearchParams({
|
||
grant_type: 'refresh_token',
|
||
client_id: this.clientId,
|
||
client_secret: this.clientSecret,
|
||
refresh_token: this.refreshToken,
|
||
});
|
||
|
||
const response = await axios.post('https://webexapis.com/v1/access_token', params, {
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
timeout: 10000,
|
||
});
|
||
|
||
const data = response.data;
|
||
|
||
this.accessToken = data.access_token;
|
||
this.refreshToken = data.refresh_token; // Cisco rotates refresh tokens
|
||
this.expiresAt = Date.now() + (data.expires_in * 1000) - (5 * 60 * 1000); // 5 min safety buffer
|
||
|
||
await this.saveTokens();
|
||
|
||
logger('webex:auth', `Tokens refreshed successfully — new access token expires in ${data.expires_in} seconds`);
|
||
return this.accessToken;
|
||
|
||
} catch (err) {
|
||
const errorDetail = err.response?.data || err.message;
|
||
logger('webex:auth', `Token refresh failed: ${errorDetail}`, 'error');
|
||
|
||
if (err.response?.status === 400 || err.response?.status === 401) {
|
||
throw new Error(
|
||
'Refresh token may be invalid or revoked. ' +
|
||
'You will need to bootstrap new tokens manually.'
|
||
);
|
||
}
|
||
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get a currently valid access token.
|
||
* Will refresh automatically if expired or near expiry.
|
||
*
|
||
* The whole "check + refresh" path runs inside a mutex so concurrent callers
|
||
* never trigger overlapping token rotations.
|
||
*/
|
||
async getAccessToken() {
|
||
// Fast path: already-valid in-memory token, no lock needed.
|
||
if (this.accessToken && Date.now() < this.expiresAt) {
|
||
return this.accessToken;
|
||
}
|
||
|
||
return this._authMutex.runExclusive(async () => {
|
||
// Re-check inside the critical section — another caller may have
|
||
// already loaded/refreshed while we were waiting for the lock.
|
||
if (this.accessToken && Date.now() < this.expiresAt) {
|
||
return this.accessToken;
|
||
}
|
||
|
||
// Lazy-load on first use
|
||
if (!this.accessToken && !this.refreshToken) {
|
||
try {
|
||
await this.loadTokens();
|
||
} catch (err) {
|
||
throw new Error('Tokens not loaded and no file present – bootstrap required');
|
||
}
|
||
}
|
||
|
||
if (this.accessToken && Date.now() < this.expiresAt) {
|
||
return this.accessToken;
|
||
}
|
||
|
||
logger('webex:auth', 'Access token expired or missing → refreshing...');
|
||
return this.refresh();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Force a refresh (useful for testing or recovery).
|
||
* Also serialized through the auth mutex so it can't race a normal getAccessToken().
|
||
*/
|
||
async forceRefresh() {
|
||
logger('webex:auth', 'Forcing token refresh...', 'warn');
|
||
return this._authMutex.runExclusive(() => this.refresh());
|
||
}
|
||
|
||
/**
|
||
* Clear all token state (for logout/testing)
|
||
*/
|
||
clearTokens() {
|
||
this.accessToken = null;
|
||
this.refreshToken = null;
|
||
this.expiresAt = 0;
|
||
logger('webex:auth', 'Webex token state cleared', 'warn');
|
||
}
|
||
}
|
||
|
||
export default WebexServiceAppAuth; |