collabSupport/commands/webexHost.js
jmcqueen 351f89a9a4 Initial commit: CollabFinder Webex bot
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).
2026-07-01 16:55:03 -04:00

632 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/commands/webexHost.js
//
// /webexhost <email> — 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<string>, 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<personId> for the given license. Walks every page on first
// call (1N 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=<id>\` in \`.env\` to enable ` +
`\`/webexhost <email>\` auto-assignment._`,
);
}
await bot.say('markdown', lines.join('\n'));
}
// ─────────────────────────────────────────────────────────────────────────────
// /webexhost debug <email> — 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 <email> — 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 <email>`');
return;
}
return handleDebug(bot, debugEmail);
}
const email = firstArg.toLowerCase();
if (!email || !email.includes('@')) {
await bot.say(
'markdown',
'**Usage:**\n' +
'- `/webexhost <email>` — 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 <email>` — 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=<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,
}],
});
}