Post-review cleanup: reliability, docs, CI
Some checks failed
CI / Syntax check (push) Has been cancelled
CI / Docker build + healthcheck smoke test (push) Has been cancelled

Reliability / correctness:
- Always arm the graceful-shutdown safety timeout. Previously
  `shutdown(force=true)` (called from uncaughtException) skipped the
  timeout entirely, so a hung `framework.stop()` after a crash would
  wedge the process until Docker's SIGKILL. Now uses 3s when forced,
  8s otherwise, and .unref()s so it never blocks a clean exit.
- Attach a `.catch()` to `framework.start()` so a bad Webex token or
  WebSocket handshake failure produces a clear "Webex framework failed
  to start" error line instead of a bare Unhandled Rejection while the
  bot silently stays dead.
- Rename MDM timestamp labels from "(EDT)" to "(ET)" since the
  formatter uses DST-aware America/New_York (half the year it's EST).

Cleanup:
- Drop `body-parser` in favor of the built-in `express.json()`
  (Express 4.16+). Removes one direct dep; still present as a
  transitive dep of express itself.
- Remove orphaned JSDoc block referring to a helper that no longer
  exists.
- Delete legacy `query-offline.js` (marked deprecated since the bot
  `offline` command shipped) and remove its `APPSPACE_API_TOKEN` /
  `APPSPACE_BASE_URL` env vars from `.env.example` and the
  `offline:legacy` npm script from `package.json`.

Config / metadata:
- Add `"engines": { "node": ">=20" }` to package.json so npm warns on
  the wrong Node version instead of just the README saying so.
- Document `SMOKE_TEST=true` in `.env.example`.

Docs:
- Rewrite README to document the `restart-offline` command (iOS
  Supervised requirement, 50-device cap, concurrency, audit log
  fields, fresh-at-execute semantics), the ET-not-EDT labeling,
  structured error logging, character-budget rendering, and the
  new CI workflow. Refresh the TODO section to reflect what has
  actually shipped.

CI:
- Add `.gitea/workflows/ci.yml` with two jobs: syntax check
  (`node --check` on index.js and mdm.js) and a Docker smoke test
  that builds the production image, boots it with dummy credentials
  + SMOKE_TEST=true, and waits up to 30s for the container's
  built-in healthcheck to reach `healthy`. Dumps container logs
  on failure.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-01 17:56:11 -04:00
parent 025b70de56
commit f7cda8f0c1
7 changed files with 168 additions and 187 deletions

View file

@ -23,10 +23,6 @@ APPSPACE_REFRESH_TOKEN=your-long-lived-refresh-token
APPSPACE_API_BASE_URL=https://api.cloud.appspace.com # or your regional API base
APPSPACE_CONSOLE_BASE_URL=https://app3.cloud.appspace.com
# (Legacy / query-offline.js still references this static token style)
APPSPACE_API_TOKEN=your-static-token-if-needed
APPSPACE_BASE_URL=https://api.cloud.appspace.com
# ---- Workspace ONE MDM (for enrichment) ----
WS1_BASE_URL=https://as1991.awmdm.com # your WS1 server URL (used for API calls in mdm.js)
WS1_CONSOLE_BASE_URL=https://cn1896.awmdm.com # console base for per-device links (MUST be the console hostname like cn1896, NOT the API hostname like as1896 — links will be broken otherwise)
@ -38,3 +34,7 @@ WS1_TENANT_CODE=your-tenant-code
DEBUG=false # enables verbose per-request / per-lookup logs (mdm lookups, ignores, command receipts, etc.)
DEBUG_WEBHOOK=false # set to "true" to log *full* Appspace webhook JSON payloads (avoid in prod - may contain sensitive data)
LOG_FORMAT=json # set to "json" (or NODE_ENV=production) for structured JSON logs suitable for Docker log aggregation (Loki, CloudWatch, etc.)
# ---- CI / test-only ----
# SMOKE_TEST=true # when set, skips Webex bot framework startup so the container can boot to a healthy /health
# with dummy credentials. Used by `npm run docker:smoke` and the CI workflow. Do NOT set in prod.

83
.gitea/workflows/ci.yml Normal file
View file

@ -0,0 +1,83 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
syntax:
name: Syntax check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Node --check on source files
run: |
set -e
node --check index.js
node --check mdm.js
docker-smoke:
name: Docker build + healthcheck smoke test
runs-on: ubuntu-latest
needs: syntax
steps:
- uses: actions/checkout@v4
- name: Build production image
run: docker build -t appspace-webex:ci .
# Boot the container with dummy credentials + SMOKE_TEST=true so the Webex
# bot framework is skipped. The Dockerfile's built-in HEALTHCHECK hits /health;
# we poll `docker inspect` for the health status.
- name: Start container with dummy credentials
run: |
docker run -d --name smoke \
-e PORT=3000 \
-e NODE_ENV=production \
-e SMOKE_TEST=true \
-e WEBEX_BOT_TOKEN=smoke-test-bot-token \
-e WEBEX_ROOM_ID=smoke-test-room-id \
-e APPSPACE_INSTANCE_URL=https://smoke.example.com \
-e APPSPACE_SUBJECT_ID=smoke-subject \
-e APPSPACE_REFRESH_TOKEN=smoke-refresh-token \
-e APPSPACE_API_BASE_URL=https://smoke.example.com \
appspace-webex:ci
- name: Wait for container to become healthy
run: |
set -e
for i in $(seq 1 30); do
status=$(docker inspect --format='{{.State.Health.Status}}' smoke 2>/dev/null || echo none)
echo "attempt $i: status=$status"
if [ "$status" = "healthy" ]; then
echo "Container reached healthy"
exit 0
fi
sleep 1
done
echo "Container failed to reach healthy within 30s"
docker logs smoke
exit 1
- name: Container logs on success (for reference)
if: success()
run: docker logs smoke
- name: Container logs on failure
if: failure()
run: docker logs smoke || true
- name: Cleanup
if: always()
run: docker rm -f smoke || true

View file

@ -1,27 +1,25 @@
# Appspace Webex Alerts
Lightweight Node/Express service that bridges **Appspace** device health events to **Cisco Webex** via Adaptive Cards, with optional enrichment from **Workspace ONE MDM**.
Lightweight Node/Express service that bridges **Appspace** device health events to **Cisco Webex** via Adaptive Cards, with optional enrichment (and remediation) from **Workspace ONE MDM**.
It also runs an interactive Webex bot (WebSocket mode) for on-demand queries.
It also runs an interactive Webex bot (WebSocket mode) for on-demand queries and actions.
## What it does
- Listens for Appspace outbound webhooks (`DEVICE.HEALTHSTATUS.*` and `DEVICE.UNREGISTERED`).
- Ignores PWA devices.
- Enriches alerts with current MDM data (model, OS version, compliance, last sample/seen timestamps in EDT).
- Enriches alerts with current MDM data (model, OS version, compliance, last sample/seen timestamps in Eastern Time — DST-aware).
- Posts formatted Adaptive Cards to a configured Webex room (with direct links to both consoles).
- Provides a Webex bot with commands:
- `offline [optional filter]` — snapshot of currently offline/lost/failed devices (client-side filter on name or type).
- `help`
- Provides a Webex bot with commands (see below).
## Requirements
- Node 20+
- Node 20+ (enforced via `engines` in `package.json`)
- Appspace instance with outbound webhook + Application refresh token
- Webex bot token + room ID
- (Optional but recommended) Workspace ONE MDM OAuth client for enrichment
- (Optional but recommended) Workspace ONE MDM OAuth client for enrichment and reboot
## Quick Start (Docker - recommended)
## Quick Start (Docker recommended)
1. Copy env:
```bash
@ -45,7 +43,7 @@ It also runs an interactive Webex bot (WebSocket mode) for on-demand queries.
**Port mapping notes**: The container always listens internally on port 3000 (hardened default). Host port 1889 is used for both dev (`docker compose --profile dev up -d app-dev`) and prod (`docker compose up -d`). The `PORT` env inside the container is forced to 3000 via compose. You cannot run both profiles at the same time due to the shared host port.
Direct:
Direct (no Docker):
```bash
npm install
npm start
@ -53,6 +51,28 @@ npm start
Health check: `GET /health`
## Bot Commands
In the Webex space where the bot is added — either as a DM to the bot, or `@mention` in a group space:
| Command | What it does |
| --- | --- |
| `offline` | Snapshot of all currently offline / lost / failed Appspace devices, grouped by location, enriched with per-device MDM facts and clickable Appspace + Workspace ONE console links. |
| `offline <filter>` | Same, narrowed to devices whose `deviceType` or name contains the filter substring. E.g. `offline ios`, `offline lobby`. |
| `restart-offline` | Sends a Workspace ONE **SoftReset** (reboot) to every currently-offline device that maps to a WS1 record by serial. Capped at **50** devices per invocation. Skipped devices (no WS1 record) are reported separately. |
| `restart-offline <filter>` | Same, narrowed to devices matching the filter. Use this to get under the 50-device cap for large fleets (e.g. `restart-offline ios`). |
| `help` | Print the command list. |
Notes on `restart-offline`:
- **Fresh at execute time.** The command re-queries Appspace at the moment of execution, so devices that came back online after you last looked are automatically excluded.
- **No confirmation prompt.** Configured for immediate execution per project preference — narrow with a filter if you want to limit scope.
- **iOS Supervised requirement.** WS1's SoftReset only actually reboots iOS devices that are Supervised (DEP-enrolled). Non-Supervised devices will surface a clean WS1 error in the failure list; they will not silently appear to succeed.
- **Rate-limited.** Reboots are sent with concurrency = 3 to avoid hammering the WS1 API.
- **Auditable.** Every invocation emits a JSON log line with `user`, `filter`, `matched`, `withMdm`, `withoutMdm`, `succeeded`, and `failed` counts.
The bot runs in WebSocket mode (no public webhook required).
## Environment Variables
See `.env.example` for the full documented list.
@ -60,36 +80,28 @@ See `.env.example` for the full documented list.
Key ones:
- `WEBEX_BOT_TOKEN`, `WEBEX_ROOM_ID`
- `APPSPACE_INSTANCE_URL`, `APPSPACE_SUBJECT_ID`, `APPSPACE_REFRESH_TOKEN`, `APPSPACE_API_BASE_URL`
- `WS1_*` (for MDM enrichment)
- `WEBHOOK_SECRET` (recommended for the Appspace webhook)
- `DEBUG`, `DEBUG_WEBHOOK` (see below)
## Bot Commands
In the Webex space where the bot is added:
- `offline` — current problematic devices
- `offline tablet` — filter to devices whose name or type contains "tablet"
- `help`
The bot runs in WebSocket mode (no public webhook required).
- `APPSPACE_CONSOLE_BASE_URL` (used to build per-device Appspace links in Webex cards)
- `WS1_BASE_URL` (API hostname, e.g. `as1991.awmdm.com`)
- `WS1_CONSOLE_BASE_URL` (console hostname, e.g. `cn1896.awmdm.com` — different from the API host; used for clickable console links)
- `WS1_CLIENT_ID`, `WS1_CLIENT_SECRET`, `WS1_TENANT_CODE`
- `WEBHOOK_SECRET` (recommended for the Appspace webhook; validated via `x-webhook-secret` header)
- `DEBUG`, `DEBUG_WEBHOOK`, `LOG_FORMAT` (see Debugging)
## Debugging
- `DEBUG=true` — verbose logging for MDM lookups, ignored events, command handling, etc. (very useful in dev, noisy in prod).
- `DEBUG_WEBHOOK=true` — log the **full** incoming Appspace webhook payload (contains device details; do **not** leave on in production).
- `LOG_FORMAT=json` (or `NODE_ENV=production`) — output structured JSON logs (ideal for Docker/K8s log collectors).
You can also set `NODE_ENV=development` for similar verbose behavior.
- `SMOKE_TEST=true` — skips Webex bot framework startup so the container can reach healthy status with dummy credentials. Used only by the smoke test and CI; do not set in prod.
## Production & Docker Notes
- **Graceful shutdown**: The service handles `SIGTERM` (used by `docker stop`, Kubernetes, etc.) and `SIGINT`. It will:
1. Stop the Webex WebSocket framework (important to avoid "excessive device registrations").
2. Close the HTTP server.
3. Exit cleanly. A hard timeout forces exit after ~8s.
3. Exit cleanly. A hard safety timeout forces exit after ~8s (or ~3s from a crash handler).
- **Healthcheck**: `/health` returns 200 with basic status. Used by Docker and orchestrators.
- **Logging**: Logs go to stdout/stderr (12-factor / Docker friendly). Use `LOG_FORMAT=json` or `NODE_ENV=production` for structured JSON. Use `DEBUG=true` in non-prod for detail. Pipe to a collector (Loki, CloudWatch, etc.) as needed.
- **Logging**: Logs go to stdout/stderr (12-factor / Docker friendly). Use `LOG_FORMAT=json` or `NODE_ENV=production` for structured JSON. Use `DEBUG=true` in non-prod for detail. Pipe to a collector (Loki, CloudWatch, etc.) as needed. Errors (including axios failures) are serialized with `message`, `stack`, `code`, `responseStatus`, and `responseData` so failures are actually visible in logs.
- **Secrets**: Never bake secrets into the image. Use:
- `env_file` for compose (dev/staging only)
- Docker secrets, Kubernetes Secrets, or a secrets manager (Vault, AWS Secrets Manager) for production.
@ -99,10 +111,18 @@ You can also set `NODE_ENV=development` for similar verbose behavior.
## Architecture Notes
- Appspace token uses refresh token + cooldown + safety buffer.
- MDM uses 24h serial→ID cache + fresh detail lookup by ID on every alert (for up-to-date compliance/last-seen).
- WebSocket mode for the bot avoids restart rate limits.
- Appspace token uses refresh-token flow with cooldown, safety buffer, and in-flight promise coalescing so burst traffic doesn't stampede the token endpoint.
- MDM uses a 24h serial→Id cache + fresh detail lookup by Id on every alert (for up-to-date compliance/last-seen). Cache refresh and OAuth token fetch are also coalesced.
- WebSocket mode for the bot avoids the WebSocket-restart rate limits associated with the webhook mode.
- All enrichment is best-effort; alerts are never blocked by MDM or token issues.
- Webex message rendering respects the 7439-character pre-encryption limit by building bodies incrementally against a character budget, with accurate "N more not shown" truncation notes.
- Bot command matching uses string phrases so the framework's `(^| )phrase($| )` wrapper handles group-space `@mentions` correctly. Filter parsing works identically in DMs and mentioned messages via a shared helper.
## CI
`.gitea/workflows/ci.yml` builds the Docker image on every push to `main` and every pull request, then boots the container with dummy credentials (`SMOKE_TEST=true`) and verifies the built-in healthcheck reaches `healthy`. Fails the run and dumps container logs if it doesn't.
You can reproduce the same check locally with `npm run docker:smoke`.
## License / Support
@ -111,6 +131,7 @@ Internal tool. Tweak as needed.
## TODO / Future
- Server-side filtering for the devices list when the Appspace API supports it reliably.
- Metrics / full structured JSON logging.
- Support for more Appspace event types.
- Multi-stage Dockerfile for even smaller prod images.
- Optional metrics endpoint (`/metrics` in Prometheus format).
- Support for more Appspace event types (e.g. content push failures, if useful).
- Multi-stage Dockerfile for even smaller prod images (current image is already Alpine + prod-only npm deps).
- Optional email allowlist for `restart-offline` (env-driven), if the current "any user in the bot's space can invoke it" policy becomes too permissive.

View file

@ -1,6 +1,5 @@
require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser');
const axios = require('axios');
const Framework = require('webex-node-bot-framework');
@ -117,13 +116,14 @@ function shutdown(force = false) {
exit(0);
}
// Force exit after a timeout (Docker stop timeout is usually 10s)
if (!force) {
// ALWAYS arm a safety timeout. If any step above hangs (e.g. framework.stop()
// never resolves), we still need to exit before Docker's 10s SIGKILL. When called
// from a crash handler (`force=true`) the process is already unhealthy, so exit
// faster to minimize the window where we're wedged.
setTimeout(() => {
logger.error('⏱️ Graceful shutdown timed out. Forcing exit.');
exit(1);
}, 8000);
}
}, force ? 3000 : 8000).unref();
}
// Basic crash handlers for container environments (Docker/K8s will restart on non-zero exit)
@ -144,7 +144,7 @@ process.on('unhandledRejection', (reason) => {
// Do not call shutdown - keep the HTTP server running (e.g. Webex bot errors shouldn't kill webhook path)
});
app.use(bodyParser.json());
app.use(express.json());
// ======================
// APPSPACE TOKEN MANAGEMENT (with cooldown + longer timeout)
@ -227,7 +227,9 @@ async function getValidAccessToken() {
// ======================
/**
* Normalize and format MDM timestamps (LastSystemSampleTime, LastSeen, etc.)
* Handles missing 'Z' suffix from some WS1/Appspace responses and formats in EDT.
* Handles missing 'Z' suffix from some WS1/Appspace responses and formats in
* Eastern Time (America/New_York, DST-aware so it renders as EST or EDT
* automatically depending on the date).
*/
function formatMdmTimestamp(ts) {
if (!ts) return 'Unknown';
@ -282,8 +284,8 @@ function buildMdmFactsAndLink(mdmDevice) {
if (model && model !== 'Unknown') mdmFacts.push({ "title": "Model", "value": model });
if (os && os !== 'Unknown') mdmFacts.push({ "title": "OS Version", "value": os });
if (compliance && compliance !== 'Unknown') mdmFacts.push({ "title": "Compliance", "value": compliance });
if (lastSampleTime && lastSampleTime !== 'Unknown') mdmFacts.push({ "title": "Last Sample (EDT)", "value": lastSampleTime });
if (lastSeenDisplay && lastSeenDisplay !== 'Unknown') mdmFacts.push({ "title": "Last Seen (EDT)", "value": lastSeenDisplay });
if (lastSampleTime && lastSampleTime !== 'Unknown') mdmFacts.push({ "title": "Last Sample (ET)", "value": lastSampleTime });
if (lastSeenDisplay && lastSeenDisplay !== 'Unknown') mdmFacts.push({ "title": "Last Seen (ET)", "value": lastSeenDisplay });
return { mdmFacts, mdmConsoleLink };
}
@ -369,10 +371,6 @@ function isProblemDevice(device) {
return ['OFFLINE', 'LOSTCOMMUNICATION', 'FAILED'].includes(status);
}
/**
* Build a clean Markdown table for offline device list.
* Much more reliable than fixed-width ASCII in Webex (and other clients).
*/
// ======================
// HEALTHCHECK
// ======================
@ -518,7 +516,13 @@ if (process.env.SMOKE_TEST !== 'true') {
token: process.env.WEBEX_BOT_TOKEN,
});
framework.start();
// If the framework fails to start (bad token, WebSocket handshake failure,
// Webex-side outage) the rejection would otherwise be swallowed by the global
// unhandledRejection handler and the bot would silently stay dead while
// Express keeps serving. Surface it explicitly so the failure is obvious.
framework.start().catch((err) => {
logger.error('Webex framework failed to start (bot will be unresponsive; HTTP server continues)', err);
});
framework.on('initialized', () => {
logger.info('Webex Bot Framework initialized (WebSocket mode)');

4
package-lock.json generated
View file

@ -9,13 +9,15 @@
"version": "1.0.0",
"dependencies": {
"axios": "^1.7.2",
"body-parser": "^1.20.2",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"webex-node-bot-framework": "^2.5.1"
},
"devDependencies": {
"nodemon": "^3.1.14"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@babel/code-frame": {

View file

@ -2,17 +2,18 @@
"name": "appspace-webex-alerts",
"version": "1.0.0",
"main": "index.js",
"engines": {
"node": ">=20"
},
"scripts": {
"start": "node index.js",
"dev": "node index.js",
"offline:legacy": "node query-offline.js",
"docker:dev": "docker compose --profile dev up app-dev",
"docker:prod": "docker compose up -d",
"docker:smoke": "echo '🚀 Running Docker smoke test (build + health check)...' && docker compose --profile smoke build smoke-test && docker compose --profile smoke up -d smoke-test && (for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30; do status=$(docker inspect --format='{{.State.Health.Status}}' appspace-smoke-test 2>/dev/null || echo 'none'); if [ \"$status\" = 'healthy' ]; then echo '✅ Smoke test PASSED: container healthcheck is healthy'; success=1; break; fi; sleep 1; done; if [ -z \"$success\" ]; then echo \"❌ Smoke test FAILED: final health status was $status\"; docker compose --profile smoke logs --tail=100 smoke-test; false; fi) ; docker compose --profile smoke down --remove-orphans smoke-test 2>/dev/null || true"
},
"dependencies": {
"axios": "^1.7.2",
"body-parser": "^1.20.2",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"webex-node-bot-framework": "^2.5.1"

View file

@ -1,130 +0,0 @@
/**
* DEPRECATED / LEGACY
*
* This standalone script is no longer the recommended way to query offline devices.
*
* Use the Webex bot command instead:
* In your Webex space: type `offline` (or `offline some-filter`)
*
* The main server (index.js) now provides a much better implementation:
* - Uses the Appspace refresh token flow (not static APPSPACE_API_TOKEN)
* - Shares token management with the webhook path
* - Has filtering, better table output, and MDM enrichment in other flows
*
* This file is kept only for emergency/audit use. It uses older env var conventions
* (APPSPACE_BASE_URL + APPSPACE_API_TOKEN) and performs a one-shot query then exits.
*
* Consider removing this file once the bot command has been validated in production.
*/
require('dotenv').config();
const axios = require('axios');
const WEBEX_BOT_TOKEN = process.env.WEBEX_BOT_TOKEN;
const WEBEX_ROOM_ID = process.env.WEBEX_ROOM_ID;
const APPSPACE_API_TOKEN = process.env.APPSPACE_API_TOKEN; // Legacy static token
const APPSPACE_API_BASE_URL = process.env.APPSPACE_BASE_URL || 'https://api.cloud.appspace.com';
if (!WEBEX_BOT_TOKEN || !WEBEX_ROOM_ID || !APPSPACE_API_TOKEN) {
console.error('❌ Missing required env vars: WEBEX_BOT_TOKEN, WEBEX_ROOM_ID, APPSPACE_API_TOKEN');
process.exit(1);
}
console.warn('⚠️ Running DEPRECATED query-offline.js script. Prefer the "offline" command via the Webex bot.');
async function queryOfflineDevices() {
try {
console.log('🔍 Querying Appspace for offline / lost devices...');
// Example API call - adjust endpoint based on your exact API docs
// Common pattern: GET /devices with filters for health status
const response = await axios.get(`${APPSPACE_API_BASE_URL}/api/v3/devices`, {
headers: {
Authorization: `Bearer ${APPSPACE_API_TOKEN}`,
'Content-Type': 'application/json'
},
params: {
// Filter examples (test and refine in Postman first)
healthStatus: 'LostCommunication,Offline,Failed', // or use separate calls if needed
limit: 200,
// locationId: 'optional-filter',
// include: 'location,group'
}
});
const devices = response.data.items || response.data; // adjust based on actual response shape
const offlineDevices = devices.filter(d =>
['LostCommunication', 'Offline', 'Failed'].includes(d.healthStatus || d.status)
);
if (offlineDevices.length === 0) {
await sendToWebex('✅ **All devices are currently online or in sync.** No offline devices detected.');
console.log('✅ No offline devices');
return;
}
// Build a nice Adaptive Card summary
const facts = offlineDevices.map(d => ({
title: d.name || d.deviceName || 'Unknown',
value: `${d.healthStatus || d.status}${d.locationName || 'No location'} • IP: ${d.ipAddress || 'N/A'}`
}));
const adaptiveCard = {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.3",
"body": [
{
"type": "Container",
"style": "attention",
"bleed": true,
"items": [
{ "type": "TextBlock", "text": `📊 Offline Devices Snapshot (${offlineDevices.length})`, "weight": "bolder", "size": "medium" }
]
},
{
"type": "FactSet",
"facts": facts
}
],
"actions": [
{
"type": "Action.OpenUrl",
"title": "🔗 Open Devices in Appspace Console",
"url": "https://app3.cloud.appspace.com/console/#!/devices"
}
]
};
await sendToWebex(null, adaptiveCard);
console.log(`✅ Sent ${offlineDevices.length} offline devices to Webex`);
} catch (err) {
console.error('❌ Error querying offline devices:', err.response?.data || err.message);
await sendToWebex('⚠️ Failed to query offline devices. Check API token and console.');
}
}
async function sendToWebex(text, card = null) {
const payload = {
roomId: WEBEX_ROOM_ID,
text: text || 'Appspace Offline Devices Report'
};
if (card) {
payload.attachments = [{
contentType: "application/vnd.microsoft.card.adaptive",
content: card
}];
}
await axios.post('https://webexapis.com/v1/messages', payload, {
headers: {
Authorization: `Bearer ${WEBEX_BOT_TOKEN}`,
'Content-Type': 'application/json'
}
});
}
queryOfflineDevices();