// src/commands/webexHost.js // // /webexhost — report whether a user holds any Webex Meetings host // license on the configured site. If they don't, post // a confirmation card to assign the default host // license (set via WEBEX_HOST_LICENSE_ID). // /webexhost list — discovery helper: list every Webex Meetings license // on the configured site with `id`, `name`, and free // seats. Marks the one currently set as // WEBEX_HOST_LICENSE_ID so the operator knows which // one auto-assignment will use. // // Host detection model // The Webex license-assignment API explicitly states that the host vs // attendee distinction on a site is **determined by whether the user holds a // meeting license whose `siteUrl` matches the site**. There is no separate // "host flag" exposed via any Webex API (the SiteUrlsRequest contract only // accepts `attendee` — see wxc_sdk source). So: // - "is host" ↔ user.licenses ∩ { licenses on this site } ≠ ∅ // - "make host" ↔ PATCH /v1/licenses/users adding the configured license // // Required scopes on the Webex service app // spark-admin:licenses_read — list/inspect licenses // spark-admin:people_read — already used elsewhere // spark-admin:people_write — apply license assignments // // Configuration // WEBEX_HOST_SITE_URL defaults to "aeo2go.webex.com" // WEBEX_HOST_LICENSE_ID no default — must be set before auto-assignment // can run. Use `/webexhost list` to discover IDs. import { logger } from '../utils/logger.js'; import webex from '../integrations/webex/WebexClient.js'; import { pendingHostAssigns } from '../utils/pendingHostAssigns.js'; import { extractRequester, describeRequester } from '../utils/requester.js'; const SITE_URL = process.env.WEBEX_HOST_SITE_URL || 'aeo2go.webex.com'; // ───────────────────────────────────────────────────────────────────────────── // License cache // ───────────────────────────────────────────────────────────────────────────── // Org-wide license list rarely changes (seats consumed do, but the license // IDs/names don't). We cache for LICENSE_CACHE_TTL_MS to avoid re-fetching on // every /webexhost invocation. The cache holds *only the licenses on the // configured site* — that's all this command cares about. const LICENSE_CACHE_TTL_MS = 5 * 60 * 1000; let _siteLicenseCache = null; let _siteLicenseCacheAt = 0; // Per-license assignee cache: { licenseId -> { personIds: Set, fetchedAt: number } }. // We need this because the per-person `licenses` field on /v1/people/{id} is // unreliable for service-app tokens (returns empty for users who demonstrably // hold licenses — confirmed via /webexhost debug). The authoritative source // is the reverse-lookup endpoint `/v1/licenses/{id}?includeAssignedTo=user`, // which is paginated and can be expensive for large licenses (the org's // Webex Meetings Suite has ~3200 users → ~11 pages). Caching the full // assignee set per license keeps subsequent /webexhost calls instant. const ASSIGNEE_CACHE_TTL_MS = 30 * 60 * 1000; const _assigneeCache = new Map(); export function _resetLicenseCacheForTests() { _siteLicenseCache = null; _siteLicenseCacheAt = 0; _assigneeCache.clear(); } async function getSiteLicenses() { if (_siteLicenseCache && Date.now() - _siteLicenseCacheAt < LICENSE_CACHE_TTL_MS) { return _siteLicenseCache; } const data = await webex.listLicenses(); const items = Array.isArray(data?.items) ? data.items : []; _siteLicenseCache = items.filter((l) => l.siteUrl === SITE_URL); _siteLicenseCacheAt = Date.now(); return _siteLicenseCache; } // Returns a Set for the given license. Walks every page on first // call (1–N HTTP requests depending on license size); subsequent calls within // ASSIGNEE_CACHE_TTL_MS are an O(1) Map lookup. async function getLicenseAssignees(licenseId) { const cached = _assigneeCache.get(licenseId); if (cached && Date.now() - cached.fetchedAt < ASSIGNEE_CACHE_TTL_MS) { return cached.personIds; } const users = await webex.listLicenseAssignees(licenseId); const personIds = new Set(); for (const u of users) { if (u && typeof u.id === 'string') personIds.add(u.id); } _assigneeCache.set(licenseId, { personIds, fetchedAt: Date.now() }); return personIds; } // Reverse-lookup detection: for each site license, check whether personId is // in its assignee list (cached). Runs in parallel across licenses. Returns // the licenses the user actually holds, plus any per-license errors so the // caller can surface them. async function findHeldSiteLicensesViaAssignees(personId, siteLicenses) { const results = await Promise.allSettled( siteLicenses.map(async (l) => { const assignees = await getLicenseAssignees(l.id); return { license: l, isHeld: assignees.has(personId) }; }), ); const held = []; const errors = []; results.forEach((r, i) => { if (r.status === 'fulfilled') { if (r.value.isHeld) held.push(r.value.license); } else { errors.push({ license: siteLicenses[i], error: explainWebexAdminError(r.reason) }); } }); return { held, errors }; } // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── function explainWebexAdminError(err) { const status = err?.response?.status; const apiMsg = err?.response?.data?.message || err?.response?.data?.errors?.[0]?.description || err?.message || String(err); if (status === 401 || status === 403) { return ( `${apiMsg} (HTTP ${status}). The service-app token is missing a required ` + `admin scope (likely \`spark-admin:people_write\` for assignments, or ` + `\`spark-admin:people_read\` if read fields are empty). ` + `**Important:** adding scopes at developer.webex.com is NOT enough — ` + `existing refresh tokens preserve their original scope set. ` + `You must (1) update scopes on the service app, (2) re-authorize the app ` + `for the org as a Full / User Admin, then (3) re-bootstrap ` + `\`tokens/webex-service-tokens.json\` with a fresh access_token + refresh_token.` ); } return status ? `${apiMsg} (HTTP ${status})` : apiMsg; } function seatsFreeFor(license) { const total = Number(license.totalUnits ?? 0); const used = Number(license.consumedUnits ?? 0); return Math.max(0, total - used); } function intersectLicenses(personLicenseIds, siteLicenses) { const owned = new Set(personLicenseIds || []); return siteLicenses.filter((l) => owned.has(l.id)); } // ───────────────────────────────────────────────────────────────────────────── // /webexhost list — discovery helper // ───────────────────────────────────────────────────────────────────────────── async function handleListLicenses(bot) { let licenses; try { licenses = await getSiteLicenses(); } catch (err) { await bot.say('markdown', `❌ Failed to list licenses for \`${SITE_URL}\`: ${explainWebexAdminError(err)}`); return; } if (licenses.length === 0) { await bot.say( 'markdown', `No Webex Meetings licenses found on \`${SITE_URL}\`. ` + `Verify the site URL via \`WEBEX_HOST_SITE_URL\` env var.`, ); return; } const configuredId = process.env.WEBEX_HOST_LICENSE_ID; const lines = [`**Webex Meetings licenses on \`${SITE_URL}\`:**`, '']; for (const l of licenses) { const free = seatsFreeFor(l); const marker = configuredId && l.id === configuredId ? ' ← currently configured as `WEBEX_HOST_LICENSE_ID`' : ''; lines.push(`- **${l.name}** — ${free}/${l.totalUnits} seats free \n id: \`${l.id}\`${marker}`); } if (!configuredId) { lines.push( '', `_Set \`WEBEX_HOST_LICENSE_ID=\` in \`.env\` to enable ` + `\`/webexhost \` auto-assignment._`, ); } await bot.say('markdown', lines.join('\n')); } // ───────────────────────────────────────────────────────────────────────────── // /webexhost debug — diagnostic dump // ───────────────────────────────────────────────────────────────────────────── async function handleDebug(bot, email) { let searchHit, fullPerson, siteLicenses; try { searchHit = await webex.findPersonByEmail(email); } catch (err) { await bot.say('markdown', `❌ \`findPersonByEmail\` failed: ${explainWebexAdminError(err)}`); return; } if (!searchHit) { await bot.say('markdown', `❌ No Webex user found for **${email}**.`); return; } try { [fullPerson, siteLicenses] = await Promise.all([ webex.getPerson(searchHit.id), getSiteLicenses(), ]); } catch (err) { await bot.say('markdown', `❌ Debug fetch failed: ${explainWebexAdminError(err)}`); return; } const searchLics = Array.isArray(searchHit.licenses) ? searchHit.licenses : null; const fullLics = Array.isArray(fullPerson.licenses) ? fullPerson.licenses : []; const peopleApiOwned = intersectLicenses(fullLics, siteLicenses); // Always run the assignee-scan path too, so debug shows both signals for // direct comparison. Errors are reported per-license rather than failing // the whole debug call. const assigneeStart = Date.now(); const assigneeResult = await findHeldSiteLicensesViaAssignees(searchHit.id, siteLicenses); const assigneeMs = Date.now() - assigneeStart; const lines = [ `### 🔍 \`/webexhost debug ${email}\``, '', `**Person id:** \`${searchHit.id}\``, `**Display name:** ${searchHit.displayName || '(none)'}`, '', '**People API signal**', `- Search-endpoint \`licenses\`: ${searchLics === null ? '(field absent)' : `\`[${searchLics.length}]\` items`}`, `- GET /people/{id} \`licenses\`: \`[${fullLics.length}]\` items`, `- \`siteUrls\` on person record: ${Array.isArray(fullPerson.siteUrls) ? `\`${JSON.stringify(fullPerson.siteUrls)}\`` : '(field absent)'}`, '', '**Assignee-scan signal** (authoritative reverse lookup)', `- Per-license assignee lists fetched / cache-hit in ${assigneeMs}ms`, `- Held on site: ${assigneeResult.held.length > 0 ? assigneeResult.held.map((l) => `\`${l.name}\``).join(', ') : '_none_'}`, ]; if (assigneeResult.errors.length > 0) { lines.push(`- ⚠️ Errors:`); for (const e of assigneeResult.errors) { lines.push(` - \`${e.license.name}\`: ${e.error}`); } } lines.push('', `**Site licenses on \`${SITE_URL}\` (${siteLicenses.length}):**`); for (const l of siteLicenses) { const heldByPeople = fullLics.includes(l.id); const heldByAssignee = assigneeResult.held.some((h) => h.id === l.id); const marker = heldByPeople && heldByAssignee ? ' ✅ HELD (both signals agree)' : heldByAssignee ? ' ✅ HELD (assignee scan only — People API silent)' : heldByPeople ? ' ⚠️ HELD (People API only — assignee scan disagrees)' : ''; lines.push(`- \`${l.id}\` — \`${l.name}\`${marker}`); } lines.push(''); const finalVerdict = peopleApiOwned.length > 0 || assigneeResult.held.length > 0 ? `**Verdict:** host (bot will report "already a host")` : `**Verdict:** not a host (bot will offer assignment card)`; lines.push(finalVerdict); if (searchLics !== null && searchLics.length !== fullLics.length) { lines.push(''); lines.push( `ℹ️ Search endpoint and GET /people/{id} returned different license counts ` + `(${searchLics.length} vs ${fullLics.length}).`, ); } await bot.say('markdown', lines.join('\n')); } // ───────────────────────────────────────────────────────────────────────────── // Public entry points used by index.js (attachmentAction routing) // ───────────────────────────────────────────────────────────────────────────── // NOTE: `bot` here is the framework-provided per-room bot — its own `bot.say` // already routes to the originating room. Do NOT pass roomId as a third arg // to bot.say: under the hood it uses util.format which would append the // roomId string to the markdown body. The roomId param is retained in the // signature for future use (audit / cross-room routing) but is intentionally // not threaded into bot.say. export async function applyHostAssignConfirmation(bot, data, _roomId, requester) { logger( 'webexhost:audit', `CONFIRMED host assign for ${data.email} (license: ${data.licenseName}) ` + `by ${describeRequester(requester)}`, ); let response; try { response = await webex.assignLicensesToUser({ personId: data.personId, licenses: [{ id: data.licenseId, operation: 'add' }], }); } catch (err) { const msg = explainWebexAdminError(err); await bot.say( 'markdown', `❌ Failed to assign host license to **${data.displayName}**: ${msg}`, ); logger('webexhost:audit', `FAILED host assign for ${data.email}: ${msg}`, 'error'); return; } const grantedIds = new Set(response?.licenses || []); const pendingIds = new Set(response?.pendingLicenses || []); let line; let outcome; if (grantedIds.has(data.licenseId)) { line = `✅ **${data.displayName}** is now a host on \`${SITE_URL}\` ` + `(license: \`${data.licenseName}\`).`; outcome = 'granted'; } else if (pendingIds.has(data.licenseId)) { line = `⏳ License assignment is **pending acceptance** by ` + `**${data.displayName}** (external user). License: \`${data.licenseName}\`.`; outcome = 'pending'; } else { line = `⚠️ License \`${data.licenseName}\` did not appear in the response. ` + `Webex returned: \`${JSON.stringify(response)}\`. Verify in Control Hub.`; outcome = 'unconfirmed'; } await bot.say('markdown', line); logger( 'webexhost:audit', `COMPLETED host assign for ${data.email}: outcome=${outcome}, ` + `license=${data.licenseId}`, ); } export async function cancelHostAssignCard(bot, data, _roomId, requester) { await bot.say( 'markdown', `❌ Host license assignment cancelled for **${data.displayName}**. No changes were made.`, ); logger( 'webexhost:audit', `CANCELLED host assign for ${data.email} by ${describeRequester(requester)}`, ); } // ───────────────────────────────────────────────────────────────────────────── // Main entry — /webexhost // ───────────────────────────────────────────────────────────────────────────── export async function handleWebexHost(bot, trigger) { const args = trigger.args || []; const query = trigger.query || {}; const firstArg = (args[0] || query.action || query.email || query.user || '') .toString() .trim(); // /webexhost list (or licenses) — discovery subcommand if (firstArg.toLowerCase() === 'list' || firstArg.toLowerCase() === 'licenses') { return handleListLicenses(bot); } // /webexhost debug — dumps the raw Webex payload for diagnosing // detection mismatches (e.g. license shows in Control Hub but the bot says // "not a host"). Shows both the search-endpoint result and the full // GET /v1/people/{id} response, plus the site licenses + intersection. if (firstArg.toLowerCase() === 'debug') { const debugEmail = ((args[1] || query.email || query.user || '').toString().trim()).toLowerCase(); if (!debugEmail || !debugEmail.includes('@')) { await bot.say('markdown', '**Usage:** `/webexhost debug `'); return; } return handleDebug(bot, debugEmail); } const email = firstArg.toLowerCase(); if (!email || !email.includes('@')) { await bot.say( 'markdown', '**Usage:**\n' + '- `/webexhost ` — check host status on the configured site; offer to assign if missing.\n' + '- `/webexhost list` — list available Webex Meetings licenses on the site.\n' + '- `/webexhost debug ` — dump raw Webex payload for troubleshooting.', ); return; } const requester = extractRequester(trigger); logger( 'webexhost:audit', `REQUESTED host check for ${email} on ${SITE_URL} by ${describeRequester(requester)}`, ); let user; let siteLicenses; try { [user, siteLicenses] = await Promise.all([ webex.findPersonByEmail(email), getSiteLicenses(), ]); } catch (err) { const msg = explainWebexAdminError(err); await bot.say('markdown', `❌ Lookup failed for **${email}**: ${msg}`); logger('webexhost:audit', `FAILED host check for ${email}: ${msg}`, 'error'); return; } if (!user) { await bot.say('markdown', `❌ No Webex user found for **${email}**.`); return; } if (siteLicenses.length === 0) { await bot.say( 'markdown', `⚠️ No Webex Meetings licenses are configured on \`${SITE_URL}\`. ` + `Run \`/webexhost list\` to confirm — or verify \`WEBEX_HOST_SITE_URL\`.`, ); return; } // Try the People API first — it's a single cheap call. For some // tenants/scopes it actually returns `licenses`. If it does and any of // them match the site, we can short-circuit before paying for the slower // assignee scan. let fullPerson; try { fullPerson = await webex.getPerson(user.id); } catch (err) { const msg = explainWebexAdminError(err); await bot.say('markdown', `❌ Couldn't read user record for **${email}**: ${msg}`); return; } const personLicenseIds = Array.isArray(fullPerson.licenses) ? fullPerson.licenses : []; let ownedSiteLicenses = intersectLicenses(personLicenseIds, siteLicenses); let detectionPath = 'people-api'; let assigneeErrors = []; // Fall back to the authoritative reverse-lookup if the People API was // silent. Service-app tokens routinely return person.licenses=[] even for // users who do hold licenses — confirmed against Josh Babir via the debug // subcommand. The reverse lookup uses the assignment data Cisco actually // maintains. if (ownedSiteLicenses.length === 0) { detectionPath = 'assignee-scan'; try { const r = await findHeldSiteLicensesViaAssignees(user.id, siteLicenses); ownedSiteLicenses = r.held; assigneeErrors = r.errors; } catch (err) { const msg = explainWebexAdminError(err); await bot.say( 'markdown', `❌ Couldn't verify license assignment for **${email}**: ${msg}`, ); return; } } if (assigneeErrors.length > 0) { logger( 'webexhost:audit', `Assignee scan for ${email} had ${assigneeErrors.length} per-license error(s); ` + `first: ${assigneeErrors[0].license.name} → ${assigneeErrors[0].error}`, 'warn', ); } // Case A — already a host: report and stop. if (ownedSiteLicenses.length > 0) { const lines = ownedSiteLicenses.map((l) => `• \`${l.name}\``).join('\n'); await bot.say( 'markdown', `✅ **${user.displayName || email}** is **already a host** on \`${SITE_URL}\`.\n\n` + `**Current meeting license(s) on this site:**\n${lines}`, ); logger( 'webexhost:audit', `COMPLETED host check for ${email}: already-host (${ownedSiteLicenses.map((l) => l.id).join(',')}) ` + `via=${detectionPath}`, ); return; } // Case B — not a host, no WEBEX_HOST_LICENSE_ID configured: tell the operator // how to fix the config and skip the card. const configuredId = process.env.WEBEX_HOST_LICENSE_ID; if (!configuredId) { await bot.say( 'markdown', `⚠️ **${user.displayName || email}** is **not a host** on \`${SITE_URL}\`, ` + `but \`WEBEX_HOST_LICENSE_ID\` is not configured.\n\n` + `Run \`/webexhost list\` to see the available licenses, then set ` + `\`WEBEX_HOST_LICENSE_ID=\` in \`.env\` and reload.`, ); return; } // Case C — configured license ID doesn't match any license on this site. const targetLicense = siteLicenses.find((l) => l.id === configuredId); if (!targetLicense) { await bot.say( 'markdown', `❌ \`WEBEX_HOST_LICENSE_ID\` is set but doesn't match any license on \`${SITE_URL}\`. ` + `Run \`/webexhost list\` to find a valid id.`, ); return; } // Case D — configured license has no free seats. const free = seatsFreeFor(targetLicense); if (free <= 0) { await bot.say( 'markdown', `❌ Cannot assign \`${targetLicense.name}\` to **${user.displayName || email}** ` + `— the license has 0/${targetLicense.totalUnits} seats free. ` + `Pick a different license via \`WEBEX_HOST_LICENSE_ID\` or free a seat first.`, ); return; } // Case E — happy path: post the confirmation card. const cardId = `hostassign-${Date.now()}`; pendingHostAssigns.set(cardId, { email, personId: user.id, displayName: user.displayName || email, licenseId: targetLicense.id, licenseName: targetLicense.name, roomId: trigger.roomId || trigger.message?.roomId, requester, }); const adaptiveCard = { type: 'AdaptiveCard', version: '1.3', body: [ { type: 'TextBlock', text: '➕ ASSIGN WEBEX HOST LICENSE', weight: 'Bolder', size: 'Large', color: 'Accent', }, { type: 'ColumnSet', columns: [ { type: 'Column', width: 'auto', items: [{ type: 'Image', url: user.avatar || 'https://www.webex.com/content/dam/wbx/us/images/icon/avatar-placeholder.png', size: 'medium', style: 'person', }], }, { type: 'Column', width: 'stretch', items: [ { type: 'TextBlock', text: `**${user.displayName || email}**`, wrap: true }, { type: 'TextBlock', text: `Email: ${email}`, wrap: true, size: 'Small' }, user.title ? { type: 'TextBlock', text: `Title: ${user.title}`, wrap: true, size: 'Small' } : null, user.department ? { type: 'TextBlock', text: `Department: ${user.department}`, wrap: true, size: 'Small' } : null, ].filter(Boolean), }, ], }, { type: 'FactSet', spacing: 'Medium', facts: [ { title: 'Site', value: SITE_URL }, { title: 'Current host status', value: 'Not a host' }, { title: 'License to assign', value: targetLicense.name }, { title: 'Seats remaining', value: `${free} / ${targetLicense.totalUnits}` }, ], }, { type: 'TextBlock', text: `Confirming will PATCH \`/v1/licenses/users\` to grant ` + `**${user.displayName || email}** the \`${targetLicense.name}\` ` + `license, making them a host on \`${SITE_URL}\`.`, wrap: true, spacing: 'Medium', }, ], actions: [ { type: 'Action.Submit', title: '✅ Confirm Assign', data: { action: 'confirm_host_assign', cardId }, }, { type: 'Action.Submit', title: '❌ Cancel', data: { action: 'cancel_host_assign', cardId }, }, ], }; await bot.say({ markdown: `**${user.displayName || email}** is not a host on \`${SITE_URL}\`. Review and confirm assignment:`, attachments: [{ contentType: 'application/vnd.microsoft.card.adaptive', content: adaptiveCard, }], }); }