Some checks failed
CI / verify (push) Has been cancelled
Weekday 10am Eastern cron exports all Webex Calling locations whose name starts with "Store" to a legacy-format CSV (+E164,Store XXXX), diffs against the previous baseline, and posts the file plus a change summary to a configured Webex space. - src/webex/storePhones.js: paginated GET /telephony/config/locations - src/services/storePhoneExport.js: CSV format, filename, diff, message - src/webex/messages.js: bot-token multipart POST /messages with file - src/jobs/storePhoneExport.js + cron in src/index.js - scripts/exportStorePhones.js for manual runs (npm run export-store-phones) - Persist baseline + archives under data/store-phone-export/ (gitignored) - Dockerfile: create /app/data for volume mount in prod - CI: align node-version with Dockerfile (20) Co-authored-by: Cursor <cursoragent@cursor.com>
278 lines
12 KiB
Markdown
278 lines
12 KiB
Markdown
# wbxStoreProvision
|
|
|
|
Webex Calling provisioning bot for AEO retail stores. Runs as a Webex bot and
|
|
exposes slash commands that create Webex locations, attach phone numbers, upload
|
|
greetings, build auto-attendants, and clean up user licensing.
|
|
|
|
## Requirements
|
|
|
|
- Node.js 20+ (Docker image uses `node:20-alpine`; Node 22 breaks
|
|
`webex-node-bot-framework` because its `navigator` global is read-only).
|
|
- A Webex bot token, a Webex integration (service account) with the scopes
|
|
currently used by admin API calls, a Twilio lookup account, an SIW basic-auth
|
|
user, and a Google API key with Address Validation + Time Zone enabled.
|
|
- An on-prem host that can reach Store Info Web, Google APIs, and Twilio
|
|
Lookup from an IP-whitelisted subnet, to run the remote agent (see
|
|
[Remote agent (SIW + Google)](#remote-agent-siw--google) below).
|
|
|
|
## Local setup
|
|
|
|
```bash
|
|
cp .env.example .env
|
|
# Edit .env with real values
|
|
npm install
|
|
npm start
|
|
```
|
|
|
|
The token-refresh cron reads and writes `config/wbxTokens.json` (path
|
|
configurable via `WEBEX_TOKEN_STORE`). Seed this file once with a valid
|
|
`access_token`, `refresh_token`, and `expiresOn`; the process will keep it up to
|
|
date from then on.
|
|
|
|
## Scripts
|
|
|
|
| npm script | What it does |
|
|
| -------------------------- | ----------------------------------------------------------- |
|
|
| `npm start` | Run the bot. |
|
|
| `npm run dev` | Run with `node --watch` for local iteration. |
|
|
| `npm run lint` | ESLint (flat config) across the tree. |
|
|
| `npm run format` | Prettier write. |
|
|
| `npm run generate-911-csv` | Rebuild `buildingFile.csv` + `locationFile.csv` from SIW. |
|
|
| `npm run fix-store-phones` | Normalize licensing + default meeting site for store users. |
|
|
|
|
## Docker
|
|
|
|
### Docker Compose (recommended)
|
|
|
|
```bash
|
|
cp .env.example .env
|
|
# Edit .env with real values and seed config/wbxTokens.json (see above)
|
|
|
|
docker compose up -d --build
|
|
docker compose logs -f
|
|
```
|
|
|
|
`docker-compose.yml` builds the image, loads `.env`, mounts `./config` for
|
|
token persistence, and publishes `WS_PORT` (default `8080`) so the remote SIW
|
|
agent can connect back to the bot.
|
|
|
|
### Plain Docker
|
|
|
|
```bash
|
|
docker build -t wbxcallprov .
|
|
docker run --rm --env-file .env \
|
|
-p 8080:8080 \
|
|
-v "$(pwd)/config:/app/config" \
|
|
wbxcallprov
|
|
```
|
|
|
|
The volume mount preserves `config/wbxTokens.json` across restarts so the
|
|
refresh cron doesn't lose state. The `-p 8080:8080` publishes the WebSocket
|
|
port so the remote SIW agent can connect back to the bot.
|
|
|
|
## Remote agent (SIW + Google)
|
|
|
|
Some services can only be reached from inside the corporate network:
|
|
|
|
- **Store Info Web** — only accepts connections from internal IPs, and
|
|
presents an internal-CA TLS cert Node's default trust store doesn't know
|
|
about.
|
|
- **Google Maps / Address Validation** — the API key is IP-restricted, so
|
|
requests direct from the cloud bot IP get `API_KEY_IP_ADDRESS_BLOCKED
|
|
(403)`.
|
|
|
|
To bridge the gap, the bot hosts a WebSocket server; a small on-prem agent
|
|
dials in and proxies HTTP requests back and forth. Both SIW and Google
|
|
calls go over this bridge so they originate from the on-prem IP.
|
|
|
|
The agent itself is intentionally generic — it just proxies whatever
|
|
`{method, url, headers, auth, body, insecure?}` payload arrives — and
|
|
lives in [`docker/remote-agent/`](docker/remote-agent) as a self-contained
|
|
Docker bundle you can build here and ship to the on-prem host. The
|
|
`insecure: true` flag is scoped per request. Both SIW and Google set it,
|
|
because the corporate network the agent lives on runs SSL-inspecting
|
|
proxies that intercept outbound HTTPS with an internal-CA chain — without
|
|
the flag Node throws `SELF_SIGNED_CERT_IN_CHAIN` even for Google's public
|
|
certs. Trust is delegated to that proxy by policy, so the scoped bypass is
|
|
consistent across all proxied traffic.
|
|
|
|
Once the agent is connected, the bot logs `Remote agent connected` and
|
|
`/stageStore` and `/finalizeStore` will succeed. If the agent is not
|
|
connected, those commands fail immediately with a preflight message
|
|
rather than silently timing out mid-flow.
|
|
|
|
### Deploying the agent
|
|
|
|
The `docker/remote-agent/` folder produces a fully offline-installable ZIP
|
|
(image tarball + `install.sh` + `docker-compose.yml`). The workflow is the
|
|
same as netanalyzer's bundle — same layout, same operator playbook — and
|
|
the two bundles use distinct image tags and container names
|
|
(`wbxprov-remote-agent` vs `sha-remote-agent`) so a single on-prem host
|
|
can run both agents side by side.
|
|
|
|
Build the bundle (on your workstation, or in CI):
|
|
|
|
```bash
|
|
# Default: linux/amd64 (typical Rocky/RHEL/Ubuntu server)
|
|
npm run agent:package
|
|
|
|
# Or explicitly, with a different target arch:
|
|
./docker/remote-agent/package.sh --platform linux/arm64
|
|
```
|
|
|
|
That produces `docker/remote-agent/dist/wbxprov-remote-agent-<version>.zip`.
|
|
Transfer it to the on-prem host, then:
|
|
|
|
```bash
|
|
unzip wbxprov-remote-agent-<version>.zip
|
|
cd wbxprov-remote-agent-<version>
|
|
./install.sh # first run: seeds .env and stops
|
|
vi .env # set WS_URL + WS_TOKEN
|
|
./install.sh # second run: starts the container
|
|
docker compose logs -f # tail the agent's connection status
|
|
```
|
|
|
|
Set `WS_URL` to the bot's public WebSocket endpoint (e.g.
|
|
`wss://your-wbxstoreprovision-host:8080`) and `WS_TOKEN` to the same value
|
|
you configured for `WS_TOKEN` in the bot's `.env`. See
|
|
[`docker/remote-agent/README.md`](docker/remote-agent/README.md) for build
|
|
details and [`docker/remote-agent/deploy/README.md`](docker/remote-agent/deploy/README.md)
|
|
for the full operator guide (upgrades, troubleshooting, coexistence with
|
|
`sha-remote-agent`).
|
|
|
|
## Bot commands
|
|
|
|
Registered in [src/commands](src/commands):
|
|
|
|
- `/stageStore <storeNumber>` — creates the Webex location, enables calling,
|
|
configures the schedule, greeting, music-on-hold, voicemail, voice portal,
|
|
and attaches the Webex Calling license to the store user. Everything
|
|
except the phone number, which has to be purchased separately in Control Hub.
|
|
- `/finalizeStore <storeNumber>` — cut-over for a previously staged store:
|
|
verifies the store's phone number is already assigned to the location
|
|
(it must be manually ordered in Control Hub first — this step never
|
|
triggers a new PSTN order), sets caller ID, creates the `Store XXXX`
|
|
DECT network (DBS-210) with the per-store default access code, creates
|
|
the auto-attendant, and finalizes user licensing. Preflights that the
|
|
store was actually staged first, and is idempotent on the DECT network
|
|
(skips if one already exists). On phone-number mismatches (pending
|
|
order, wrong location, or number missing from the org) finalize aborts
|
|
early with a specific Control-Hub fix-it instruction.
|
|
- `/provisionDect <storeNumber>` — manage DECT phones for a store: card
|
|
for adding basestations by MAC and adding/removing handsets. Every
|
|
removal goes through an explicit confirmation card. New stores have
|
|
their DECT network created by `/finalizeStore`; this command still
|
|
offers a recovery "create network" prompt for legacy stores or if
|
|
finalize's DECT step was skipped. All Webex API — no remote agent
|
|
required.
|
|
- `/provisionPhone <storeNumber>` — manage wired desk phones (Cisco 7841
|
|
/ 7821) owned by the store user (resolved via `ae<5-digit>@ae.com`).
|
|
The card shows the store user's current wired phones (DECT handsets
|
|
are filtered out — those live in `/provisionDect`), with an add form
|
|
(MAC + model) and a checkbox list for removal. Adds are idempotent
|
|
(MACs already registered are skipped), and every removal goes through
|
|
an explicit confirmation card. All Webex API — no remote agent
|
|
required.
|
|
- `/storeinfo <storeNumber>` — show current Webex info for the store user
|
|
(`ae<5-digit>@ae.com`).
|
|
- `/userinfo <email>` — show current Webex info for any user by email.
|
|
- `/help` — bot's own help output.
|
|
|
|
Typical provisioning workflow: `/stageStore 499` → purchase a phone number
|
|
in Control Hub → `/finalizeStore 499`.
|
|
|
|
Card confirmations post `attachmentAction` events, dispatched in
|
|
[src/commands/attachmentActions.js](src/commands/attachmentActions.js).
|
|
|
|
## Scheduled jobs
|
|
|
|
The bot runs two background tasks via `node-cron` in [src/index.js](src/index.js):
|
|
|
|
- **Token refresh** — every 60 seconds, refreshes the Webex service-account
|
|
access token when it is within 2 hours of expiry.
|
|
- **Store phone export** — weekdays at 10:00 AM `America/New_York` (configurable
|
|
via `STORE_PHONE_EXPORT_TIMEZONE`). Exports all Webex Calling locations whose
|
|
name starts with `Store` to a CSV (`+E164,Store XXXX` per line), diffs against
|
|
the previous run, and posts the file plus a change summary to a Webex space.
|
|
|
|
Configure the export in `.env`:
|
|
|
|
- `STORE_PHONE_EXPORT_ROOM_ID` — target space ID (required for the job to run).
|
|
The bot must be a member of this space.
|
|
- `STORE_PHONE_EXPORT_TIMEZONE` — cron timezone (default `America/New_York`).
|
|
- `STORE_PHONE_EXPORT_DATA_DIR` — where baseline + archive CSVs are stored
|
|
(default `./data/store-phone-export`). Mount `/app/data` as a volume in Docker
|
|
so diffs survive restarts.
|
|
|
|
Manual trigger (same code path as the cron):
|
|
|
|
```bash
|
|
npm run export-store-phones
|
|
# or: node scripts/exportStorePhones.js
|
|
```
|
|
|
|
## Architecture
|
|
|
|
```
|
|
src/
|
|
index.js bot bootstrap, cron, command wiring (~70 lines)
|
|
config.js dotenv loading + validation
|
|
constants.js org-scoped IDs (route groups, licenses, greetings, ...)
|
|
logger.js small level-aware logger
|
|
http.js fetch wrapper (rate limit + optional SIW TLS agent)
|
|
webex/ all Webex API calls, one module per resource family
|
|
integrations/ SIW / Twilio / Google
|
|
cards/ Adaptive Card builders
|
|
flows/ multi-step provisioning (stage, finalize)
|
|
commands/ framework.hears handlers + attachmentAction dispatch
|
|
jobs/ scheduled background tasks (store phone export)
|
|
scripts/ one-off maintenance scripts (911 CSV, phone fix-up, export)
|
|
greetings/ WAV files uploaded as location announcements
|
|
config/wbxTokens.json persistent service-account token cache (git-ignored)
|
|
```
|
|
|
|
Data flow for a store provisioning:
|
|
|
|
```mermaid
|
|
flowchart LR
|
|
User[Webex user] -->|/stageStore 1234| Commands[commands/*]
|
|
Commands --> SIW[integrations/siw.js]
|
|
Commands --> Users[webex/users.js]
|
|
Commands --> Card[cards/storeConfirmationCard.js]
|
|
Card -->|confirm| Attachment[attachmentActions.js]
|
|
Attachment --> Flow[flows/stageStore.js]
|
|
Flow --> Locations[webex/locations.js]
|
|
Flow --> Devices[webex/devices.js]
|
|
Flow --> Announce[webex/announcements.js]
|
|
Flow --> License[webex/licensing.js]
|
|
subgraph background
|
|
Cron[node-cron every 60s] --> Auth[webex/auth.js]
|
|
Auth --> Tokens[(config/wbxTokens.json)]
|
|
end
|
|
```
|
|
|
|
## Security notes
|
|
|
|
- `.env` and `config/wbxTokens.json` are git-ignored. Never commit them.
|
|
- `NODE_TLS_REJECT_UNAUTHORIZED=0` is no longer set globally. If SIW's TLS
|
|
cert cannot be verified from your host, set `ALLOW_INSECURE_SIW_TLS=true`;
|
|
the insecure dispatcher is then scoped to SIW requests only.
|
|
- Concurrent Webex token refreshes are collapsed into a single in-flight
|
|
refresh in [src/webex/auth.js](src/webex/auth.js).
|
|
|
|
### One-time secret rotation
|
|
|
|
The pre-refactor `config.json` and `getMeeting.js` stored real secrets in
|
|
plain text on disk. Regardless of git history, treat the following as
|
|
**exposed** and rotate them at their source:
|
|
|
|
- Webex bot token (`WEBEX_BOT_TOKEN`)
|
|
- Webex integration client secret + service-account access / refresh tokens
|
|
(`WEBEX_SVC_CLIENT_SECRET`, plus everything in `config/wbxTokens.json`)
|
|
- Twilio auth token (`TWILIO_AUTH_TOKEN`)
|
|
- SIW basic-auth password (`SIW_PASSWORD`)
|
|
- Google API key (`GOOGLE_API_KEY`) and any service-account private key that
|
|
was previously in `config.json`
|
|
|
|
After rotating, populate the new values in `.env` and seed a fresh
|
|
`config/wbxTokens.json` with the new access/refresh token pair.
|