Modernize bot to Node.js 22 with modular architecture and remote SIW agent

- Refactor monolithic index.js (2646 lines) into src/{webex,integrations,
  cards,flows,commands,services} modules; replace node-fetch/form-data
  with native fetch/FormData; move all secrets to .env via dotenv
- Add dockerized remote SIW agent (docker/remote-agent/) with cross-arch
  buildx packaging (arm64 Mac -> linux/amd64), idempotent install.sh
  deploy bundle, and docker-free ZIP inspector for arch verification
- Bot hosts a WebSocket server; agent proxies SIW requests with a
  per-request insecure:true flag, replacing the process-wide
  NODE_TLS_REJECT_UNAUTHORIZED bypass
- Add ESLint flat config + Prettier, rewrite Dockerfile as non-root
  multi-stage node:22-alpine build, README covering setup / deploy /
  remote agent workflow
- Fix parseStoreArg to read trigger.prompt correctly (was indexing past
  the framework's post-match slice); register /help as regex (string
  matcher only compares the first token); switch catch-all to /.+/
  (previous /.*/gim was stateful due to the g flag); remove
  /fixDisplayNames command and its flow/card

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-06 14:48:51 -04:00
commit 93b060bc8b
59 changed files with 15368 additions and 0 deletions

44
.dockerignore Normal file
View file

@ -0,0 +1,44 @@
.git
.gitignore
.dockerignore
# Dependencies (installed inside the image)
node_modules
npm-debug.log*
# Secrets and local runtime state
.env
.env.*
config.json
config/wbxTokens.json
config/google-service-account.json
config/*-service-account.json
googleData.json
*.pem
*.key
# Legacy top-level scripts (deleted after refactor but ignored defensively)
get911.js
getMeeting.js
phoneFix.js
storeAddress.js
# Local scripts/tooling not needed at runtime
scripts/
eslint.config.js
.prettierrc.json
.prettierignore
# Remote-agent packaging (its own Dockerfile / image; not part of the bot image)
docker/
# Docs and generated output
*.md
*.log
*.zip
*.csv
# OS / editor cruft
.DS_Store
.vscode/
.idea/

50
.env.example Normal file
View file

@ -0,0 +1,50 @@
# Copy this file to .env and fill in real values.
# .env is git-ignored. NEVER commit real secrets.
# --- Webex bot (webex-node-bot-framework) ---
WEBEX_BOT_TOKEN=your-webex-bot-token
WEBEX_BOT_NAME=AEO Call Provisioning
WEBEX_BOT_USERNAME=aeoCallProvisioning@webex.bot
WEBEX_BOT_ID=your-webex-bot-id
# --- Webex service account (integration used for admin API calls) ---
WEBEX_SVC_CLIENT_ID=your-webex-integration-client-id
WEBEX_SVC_CLIENT_SECRET=your-webex-integration-client-secret
# Path to the JSON file that persists the service-account access/refresh tokens.
# Created and rewritten automatically by the token-refresh cron.
WEBEX_TOKEN_STORE=./config/wbxTokens.json
# --- Twilio (phone number validation) ---
TWILIO_ACCOUNT_SID=your-twilio-account-sid
TWILIO_AUTH_TOKEN=your-twilio-auth-token
# --- Store Info Web (SIW) ---
# SIW requests are proxied through the on-prem remote agent (see WS_* below),
# so the base URL and credentials are forwarded to the agent per-request. This
# bot never talks to SIW directly.
SIW_BASE_URL=https://storeinfoweb-prod.ae.com
SIW_USERNAME=your-siw-user
SIW_PASSWORD=your-siw-password
# --- Remote agent WebSocket bridge ---
# This bot listens on WS_PORT; the on-prem agent (a second instance of the
# netanalyzer remoteAgent.js) dials in with WS_URL=wss://.../ and this token
# as its Bearer credential. Rotate WS_TOKEN with any high-entropy string.
WS_PORT=8080
WS_TOKEN=change-me-to-a-long-random-string
# --- Google ---
# Simple REST API key used by Address Validation + Time Zone endpoints.
GOOGLE_API_KEY=your-google-api-key
# Optional. Path to a Google service-account JSON key file (the one shaped
# like { type: "service_account", private_key: "-----BEGIN...", ... }).
# Not required for the REST calls above, but standard for any future code
# that uses google-auth-library / googleapis. Keep the file itself outside
# of git (config/google-service-account.json is already ignored).
GOOGLE_APPLICATION_CREDENTIALS=./config/google-service-account.json
# --- Runtime ---
NODE_ENV=production
LOG_LEVEL=info

39
.gitignore vendored Normal file
View file

@ -0,0 +1,39 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Secrets and local config
.env
.env.local
.env.*.local
config.json
config/wbxTokens.json
config/google-service-account.json
config/*-service-account.json
googleData.json
*.pem
*.key
# Runtime artifacts
*.log
buildlogs.log
# Generated CSV outputs (from scripts/generate911Csv.js)
buildingFile.csv
locationFile.csv
# Archives
*.zip
# Remote agent packaging output (produced by docker/remote-agent/package.sh)
docker/remote-agent/dist/
docker/remote-agent/.env
# OS / editor cruft
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp

6
.prettierignore Normal file
View file

@ -0,0 +1,6 @@
node_modules/
greetings/
buildingFile.csv
locationFile.csv
package-lock.json
config/wbxTokens.json

8
.prettierrc.json Normal file
View file

@ -0,0 +1,8 @@
{
"singleQuote": true,
"tabWidth": 4,
"printWidth": 100,
"trailingComma": "all",
"semi": true,
"arrowParens": "always"
}

30
Dockerfile Normal file
View file

@ -0,0 +1,30 @@
# syntax=docker/dockerfile:1.7
# --- build stage: install production dependencies only ---
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund
# --- runtime stage: minimal image with just what's needed to run ---
FROM node:22-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
# Bring in dependencies (owned by the built-in `node` user).
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
# Application code.
COPY --chown=node:node package.json package-lock.json ./
COPY --chown=node:node src ./src
COPY --chown=node:node greetings ./greetings
# Directory the token-refresh cron writes to. Mount a volume here in prod.
RUN mkdir -p /app/config && chown node:node /app/config
# WebSocket port the on-prem SIW agent connects back to. Override at runtime
# via -e WS_PORT and -p host:container.
EXPOSE 8080
USER node
CMD ["node", "src/index.js"]

197
README.md Normal file
View file

@ -0,0 +1,197 @@
# 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:22-alpine`).
- 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, to run the remote agent
(see [Remote SIW agent](#remote-siw-agent) below).
- Optional: a Google service-account JSON key. Only the REST API key is
required today, but if/when you add code that uses `google-auth-library`,
save the JSON at `config/google-service-account.json` and set
`GOOGLE_APPLICATION_CREDENTIALS` in `.env` to point at it. The file is
git-ignored.
## 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
```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 SIW agent
Store Info Web only accepts connections from inside the corporate network,
but the bot runs in the cloud. To bridge the gap, the bot hosts a
WebSocket server; a small on-prem agent dials in and proxies HTTP requests
back and forth.
The agent itself is intentionally generic — it just proxies whatever
`{method, url, headers, auth, body}` 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.
Once the agent is connected, the bot logs `Remote agent connected` and any
`/buildStore`, `/stageStore`, `/migrateStore` command will succeed. If the
agent is not connected, the SIW-dependent commands fail immediately with
`No remote SIW agent connected` rather than silently timing out.
### 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):
- `/buildStore <storeNumber>` — full green-field build (create location, calling,
greeting, attach user, license cleanup).
- `/stageStore <storeNumber>` — pre-migration setup: same as buildStore but
without phone-number attachment or licensing cleanup.
- `/migrateStore <storeNumber>` — cut-over for a staged store: attach the phone
number, set caller ID, create the auto-attendant, finalize licensing.
- `/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.
Card confirmations post `attachmentAction` events, dispatched in
[src/commands/attachmentActions.js](src/commands/attachmentActions.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 (build, stage, migrate, ...)
commands/ framework.hears handlers + attachmentAction dispatch
scripts/ one-off maintenance scripts (911 CSV, phone fix-up)
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] -->|/buildStore 1234| Commands[commands/*]
Commands --> SIW[integrations/siw.js]
Commands --> Users[webex/users.js]
Commands --> Card[cards/storeInfoCard.js]
Card -->|confirm| Attachment[attachmentActions.js]
Attachment --> Flow[flows/buildStore.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.

View file

@ -0,0 +1,16 @@
# =============================================================================
# wbxStoreProvision Remote Agent — Environment
# Copy this file to `.env` (next to docker-compose.yml) and fill in the
# values. NEVER commit .env — the .gitignore already excludes it.
# =============================================================================
# WebSocket URL of the main wbxStoreProvision bot. Use `wss://` if the bot
# is reverse-proxied through TLS; `ws://host:port` for a direct connection.
# Do NOT put the token in a `?token=` query parameter — WS_TOKEN below is
# sent as an Authorization: Bearer header instead.
WS_URL=wss://wbxstoreprovision.example.com/ws
# Shared secret that must match WS_TOKEN on the bot side (the value in the
# bot's own .env). Generate a strong random value once and rotate it if you
# suspect it's been exposed.
WS_TOKEN=change_me_to_a_long_random_value

View file

@ -0,0 +1,54 @@
# syntax=docker/dockerfile:1.7
# ============================================================================
# wbxStoreProvision — Remote Agent
# ----------------------------------------------------------------------------
# Tiny WebSocket client that proxies HTTP requests (Store Info Web today,
# anything else in the future) from an internal network back to the main
# wbxStoreProvision bot. Ships as a standalone container so it can run
# inside the segmented network where SIW lives.
#
# Build context is docker/remote-agent (self-contained — the agent doesn't
# need any of the bot's source or dependencies).
#
# Build:
# docker build -f docker/remote-agent/Dockerfile \
# -t wbxprov-remote-agent:latest docker/remote-agent
#
# Run:
# docker run --rm -it \
# --env-file docker/remote-agent/.env \
# --name wbxprov-remote-agent \
# wbxprov-remote-agent:latest
# ============================================================================
# ---- Stage 1: dependencies ------------------------------------------------
FROM node:22-alpine AS deps
WORKDIR /app
# Minimal manifest: ws + axios + dotenv. No lockfile — three stable deps.
COPY package.json ./package.json
RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force
# ---- Stage 2: runtime -----------------------------------------------------
FROM node:22-alpine AS runtime
# tini reaps zombies and forwards signals correctly so `docker stop` reaches
# Node's SIGTERM handler for a clean websocket close.
RUN apk add --no-cache tini
WORKDIR /app
USER node
COPY --chown=node:node --from=deps /app/node_modules ./node_modules
COPY --chown=node:node package.json ./package.json
COPY --chown=node:node remoteAgent.js ./remoteAgent.js
ENV NODE_ENV=production
# The agent is a WebSocket CLIENT — it doesn't listen on any port, so we
# intentionally skip EXPOSE.
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "remoteAgent.js"]

View file

@ -0,0 +1,152 @@
# wbxStoreProvision Remote Agent — Build & Package
This directory produces a self-contained, offline-installable Docker bundle
for the wbxStoreProvision remote agent. The agent is a tiny WebSocket
client that runs inside a segmented (store / on-prem) network and proxies
HTTP requests from the main bot back to Store Info Web.
The layout mirrors the equivalent bundle in the `netanalyzer` repo so the
same operator playbook applies. Both 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 without conflict.
## Layout
```
docker/remote-agent/
├── Dockerfile build definition (node:22-alpine + tini, non-root)
├── docker-compose.yml build-from-source compose (dev / rebuild use only)
├── package.json the agent's own package manifest (ws + axios + dotenv)
├── package.sh produces the shippable ZIP under dist/
├── inspect-bundle.sh docker-free arch/OS check on any built ZIP
├── remoteAgent.js the agent itself — proxies WS → HTTP
├── .env.example template consumed at deploy time
├── deploy/
│ ├── docker-compose.yml runtime-only compose (used inside the ZIP)
│ ├── install.sh idempotent installer bundled into the ZIP
│ └── README.md operator-facing README bundled into the ZIP
└── dist/ produced by package.sh (git-ignored)
```
## Build & package a bundle
From the repo root, or the `docker/remote-agent/` directory:
```bash
# Default: build for linux/amd64 (typical Rocky/RHEL/Ubuntu servers)
npm run agent:package
# or explicitly: ./docker/remote-agent/package.sh
# ARM Linux target (e.g. Raspberry Pi, ARM-based server)
./docker/remote-agent/package.sh --platform linux/arm64
# Override the version tag
./docker/remote-agent/package.sh --tag 1.0.1
# Preflight only — verify the build environment without actually building.
# Useful the first time you set up a new machine.
npm run agent:check
```
Output: `docker/remote-agent/dist/wbxprov-remote-agent-<version>.zip`.
### How cross-arch builds (arm64 Mac → linux/amd64) are guaranteed
Building an `x86_64` Linux image from an Apple Silicon Mac is the single
easiest way to silently ship a broken bundle, so the script defends against
that in **four independent layers**:
1. **Dedicated `docker-container` builder.** The default buildx builder on
Docker Desktop uses the `docker` driver, which is pinned to the daemon's
native architecture and will happily *ignore* `--platform`. The script
creates (and, if it finds a mis-driver builder with the same name,
*re-creates*) a `wbxprov-remote-agent-builder` using the
`docker-container` driver, which spins up an isolated BuildKit instance
that actually honors `--platform`.
2. **`binfmt` handlers.** When cross-building, the script best-effort
registers QEMU handlers for the target arch via `tonistiigi/binfmt`.
Docker Desktop usually has these; Colima / plain Docker Engine often
don't.
3. **`docker image inspect` check after build.** Reads the image out of the
local daemon and refuses to proceed if `Architecture` doesn't match the
requested `--platform`.
4. **`inspect-bundle.sh` on the final ZIP.** Independent, docker-free check
that reads the image config JSON directly out of the tarball inside the
ZIP. If this passes, the ZIP is provably correct regardless of anything
the local daemon may have said.
If any layer detects a mismatch, `package.sh` exits non-zero and prints
the exact rebuild command. You cannot accidentally ship an arm64 bundle to
an amd64 host.
### Verify a ZIP after the fact (no docker required)
```bash
# Auto-detects the newest ZIP in dist/:
npm run agent:inspect
# Or point it at a specific bundle:
./docker/remote-agent/inspect-bundle.sh path/to/wbxprov-remote-agent-1.0.0.zip
# Enforce expectations (exits non-zero if wrong):
EXPECTED_ARCH=amd64 EXPECTED_OS=linux npm run agent:inspect
```
This works on any machine with `python3` + `unzip` — no Docker daemon
required — so you can verify a bundle on the Linux target host itself
before running `./install.sh`.
### Requirements on the build host
- Docker with the buildx plugin (Docker Desktop includes it out of the box).
- `zip`, `node`, `python3`, and either `sha256sum` or `shasum`.
## Deploy the bundle on the remote host
Transfer the ZIP produced above to the target host, then:
```bash
unzip wbxprov-remote-agent-<version>.zip
cd wbxprov-remote-agent-<version>
./install.sh
```
`install.sh` will:
1. Verify the SHA-256 checksum against `SHA256SUMS`.
2. `docker load` the image tarball.
3. Sanity-check the image architecture matches the host.
4. On first run: copy `.env.example``.env` and stop, asking you to fill
in `WS_URL` (the bot's public WebSocket endpoint) and `WS_TOKEN`
(matching the value in the bot's `.env`).
5. On the second run: `docker compose up -d` to start the container.
See `deploy/README.md` for the full operator-facing guide (upgrades,
troubleshooting, logs).
## Configuring the bot side
The bot's `.env` needs matching values:
```
WS_PORT=8080 # what the bot's WebSocket server listens on
WS_TOKEN=<same secret the agent sends as Authorization: Bearer>
```
Make sure whatever public URL fronts the bot (reverse proxy, ingress, etc.)
maps `/ws` (or wherever `WS_URL` in the agent's `.env` points) through to
`WS_PORT` on the bot container.
## Coexisting with the netanalyzer agent
The two bundles are cleanly separated:
| | wbxStoreProvision | netanalyzer |
| ---------------------- | --------------------------------- | ---------------------------- |
| Image tag | `wbxprov-remote-agent:<version>` | `sha-remote-agent:<version>` |
| Container name | `wbxprov-remote-agent` | `sha-remote-agent` |
| Deploy folder | `wbxprov-remote-agent-<version>/` | `sha-remote-agent-<version>/`|
| `.env` variables | `WS_URL`, `WS_TOKEN` | `WS_URL`, `WS_TOKEN` |
Each has its own `.env` inside its own folder pointing at its own server, so
there's no shared state between them.

View file

@ -0,0 +1,136 @@
# wbxStoreProvision Remote Agent — Deploy Bundle
This ZIP is a self-contained deployment bundle for the wbxStoreProvision
remote agent. Extract it, run `install.sh`, fill in your `.env`, and the
agent will start as a Docker container.
## What's in the bundle
| File | Purpose |
| --- | --- |
| `wbxprov-remote-agent-<version>.tar.gz` | The Docker image, saved via `docker save`. |
| `docker-compose.yml` | Runtime-only compose file (no build step; references the loaded image). |
| `install.sh` | Verifies checksum, loads the image, seeds `.env`, starts the container. |
| `.env.example` | Template — copied to `.env` on first run for you to fill in. |
| `SHA256SUMS` | Integrity check for the image tarball. |
| `VERSION` | Plain-text version marker used by `install.sh` and `docker-compose.yml`. |
| `README.md` | This file. |
## Prerequisites (on the remote host)
- Docker 20.10+ with the daemon running.
- Docker Compose — either the modern `docker compose` plugin (v2) or the
legacy `docker-compose` binary. `install.sh` auto-detects.
- Whichever user runs `install.sh` needs permission to talk to the Docker
daemon (member of the `docker` group, or run under `sudo`).
- Outbound network access from the host to:
- The main wbxStoreProvision bot (`WS_URL`).
- Store Info Web (the internal API the agent proxies for).
## Install / start
```bash
unzip wbxprov-remote-agent-<version>.zip
cd wbxprov-remote-agent-<version>
./install.sh
```
On the first run `install.sh` will:
1. Verify the SHA-256 of the image tarball against `SHA256SUMS`.
2. Load the image into Docker (a fast no-op on subsequent runs).
3. Copy `.env.example``.env` and stop, asking you to fill it in.
Fill in `.env`:
```bash
vi .env # set WS_URL and WS_TOKEN
```
Then re-run:
```bash
./install.sh
```
That last run will start the container (`docker compose up -d`) and print
the log-tail command.
## Day-to-day operations
```bash
docker compose logs -f # tail the agent logs
docker compose ps # show container status
docker compose restart # cycle it
docker compose down # stop and remove the container
docker compose up -d # bring it back up
```
Healthy startup looks like:
```
Connecting to wss://.../ws...
Remote Agent connected to wbxStoreProvision
```
## Upgrading
When you receive a newer ZIP:
```bash
# Optional: back up your existing config
cp -a <old-version-folder>/.env ./wbxprov-remote-agent-<new-version>-env.bak
# Stop the old container
cd <old-version-folder> && docker compose down && cd ..
# Extract and start the new one
unzip wbxprov-remote-agent-<new-version>.zip
cp <old-version-folder>/.env wbxprov-remote-agent-<new-version>/.env
cd wbxprov-remote-agent-<new-version>
./install.sh
```
The old image stays in Docker's local cache until you `docker image prune`
it — handy if you need to roll back quickly.
## Coexistence with the netanalyzer agent
This bundle uses distinct image and container names
(`wbxprov-remote-agent`), so it can run on the same host as
`sha-remote-agent` (netanalyzer's agent) without any conflict. Keep the
two deploy folders separate — each has its own `.env` pointing at its own
server.
## Troubleshooting
- **"Cannot talk to the Docker daemon"** — either Docker isn't running or
your user isn't in the `docker` group. Try `sudo ./install.sh` or add
yourself to the group: `sudo usermod -aG docker $USER` and log back in.
- **"Checksum verification FAILED"** — the ZIP was corrupted in transit.
Re-transfer.
- **"exec /sbin/tini: exec format error"** or **"Image architecture
does not match this host"** — the ZIP was built for the wrong CPU
architecture (typically an Apple Silicon Mac produced an `arm64` image
for an `x86_64` Linux host). `install.sh` catches this and prints the
exact rebuild command; ask your build operator to run:
```
./docker/remote-agent/package.sh --platform linux/amd64
```
(or `linux/arm64` if this host is ARM — run `uname -m` to check:
`x86_64``linux/amd64`, `aarch64``linux/arm64`.)
Note that `install.sh` **always** re-runs `docker load` on the bundled
tarball, so a stale image left from an earlier wrong-arch attempt at the
same version tag will be transparently replaced when you install a
corrected bundle — no need to `docker rmi` by hand.
- **Agent connects, then disconnects immediately**`WS_TOKEN` doesn't
match the bot's `WS_TOKEN`. Fix in `.env`, then `docker compose restart`.
- **Agent never connects** — check `WS_URL` (correct hostname, correct
scheme `ws://` vs `wss://`) and that there's no firewall between this
host and the bot.
- **Requests to SIW fail from the agent's logs** — the container needs
direct network reachability to SIW. If SIW lives on the host's local
network and the container can't reach it, uncomment `network_mode: host`
in `docker-compose.yml` (Linux only) or attach the container to the
right user-defined network.

View file

@ -0,0 +1,26 @@
# Runtime-only compose file that ships inside the deploy ZIP.
# Unlike the build-time compose one level up, this one does NOT build
# anything — it references the image loaded from the tarball
# (`wbxprov-remote-agent:__VERSION__`, replaced at package time).
#
# Run:
# ./install.sh # first-time setup (loads image, seeds .env, starts)
# docker compose up -d # subsequent starts once installed
# docker compose logs -f # tail logs
# docker compose down # stop
services:
remote-agent:
image: wbxprov-remote-agent:__VERSION__
container_name: wbxprov-remote-agent
restart: unless-stopped
env_file:
- .env
# The agent is a websocket CLIENT — no ports to publish.
stop_signal: SIGTERM
stop_grace_period: 10s
logging:
driver: json-file
options:
max-size: '10m'
max-file: '3'

View file

@ -0,0 +1,138 @@
#!/usr/bin/env bash
#
# wbxStoreProvision Remote Agent — install / (re)start on a remote host.
#
# Run this after extracting the deploy ZIP:
# unzip wbxprov-remote-agent-<version>.zip
# cd wbxprov-remote-agent-<version>
# ./install.sh
#
# On first run: verifies the image tarball, loads it into Docker, and drops
# a starter .env so you can fill in WS_URL / WS_TOKEN. Re-runs are safe —
# the script is idempotent.
set -euo pipefail
# Move to the script's own directory so relative paths work regardless of
# where the user invoked it from.
cd "$(dirname "$0")"
RED=$'\033[0;31m'
GRN=$'\033[0;32m'
YLW=$'\033[1;33m'
RST=$'\033[0m'
log() { printf '%s[install]%s %s\n' "$GRN" "$RST" "$*"; }
warn() { printf '%s[install]%s %s\n' "$YLW" "$RST" "$*"; }
die() { printf '%s[install]%s %s\n' "$RED" "$RST" "$*" >&2; exit 1; }
# --- 1. Preflight ----------------------------------------------------------
command -v docker >/dev/null 2>&1 || die "Docker not found on PATH."
docker info >/dev/null 2>&1 \
|| die "Cannot talk to the Docker daemon. Is it running / do you have permission?"
# Detect either `docker compose` (v2 plugin) or the legacy `docker-compose`.
if docker compose version >/dev/null 2>&1; then
COMPOSE=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE=(docker-compose)
else
die "Neither 'docker compose' nor 'docker-compose' is available. Install Docker Compose."
fi
[[ -f VERSION ]] || die "VERSION file missing from bundle — is this a valid deploy ZIP?"
VERSION="$(cat VERSION)"
IMAGE_TARBALL="wbxprov-remote-agent-${VERSION}.tar.gz"
[[ -f "$IMAGE_TARBALL" ]] || die "Image tarball not found: $IMAGE_TARBALL"
# --- 2. Verify checksum (optional; skip gracefully if no shasum tool) -----
if [[ -f SHA256SUMS ]]; then
if command -v sha256sum >/dev/null 2>&1; then
log "Verifying SHA256 checksum..."
sha256sum -c SHA256SUMS >/dev/null || die "Checksum verification FAILED."
elif command -v shasum >/dev/null 2>&1; then
log "Verifying SHA256 checksum (macOS shasum)..."
shasum -a 256 -c SHA256SUMS >/dev/null || die "Checksum verification FAILED."
else
warn "No sha256sum/shasum tool found — skipping integrity check."
fi
log "Checksum OK."
else
warn "No SHA256SUMS file in bundle — skipping integrity check."
fi
# --- 3. Load the image -----------------------------------------------------
# ALWAYS docker load, unconditionally. `docker load` reassigns the tag to
# whatever's in the tarball and is a fast no-op when the layers are already
# present, so this is safe to re-run. We intentionally do NOT "skip if the
# tag already exists" — a stale image left over from a previous wrong-arch
# attempt has this exact tag, and skipping the load would hide the correct
# image in the tarball we're actually holding.
IMAGE_TAG="wbxprov-remote-agent:${VERSION}"
log "Loading Docker image from ${IMAGE_TARBALL}..."
docker load -i "$IMAGE_TARBALL"
# --- 3a. Platform sanity check --------------------------------------------
# Inspect the image we just (re)loaded — this is the ground truth for what
# was actually shipped in this ZIP, independent of anything that was on the
# host before. If the tarball was built for a different CPU architecture
# than this host, Docker will let it "run" but tini (and node) fail with
# cryptic errors like "exec format error". Catch that up front with a
# clear message.
IMAGE_ARCH="$(docker image inspect --format '{{.Architecture}}' "$IMAGE_TAG" 2>/dev/null || true)"
IMAGE_ID_SHORT="$(docker image inspect --format '{{.Id}}' "$IMAGE_TAG" 2>/dev/null | sed 's|^sha256:||' | cut -c1-12)"
HOST_ARCH_RAW="$(uname -m)"
case "$HOST_ARCH_RAW" in
x86_64|amd64) HOST_ARCH="amd64" ;;
aarch64|arm64) HOST_ARCH="arm64" ;;
armv7l) HOST_ARCH="arm" ;;
*) HOST_ARCH="$HOST_ARCH_RAW" ;;
esac
log "Loaded ${IMAGE_TAG} (id: ${IMAGE_ID_SHORT:-unknown}, arch: ${IMAGE_ARCH:-unknown}); host arch: ${HOST_ARCH}."
if [[ -z "$IMAGE_ARCH" ]]; then
die "Could not read image architecture from Docker — is the image really loaded?"
fi
if [[ "$IMAGE_ARCH" != "$HOST_ARCH" ]]; then
warn "Image architecture (${IMAGE_ARCH}) does not match this host (${HOST_ARCH})."
warn "The container would fail to start with 'exec /sbin/tini: exec format error'."
warn ""
warn "This means the ZIP was built for the wrong CPU. On the build host:"
warn " 1. git pull # make sure you have the fixed package.sh"
warn " 2. ./docker/remote-agent/package.sh --platform linux/${HOST_ARCH}"
warn "The updated package.sh verifies the architecture during build and"
warn "refuses to produce a ZIP that would fail this check."
die "Aborting install. Ship a linux/${HOST_ARCH} bundle and re-run this script."
fi
# --- 4. Bootstrap .env -----------------------------------------------------
if [[ ! -f .env ]]; then
if [[ -f .env.example ]]; then
cp .env.example .env
warn ".env did not exist — copied .env.example into place."
warn "EDIT .env now to set WS_URL and WS_TOKEN, then re-run this script."
exit 0
else
die ".env is missing and no .env.example is bundled. Cannot proceed."
fi
fi
# --- 5. Start the container -----------------------------------------------
log "Starting wbxprov-remote-agent (version ${VERSION})..."
"${COMPOSE[@]}" up -d
log "Done. Tail logs with:"
log " ${COMPOSE[*]} logs -f"
log "Stop with:"
log " ${COMPOSE[*]} down"

View file

@ -0,0 +1,40 @@
# Build-time compose file for the wbxStoreProvision remote agent.
# This one BUILDS from source; the runtime-only version used inside the
# deploy ZIP lives at deploy/docker-compose.yml.
#
# docker compose -f docker/remote-agent/docker-compose.yml up -d --build
#
# Environment values come from docker/remote-agent/.env (copy the .env.example
# next to it). Set WS_URL to the main wbxStoreProvision bot's public
# websocket endpoint, and WS_TOKEN to the shared secret configured on the
# bot side.
services:
remote-agent:
build:
context: .
dockerfile: Dockerfile
image: wbxprov-remote-agent:latest
container_name: wbxprov-remote-agent
restart: unless-stopped
env_file:
- .env
# The agent is a websocket CLIENT — nothing to publish. It just
# needs outbound network access to:
# - the main wbxStoreProvision bot (WS_URL)
# - Store Info Web (whatever internal SIW endpoint it proxies)
#
# If those live on the host's Docker network, uncomment
# `network_mode: host` (Linux only) or attach to a shared
# user-defined network.
#
# network_mode: host
stop_signal: SIGTERM
stop_grace_period: 10s
logging:
driver: json-file
options:
max-size: '10m'
max-file: '3'

View file

@ -0,0 +1,135 @@
#!/usr/bin/env bash
#
# Inspect a wbxprov-remote-agent deploy ZIP (or the tarball inside one) and
# report the actual OS/architecture of the Docker image it contains — WITHOUT
# needing docker installed. Reads the OCI/Docker image manifest directly out
# of the tarball.
#
# Use this before shipping a ZIP to a remote host to confirm you built the
# right platform. The main `package.sh` already runs `docker image inspect`
# on the image it just built, but this script gives you a completely
# independent check (no docker daemon involved at all) that also works on
# machines that never had docker on them.
#
# Usage:
# ./docker/remote-agent/inspect-bundle.sh <path-to-zip-or-tar.gz>
# ./docker/remote-agent/inspect-bundle.sh # auto-detect newest ZIP in dist/
#
# Exit codes:
# 0 bundle looks well-formed; arch/os printed
# 1 bad arguments / no bundle found
# 2 bundle is malformed or arch could not be read
#
# Optional env:
# EXPECTED_ARCH=amd64 die if the image's architecture does not match
# EXPECTED_OS=linux die if the image's OS does not match
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DIST_DIR="$SCRIPT_DIR/dist"
RED=$'\033[0;31m'
GRN=$'\033[0;32m'
YLW=$'\033[1;33m'
RST=$'\033[0m'
log() { printf '%s[inspect]%s %s\n' "$GRN" "$RST" "$*"; }
warn() { printf '%s[inspect]%s %s\n' "$YLW" "$RST" "$*"; }
die() { printf '%s[inspect]%s %s\n' "$RED" "$RST" "$*" >&2; exit 2; }
command -v python3 >/dev/null 2>&1 || die "python3 is required (ships with macOS; on Linux: 'apt install python3')."
BUNDLE="${1:-}"
if [[ -z "$BUNDLE" ]]; then
# Auto-detect: newest ZIP in dist/
[[ -d "$DIST_DIR" ]] || { printf '%s\n' "usage: $0 <zip-or-tarball>"; exit 1; }
BUNDLE="$(ls -t "$DIST_DIR"/*.zip 2>/dev/null | head -n 1 || true)"
[[ -n "$BUNDLE" ]] || { printf '%s\n' "usage: $0 <zip-or-tarball> (no ZIPs in $DIST_DIR)"; exit 1; }
log "Auto-detected newest bundle: $BUNDLE"
fi
[[ -e "$BUNDLE" ]] || die "File not found: $BUNDLE"
# Resolve to the actual image tarball. If given a ZIP, extract to a tempdir
# and locate the tar.gz inside; otherwise assume the arg IS the tarball.
TMPDIR_INSPECT=""
cleanup() { [[ -n "$TMPDIR_INSPECT" ]] && rm -rf "$TMPDIR_INSPECT"; }
trap cleanup EXIT
case "$BUNDLE" in
*.zip)
command -v unzip >/dev/null 2>&1 || die "unzip is required to inspect a ZIP."
TMPDIR_INSPECT="$(mktemp -d)"
log "Extracting ZIP into temp dir for inspection..."
unzip -q "$BUNDLE" -d "$TMPDIR_INSPECT"
TARBALL="$(find "$TMPDIR_INSPECT" -maxdepth 3 -name '*.tar.gz' -type f | head -n 1 || true)"
[[ -n "$TARBALL" ]] || die "Could not find an image tarball (*.tar.gz) inside the ZIP."
;;
*.tar.gz|*.tgz|*.tar)
TARBALL="$BUNDLE"
;;
*)
die "Unrecognized bundle type: $BUNDLE (expected .zip, .tar.gz, .tgz, or .tar)"
;;
esac
log "Reading image manifest from: $(basename "$TARBALL")"
# The image tarball is a standard docker/OCI save. It contains:
# manifest.json -> lists image config path (per-tag entry)
# <config-sha>.json OR blobs/sha256/<sha> -> per-image config JSON
# We read manifest.json to find the config blob, then read the config
# blob's 'architecture' and 'os' fields. Everything happens in-memory via
# `tar -xO`, so nothing is written to disk.
DECOMPRESS="cat"
case "$TARBALL" in
*.gz|*.tgz) DECOMPRESS="gunzip -c" ;;
esac
MANIFEST_JSON="$($DECOMPRESS "$TARBALL" | tar -xO manifest.json 2>/dev/null || true)"
[[ -n "$MANIFEST_JSON" ]] || die "No manifest.json in tarball — is this really a 'docker save' bundle?"
CONFIG_PATH="$(printf '%s' "$MANIFEST_JSON" | python3 -c "
import json, sys
data = json.load(sys.stdin)
if not data:
sys.exit('empty manifest')
entry = data[0]
config = entry.get('Config') or entry.get('config')
if not config:
sys.exit('no Config in manifest entry')
print(config)
")"
CONFIG_JSON="$($DECOMPRESS "$TARBALL" | tar -xO "$CONFIG_PATH" 2>/dev/null || true)"
[[ -n "$CONFIG_JSON" ]] || die "Could not read config blob '$CONFIG_PATH' from tarball."
read -r ARCH OS <<<"$(printf '%s' "$CONFIG_JSON" | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(d.get('architecture','?'), d.get('os','?'))
")"
log "Image tags: $(printf '%s' "$MANIFEST_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(', '.join(d[0].get('RepoTags') or ['(none)']))")"
log "Image OS: ${OS}"
log "Image arch: ${ARCH}"
FAIL=0
if [[ -n "${EXPECTED_ARCH:-}" && "${ARCH}" != "${EXPECTED_ARCH}" ]]; then
warn "Expected arch '${EXPECTED_ARCH}' but got '${ARCH}'."
FAIL=1
fi
if [[ -n "${EXPECTED_OS:-}" && "${OS}" != "${EXPECTED_OS}" ]]; then
warn "Expected OS '${EXPECTED_OS}' but got '${OS}'."
FAIL=1
fi
if (( FAIL != 0 )); then
die "Bundle does not match expected OS/arch. Rebuild with the correct --platform."
fi
log "OK — bundle looks well-formed."

View file

@ -0,0 +1,19 @@
{
"name": "wbxprov-remote-agent",
"version": "1.1.0",
"private": true,
"description": "Standalone container for the wbxStoreProvision remote agent (WebSocket proxy for Store Info Web from an internal network).",
"main": "remoteAgent.js",
"type": "commonjs",
"scripts": {
"start": "node remoteAgent.js"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"axios": "^1.16.1",
"dotenv": "^17.4.2",
"ws": "^8.20.1"
}
}

334
docker/remote-agent/package.sh Executable file
View file

@ -0,0 +1,334 @@
#!/usr/bin/env bash
#
# Package the wbxStoreProvision remote agent into a self-contained ZIP
# for offline / manual transfer to a remote Docker host.
#
# What this script does:
# 1. Reads the version from docker/remote-agent/package.json.
# 2. Builds `wbxprov-remote-agent:<version>` from the local source tree.
# 3. `docker save`s the image, gzip-compressed, into a temp staging dir.
# 4. Copies deploy/docker-compose.yml, deploy/install.sh, deploy/README.md,
# and .env.example into the staging dir. Rewrites the compose file's
# __VERSION__ placeholder to match the built image tag.
# 5. Writes VERSION and SHA256SUMS files for identification / integrity.
# 6. Zips the whole staging dir into docker/remote-agent/dist/.
#
# Usage:
# ./docker/remote-agent/package.sh # tag=package.json, platform=linux/amd64
# ./docker/remote-agent/package.sh --tag 1.0.1 # override tag
# ./docker/remote-agent/package.sh --platform linux/arm64 # ARM Linux target
# ./docker/remote-agent/package.sh --platform linux/amd64 # explicit default (Linux RH/Rocky/CentOS/Ubuntu on Intel)
#
# The image is ALWAYS built for the target platform via `docker buildx
# build --platform ...` so the tarball you ship matches the remote host.
# Default is linux/amd64 because that's the overwhelmingly common Linux
# server architecture; override with --platform if your remote host is
# something else (e.g. linux/arm64 for a Raspberry Pi or ARM-based server).
#
# Requires: docker (with buildx), zip, node (for reading package.json),
# sha256sum OR shasum (macOS ships shasum by default).
set -euo pipefail
# --- Locations --------------------------------------------------------------
# The build context is this directory (docker/remote-agent), which contains
# both the Dockerfile and the single agent source file. This is intentionally
# self-contained — the agent doesn't need any of the bot's source.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONTEXT_DIR="$SCRIPT_DIR"
DIST_DIR="$SCRIPT_DIR/dist"
DEPLOY_DIR="$SCRIPT_DIR/deploy"
DOCKERFILE="$SCRIPT_DIR/Dockerfile"
# --- Colors -----------------------------------------------------------------
GRN=$'\033[0;32m'
YLW=$'\033[1;33m'
RED=$'\033[0;31m'
RST=$'\033[0m'
log() { printf '%s[package]%s %s\n' "$GRN" "$RST" "$*"; }
warn() { printf '%s[package]%s %s\n' "$YLW" "$RST" "$*"; }
die() { printf '%s[package]%s %s\n' "$RED" "$RST" "$*" >&2; exit 1; }
# --- Argument parsing -------------------------------------------------------
VERSION=""
# Default target platform. Overwhelming majority of Linux server hosts
# (RHEL, Rocky, CentOS, Ubuntu, Debian) run on x86_64. Override with
# --platform for ARM Linux (linux/arm64) or anything else.
PLATFORM="linux/amd64"
CHECK_ONLY=0
while [[ $# -gt 0 ]]; do
case "$1" in
--tag)
shift
VERSION="${1:-}"
shift || true
;;
--platform)
shift
PLATFORM="${1:-}"
shift || true
;;
--check)
# Preflight only: verify docker/buildx/binfmt/builder are set up
# for the requested --platform. Do not build anything.
CHECK_ONLY=1
shift
;;
-h|--help)
grep '^#' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*)
die "Unknown argument: $1 (try --help)"
;;
esac
done
[[ -n "$PLATFORM" ]] || die "--platform requires a value (e.g. linux/amd64, linux/arm64)"
# --- Preflight --------------------------------------------------------------
command -v docker >/dev/null 2>&1 || die "docker not found on PATH."
command -v zip >/dev/null 2>&1 || die "zip not found on PATH."
command -v node >/dev/null 2>&1 || die "node not found on PATH."
# buildx is required so we can cross-build for a specific target platform
# on any host (e.g. build linux/amd64 from an Apple Silicon Mac).
docker buildx version >/dev/null 2>&1 \
|| die "docker buildx not available. Install Docker Desktop or the buildx plugin."
if [[ -z "$VERSION" ]]; then
VERSION="$(node -p "require('$SCRIPT_DIR/package.json').version")"
fi
[[ -n "$VERSION" ]] || die "Could not determine version."
log "Packaging wbxprov-remote-agent version: ${VERSION}"
log "Target platform: ${PLATFORM}"
IMAGE_TAG="wbxprov-remote-agent:${VERSION}"
BUNDLE_NAME="wbxprov-remote-agent-${VERSION}"
STAGING_DIR="$(mktemp -d)"
STAGING_ROOT="$STAGING_DIR/$BUNDLE_NAME"
mkdir -p "$STAGING_ROOT"
# Guarantee cleanup even on error.
cleanup() { rm -rf "$STAGING_DIR"; }
trap cleanup EXIT
# Normalize host arch into a linux/* platform string for cross-build detection.
HOST_ARCH_RAW="$(uname -m)"
case "$HOST_ARCH_RAW" in
x86_64|amd64) HOST_LINUX_PLATFORM="linux/amd64" ;;
aarch64|arm64) HOST_LINUX_PLATFORM="linux/arm64" ;;
armv7l) HOST_LINUX_PLATFORM="linux/arm/v7" ;;
*) HOST_LINUX_PLATFORM="linux/${HOST_ARCH_RAW}" ;;
esac
EXPECTED_ARCH="${PLATFORM##*/}"
# --- 1. Ensure a cross-arch-capable buildx builder --------------------------
# The DEFAULT buildx builder on Docker Desktop uses the "docker" driver, which
# is tied to the daemon's native platform. Passing --platform linux/amd64 on
# an arm64 host with that driver can silently produce an arm64 image (which
# is exactly the "image arch does not match host" failure the install.sh
# sanity check catches on the target machine).
#
# We work around it by creating (once) a dedicated builder with the
# "docker-container" driver, which spins up an isolated BuildKit instance
# capable of cross-arch builds when QEMU/binfmt is available.
BUILDER_NAME="wbxprov-remote-agent-builder"
CURRENT_BUILDER_DRIVER=""
if docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
CURRENT_BUILDER_DRIVER="$(docker buildx inspect "$BUILDER_NAME" 2>/dev/null | awk -F': *' '/^Driver:/ {print $2; exit}')"
fi
if [[ -z "$CURRENT_BUILDER_DRIVER" ]]; then
log "Creating dedicated buildx builder '${BUILDER_NAME}' (docker-container driver)..."
docker buildx create \
--name "$BUILDER_NAME" \
--driver docker-container \
--bootstrap >/dev/null
elif [[ "$CURRENT_BUILDER_DRIVER" != "docker-container" ]]; then
# A builder with this exact name exists but uses the wrong driver — the
# plain `docker` driver, most likely — which is the exact silent-fail
# source: it's pinned to the daemon's native arch and will happily ignore
# --platform. Recreate it correctly.
warn "Existing builder '${BUILDER_NAME}' uses driver '${CURRENT_BUILDER_DRIVER}', not 'docker-container'."
warn "Recreating it so cross-arch builds actually respect --platform..."
docker buildx rm "$BUILDER_NAME" >/dev/null 2>&1 || true
docker buildx create \
--name "$BUILDER_NAME" \
--driver docker-container \
--bootstrap >/dev/null
else
# Right builder, right driver — just make sure it's up.
docker buildx inspect --bootstrap "$BUILDER_NAME" >/dev/null
fi
# --- 2. Cross-arch binfmt (only when needed) --------------------------------
# Docker Desktop ships QEMU/binfmt handlers by default so this usually no-ops,
# but plain Docker Engine, Colima, or rootless setups often don't. When we're
# cross-building, best-effort install binfmt for the target arch. If it fails
# (e.g. no --privileged, no internet, no image), warn but continue — the
# subsequent build step will fail fast with a clearer error if binfmt truly
# is missing.
if [[ "$PLATFORM" != "$HOST_LINUX_PLATFORM" ]]; then
log "Cross-arch build (${HOST_LINUX_PLATFORM} -> ${PLATFORM}); ensuring binfmt handlers..."
if ! docker run --privileged --rm tonistiigi/binfmt --install "$EXPECTED_ARCH" >/dev/null 2>&1; then
warn "Could not auto-install binfmt for ${EXPECTED_ARCH}. If the build fails, install it manually:"
warn " docker run --privileged --rm tonistiigi/binfmt --install all"
fi
fi
# --- 2a. --check preflight short-circuit ------------------------------------
# If --check was passed, we've now verified: docker daemon reachable, buildx
# available, a docker-container builder exists (or was just recreated) for
# cross-arch, and binfmt handlers were attempted. Report and exit without
# building anything.
if (( CHECK_ONLY == 1 )); then
log ""
log "=========================================================="
log " Preflight OK for target platform: ${PLATFORM}"
log " host platform: ${HOST_LINUX_PLATFORM}"
log " builder: ${BUILDER_NAME} (docker-container)"
if [[ "$PLATFORM" != "$HOST_LINUX_PLATFORM" ]]; then
log " cross-arch: yes (binfmt registered above)"
else
log " cross-arch: no (native build)"
fi
log "=========================================================="
log "Re-run without --check to actually produce a bundle."
exit 0
fi
# --- 3. Build directly to a portable tarball --------------------------------
# `--output type=docker,dest=...` writes a `docker load`-compatible tarball
# straight to disk. This intentionally bypasses `--load` (and therefore the
# question of whether the local daemon can even store cross-arch images).
UNZIPPED_TAR="$STAGING_ROOT/${BUNDLE_NAME}.tar"
log "Building ${IMAGE_TAG} for ${PLATFORM} -> $(basename "$UNZIPPED_TAR") (context = ${CONTEXT_DIR})..."
docker buildx build \
--builder "$BUILDER_NAME" \
--platform "$PLATFORM" \
--output "type=docker,dest=${UNZIPPED_TAR},name=${IMAGE_TAG}" \
-f "$DOCKERFILE" \
"$CONTEXT_DIR"
[[ -s "$UNZIPPED_TAR" ]] || die "buildx produced no output tarball. Aborting."
# --- 4. Verify the built image actually matches --platform ------------------
# Regression guard: if buildx (or binfmt) silently ignored the requested
# platform, catch it here instead of shipping a broken bundle that only
# fails on the remote host with an "exec format error".
log "Verifying built image architecture..."
docker load -i "$UNZIPPED_TAR" >/dev/null
ACTUAL_ARCH="$(docker image inspect --format '{{.Architecture}}' "$IMAGE_TAG")"
if [[ "$ACTUAL_ARCH" != "$EXPECTED_ARCH" ]]; then
die "Built image architecture is '${ACTUAL_ARCH}' but '${EXPECTED_ARCH}' was requested.
This usually means buildx couldn't cross-compile for ${PLATFORM}.
Try installing binfmt handlers explicitly:
docker run --privileged --rm tonistiigi/binfmt --install all
Then re-run:
$0 --platform ${PLATFORM}"
fi
log "Verified: image architecture is ${ACTUAL_ARCH} (matches requested ${EXPECTED_ARCH})."
# Also tag :latest locally for convenience (only when it matches the host,
# so we don't leave a broken cross-arch :latest sitting in the daemon).
if [[ "$PLATFORM" == "$HOST_LINUX_PLATFORM" ]]; then
docker tag "$IMAGE_TAG" "wbxprov-remote-agent:latest" 2>/dev/null || true
fi
# --- 5. Compress the tarball ------------------------------------------------
IMAGE_TARBALL="${BUNDLE_NAME}.tar.gz"
log "Compressing image tarball -> ${IMAGE_TARBALL}..."
gzip -c "$UNZIPPED_TAR" > "$STAGING_ROOT/$IMAGE_TARBALL"
rm -f "$UNZIPPED_TAR"
TAR_SIZE_MB="$(du -m "$STAGING_ROOT/$IMAGE_TARBALL" | cut -f1)"
log "Image tarball size: ${TAR_SIZE_MB} MB"
# --- 6. Copy deploy assets --------------------------------------------------
log "Copying deploy assets into bundle..."
cp "$SCRIPT_DIR/.env.example" "$STAGING_ROOT/.env.example"
cp "$DEPLOY_DIR/install.sh" "$STAGING_ROOT/install.sh"
cp "$DEPLOY_DIR/README.md" "$STAGING_ROOT/README.md"
# Template the version into the runtime compose file so it references the
# specific image tag we just built.
sed "s|__VERSION__|${VERSION}|g" \
"$DEPLOY_DIR/docker-compose.yml" > "$STAGING_ROOT/docker-compose.yml"
chmod +x "$STAGING_ROOT/install.sh"
# --- 7. VERSION + SHA256SUMS -----------------------------------------------
printf '%s\n' "$VERSION" > "$STAGING_ROOT/VERSION"
log "Computing SHA-256 checksum for the image tarball..."
pushd "$STAGING_ROOT" >/dev/null
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$IMAGE_TARBALL" > SHA256SUMS
elif command -v shasum >/dev/null 2>&1; then
# macOS: shasum -a 256 emits the same "<hash> <filename>" format
# `sha256sum -c` understands.
shasum -a 256 "$IMAGE_TARBALL" > SHA256SUMS
else
warn "No sha256sum/shasum available — skipping checksum file."
fi
popd >/dev/null
# --- 8. Zip -----------------------------------------------------------------
mkdir -p "$DIST_DIR"
ZIP_PATH="$DIST_DIR/${BUNDLE_NAME}.zip"
rm -f "$ZIP_PATH"
log "Creating ZIP: ${ZIP_PATH}"
# Zip from inside the temp dir so the archive contains the top-level folder
# with the bundle name (matching what install.sh expects when unzipped).
(cd "$STAGING_DIR" && zip -qr "$ZIP_PATH" "$BUNDLE_NAME")
ZIP_SIZE_MB="$(du -m "$ZIP_PATH" | cut -f1)"
# --- 9. Independent post-build inspection ----------------------------------
# Final belt-and-suspenders: run inspect-bundle.sh against the ZIP we just
# produced. This reads the image config straight out of the tarball WITHOUT
# going through the docker daemon, so it catches anything a misbehaving
# daemon or `docker image inspect` might have hidden earlier.
INSPECT_SCRIPT="$SCRIPT_DIR/inspect-bundle.sh"
if [[ -x "$INSPECT_SCRIPT" ]]; then
log "Running independent (no-docker) inspection on the final ZIP..."
EXPECTED_OS=linux EXPECTED_ARCH="$EXPECTED_ARCH" \
"$INSPECT_SCRIPT" "$ZIP_PATH" \
|| die "Independent inspection FAILED. Bundle at $ZIP_PATH is not fit to ship."
else
warn "inspect-bundle.sh not found or not executable; skipping independent verification."
fi
# --- 10. Done ---------------------------------------------------------------
log ""
log "=========================================================="
log " Bundle ready:"
log " $ZIP_PATH"
log " (${ZIP_SIZE_MB} MB, built for ${PLATFORM})"
log "=========================================================="
log ""
log "Transfer to the remote host, then:"
log " unzip ${BUNDLE_NAME}.zip"
log " cd ${BUNDLE_NAME}"
log " ./install.sh"

View file

@ -0,0 +1,129 @@
const WebSocket = require('ws');
const axios = require('axios');
const https = require('https');
require('dotenv').config();
const WS_URL = process.env.WS_URL;
const WS_TOKEN = process.env.WS_TOKEN;
if (!WS_URL) {
console.error('WS_URL is not set in .env');
process.exit(1);
}
const INITIAL_BACKOFF_MS = 2000;
const MAX_BACKOFF_MS = 60000;
const PROXY_TIMEOUT_MS = 30000;
// Shared https.Agent used only when the bot flags a proxied request with
// `insecure: true` (e.g. reaching Store Info Web, which is served with an
// internal-CA cert Node doesn't know about). All other requests use axios's
// default (validated) TLS. Kept as a module-level singleton so we don't
// leak sockets per request.
const insecureHttpsAgent = new https.Agent({ rejectUnauthorized: false });
let ws = null;
let reconnectAttempts = 0;
let shuttingDown = false;
/**
* If WS_TOKEN is provided, send it as an Authorization: Bearer header so the
* secret stays out of access logs. (The server still accepts the legacy
* ?token=... query parameter for backward compatibility.)
*/
function buildClientOptions() {
if (!WS_TOKEN) return undefined;
return { headers: { Authorization: `Bearer ${WS_TOKEN}` } };
}
function connect() {
console.log(`Connecting to ${WS_URL}...`);
ws = new WebSocket(WS_URL, buildClientOptions());
ws.on('open', () => {
console.log('Remote Agent connected to wbxStoreProvision');
reconnectAttempts = 0;
});
ws.on('message', async (data) => {
let request;
try {
request = JSON.parse(data);
if (request.action !== 'proxyRequest') return;
const insecure = request.insecure === true;
console.log(
`Proxying ${request.method || 'GET'} ${request.url}${insecure ? ' (insecure TLS)' : ''}`,
);
const response = await axios({
method: request.method || 'GET',
url: request.url,
headers: request.headers || {},
auth: request.auth || undefined,
data: request.body || undefined,
timeout: PROXY_TIMEOUT_MS,
...(insecure ? { httpsAgent: insecureHttpsAgent } : {}),
});
ws.send(
JSON.stringify({
requestId: request.requestId,
status: response.status,
data: response.data,
headers: response.headers,
}),
);
} catch (err) {
console.error('Proxy error:', err.message);
ws.send(
JSON.stringify({
requestId: request ? request.requestId : null,
error: err.message,
status: err.response?.status || 500,
data: err.response?.data || null,
}),
);
}
});
ws.on('close', (code) => {
console.log(`Disconnected (code: ${code}).`);
if (!shuttingDown) scheduleReconnect();
});
ws.on('error', (err) => {
console.error('WebSocket error:', err.message);
});
}
function scheduleReconnect() {
reconnectAttempts++;
const backoff = Math.min(
INITIAL_BACKOFF_MS * Math.pow(1.5, reconnectAttempts - 1),
MAX_BACKOFF_MS,
);
console.log(
`Reconnecting in ${Math.round(backoff / 1000)}s... (attempt ${reconnectAttempts})`,
);
setTimeout(connect, backoff);
}
connect();
function shutdownRemote(signal) {
console.log(`${signal} received. Shutting down remote agent...`);
shuttingDown = true;
if (ws) {
try {
ws.close();
} catch (_e) {
// ignore close errors during shutdown
}
}
process.exit(0);
}
process.on('SIGINT', () => shutdownRemote('SIGINT'));
process.on('SIGTERM', () => shutdownRemote('SIGTERM'));

30
eslint.config.js Normal file
View file

@ -0,0 +1,30 @@
import js from '@eslint/js';
import globals from 'globals';
import prettier from 'eslint-config-prettier';
export default [
{
ignores: ['node_modules/**', 'greetings/**', 'buildingFile.csv', 'locationFile.csv'],
},
js.configs.recommended,
{
languageOptions: {
ecmaVersion: 2023,
sourceType: 'module',
globals: {
...globals.node,
fetch: 'readonly',
FormData: 'readonly',
Blob: 'readonly',
},
},
rules: {
'no-console': 'off',
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
eqeqeq: ['error', 'always'],
'no-var': 'error',
'prefer-const': 'warn',
},
},
prettier,
];

BIN
greetings/AEGreeting.wav Normal file

Binary file not shown.

BIN
greetings/AerieGreeting.wav Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

11319
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

40
package.json Normal file
View file

@ -0,0 +1,40 @@
{
"name": "wbxcallprov",
"version": "2.0.0",
"description": "Webex Calling provisioning bot for retail stores",
"private": true,
"type": "module",
"main": "src/index.js",
"engines": {
"node": ">=20.0.0"
},
"author": "Joseph B. McQueen",
"license": "ISC",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"generate-911-csv": "node scripts/generate911Csv.js",
"fix-store-phones": "node scripts/fixStorePhones.js",
"agent:package": "./docker/remote-agent/package.sh",
"agent:check": "./docker/remote-agent/package.sh --check",
"agent:inspect": "./docker/remote-agent/inspect-bundle.sh"
},
"dependencies": {
"dotenv": "^16.4.5",
"node-cron": "^3.0.3",
"undici": "^6.19.8",
"webex-node-bot-framework": "^2.5.1",
"ws": "^8.20.1"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"eslint": "^9.9.0",
"eslint-config-prettier": "^9.1.0",
"globals": "^15.9.0",
"prettier": "^3.3.3"
}
}

36
scripts/fixStorePhones.js Normal file
View file

@ -0,0 +1,36 @@
#!/usr/bin/env node
/**
* One-shot cleanup for store users: normalize licensing and pin the default
* meeting site. Replaces the legacy phoneFix.js.
*/
import { getAllUsers, setDefaultMeetingSite } from '../src/webex/users.js';
import { normalizeStoreUserLicenses } from '../src/webex/licensing.js';
import { DEFAULT_DEFAULT_MEETING_SITE } from '../src/constants.js';
import { logger } from '../src/logger.js';
async function main() {
logger.info('Fetching all Webex users...');
const users = await getAllUsers();
const storeUsers = users.filter((u) => u.firstName === 'Store');
logger.info(`Found ${storeUsers.length} store users to normalize.`);
for (const user of storeUsers) {
try {
await normalizeStoreUserLicenses(user);
logger.info(`${user.displayName} license normalized.`);
} catch (error) {
logger.error(`Error updating license for ${user.displayName}:`, error);
}
try {
await setDefaultMeetingSite(user.emails[0], DEFAULT_DEFAULT_MEETING_SITE);
logger.info(`${user.displayName} default site updated.`);
} catch (error) {
logger.error(`Error updating default site for ${user.displayName}:`, error);
}
}
}
main().catch((error) => {
logger.error('fixStorePhones failed:', error);
process.exit(1);
});

92
scripts/generate911Csv.js Normal file
View file

@ -0,0 +1,92 @@
#!/usr/bin/env node
/**
* Merged replacement for the legacy get911.js and storeAddress.js scripts.
* Pulls the store list from SIW, validates each address with Google, looks up
* phone numbers with Twilio, and writes buildingFile.csv + locationFile.csv
* suitable for the E911 uploader.
*
* Usage:
* node scripts/generate911Csv.js [--env dev|prod]
*
* Defaults to prod. The SIW filter endpoint takes /Store/Filter/{page}/{size}/{sort}/{dir}.
*/
import fs from 'node:fs/promises';
import { config } from '../src/config.js';
import { getSIWData } from '../src/integrations/siw.js';
import { validatePhoneNumber } from '../src/integrations/twilio.js';
import { formatE911Address, formatSuite, validateAddress } from '../src/integrations/google.js';
import { logger } from '../src/logger.js';
const BUILDING_HEADER =
'"Building Name *","Address *","Supplemental Info (20)","Organization Name Override (50)"\n';
const LOCATION_HEADER =
'"Building Name *","Location Name *","Phone Number (10)","Alternate ID (50)","Callback Number (10)","Location Info (20)","Organization Name Override (50)","HTML Link Name (64)","HTML Link URL (1024)"\n';
function brandFor(brandCode) {
switch (brandCode) {
case 'AE':
return 'American Eagle';
case 'AR':
return 'Aerie';
default:
return 'American Eagle';
}
}
async function main() {
const args = process.argv.slice(2);
const envIndex = args.indexOf('--env');
const envOverride = envIndex >= 0 ? args[envIndex + 1] : undefined;
const baseUrl =
envOverride === 'dev' ? config.siw.baseUrl.replace('-prod.', '-dev.') : config.siw.baseUrl;
const listUrl = `${baseUrl}/api/Store/Filter/1/9999/store_number/1`;
logger.info(`Fetching stores from ${listUrl}`);
const stores = await getSIWData(listUrl);
let buildingCsv = BUILDING_HEADER;
let locationCsv = LOCATION_HEADER;
let liveStores = 0;
for (const store of stores.stores ?? []) {
const openInUsProd =
store.status_id === 2 &&
store.environment_code === 'PRD' &&
store.country_code === 'US' &&
store.brand_code !== 'TS' &&
store.store_number > 1000;
if (!openInUsProd) continue;
try {
const googleAddress = await validateAddress(
`${store.address ?? ''} ${store.address2 ?? ''}`.trim(),
store.city,
store.state,
store.postal_code,
store.country_code,
);
const formatted = formatE911Address(googleAddress);
const suite = formatSuite(googleAddress);
const phone = await validatePhoneNumber(store.phone);
const brand = brandFor(store.brand_code);
const padded = String(store.store_number).padStart(4, '0');
buildingCsv += `"Store ${padded}","${formatted}",,"${brand}"\n`;
locationCsv += `"Store ${padded}","Store ${padded}","${phone.phone_number}",,,"${suite}","${brand}",,\n`;
liveStores++;
} catch (error) {
logger.error(`Failed store ${store.store_number}:`, error);
}
}
logger.info(`Live stores processed: ${liveStores}`);
await fs.writeFile('./buildingFile.csv', buildingCsv);
logger.info('buildingFile.csv saved.');
await fs.writeFile('./locationFile.csv', locationCsv);
logger.info('locationFile.csv saved.');
}
main().catch((error) => {
logger.error('generate911Csv failed:', error);
process.exit(1);
});

View file

@ -0,0 +1,66 @@
/**
* Build the confirmation card shown before build/stage/migrate operations.
* `action` is what the "Yes" button posts back to the bot (buildStore, stageStore, migrateStore).
*/
export function buildStoreInfoCard(storeInfo, userInfo, action) {
const storeFacts = [
{ title: 'Name', value: storeInfo.name },
{ title: 'Brand', value: storeInfo.brand },
{ title: 'Status', value: storeInfo.status },
{ title: 'Extension', value: storeInfo.extension },
{ title: 'VoicePortal', value: storeInfo.vpExtension },
{ title: 'Street', value: storeInfo.address.address1 },
{ title: 'City', value: storeInfo.address.city },
{ title: 'State', value: storeInfo.address.state },
{ title: 'postalCode', value: storeInfo.address.postalCode },
{ title: 'Country', value: storeInfo.address.country },
{ title: 'phone', value: storeInfo.phoneNumber },
{ title: 'Time Zone', value: storeInfo.timeZone },
];
const userFacts = [
{ title: 'Display Name', value: userInfo.displayName },
{ title: 'First Name', value: userInfo.firstName },
{ title: 'Last Name', value: userInfo.lastName },
];
return {
type: 'AdaptiveCard',
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
version: '1.3',
body: [
{
type: 'TextBlock',
text: `Location: ${storeInfo.name}`,
wrap: true,
weight: 'Bolder',
size: 'Medium',
},
{ type: 'FactSet', facts: storeFacts },
{
type: 'TextBlock',
text: `User: ${userInfo.emails[0]}`,
wrap: true,
weight: 'Bolder',
separator: true,
size: 'Medium',
},
{ type: 'FactSet', facts: userFacts },
{ type: 'TextBlock', text: 'Is this correct?', wrap: true, separator: true },
],
actions: [
{
type: 'Action.Submit',
title: 'Yes',
id: 'btnBuildStore',
data: { action, storeInfo, userInfo },
},
{
type: 'Action.Submit',
title: 'No',
id: 'btnBadInfo',
data: { action: 'deleteCard' },
},
],
};
}

185
src/cards/userInfoCard.js Normal file
View file

@ -0,0 +1,185 @@
import { findWebexUser, findWebexUserPhones, getWebexLicenses } from '../webex/users.js';
import { findWebexLocationById, getLocationCallingDetails } from '../webex/locations.js';
import { logger } from '../logger.js';
const DEFAULT_AVATAR =
'https://thumbs.dreamstime.com/z/default-avatar-profile-icon-vector-unknown-social-media-user-photo-default-avatar-profile-icon-vector-unknown-social-media-user-184816085.jpg?w=768';
function userHeader(user) {
const avatarUrl = user.avatar || DEFAULT_AVATAR;
const detailLines = [];
if (user.title) detailLines.push(user.title);
if (user.department) detailLines.push(user.department);
const phoneFacts = (user.phoneNumbers ?? []).map((p) => ({
title: p.type === 'work_extension' ? 'ext' : p.type,
value: p.value,
}));
return {
type: 'ColumnSet',
columns: [
{
type: 'Column',
items: [{ type: 'Image', style: 'Person', url: avatarUrl, size: 'Large' }],
width: 'auto',
horizontalAlignment: 'Left',
verticalContentAlignment: 'Top',
},
{
type: 'Column',
items: [
{
type: 'TextBlock',
weight: 'Bolder',
text: user.displayName,
wrap: true,
size: 'Medium',
},
{
type: 'TextBlock',
spacing: 'None',
text: detailLines.join('\n'),
isSubtle: true,
wrap: true,
},
{ type: 'FactSet', facts: phoneFacts, spacing: 'None' },
],
width: 'auto',
horizontalAlignment: 'Left',
},
],
};
}
function accountFacts(user) {
const facts = [];
if (user.created) facts.push({ title: 'Created', value: user.created });
if (user.lastModified) facts.push({ title: 'Modified', value: user.lastModified });
if (user.lastActivity) facts.push({ title: 'Last Active', value: user.lastActivity });
return { type: 'FactSet', facts, spacing: 'None' };
}
async function locationAction(user) {
try {
const [location, callingDetails] = await Promise.all([
findWebexLocationById(user.locationId),
getLocationCallingDetails(user.locationId),
]);
const facts = [
{ title: 'Name', value: location.name },
{ title: 'Address', value: location.address.address1 },
{ title: 'Timezone', value: location.timeZone },
{ title: 'Language', value: location.preferredLanguage },
{ title: 'Calling Line Name', value: callingDetails.callingLineId.name },
{ title: 'Calling Line Number', value: callingDetails.callingLineId.phoneNumber },
{ title: 'External Caller ID', value: callingDetails.externalCallerIdName },
];
return {
type: 'Action.ShowCard',
title: 'Location Information',
card: {
type: 'AdaptiveCard',
body: [{ type: 'FactSet', facts }],
},
};
} catch (error) {
logger.error('Error getting location for user card:', error);
return null;
}
}
async function licensesAction(user) {
const webexLicenses = await getWebexLicenses();
const lookup = new Map(webexLicenses.map((l) => [l.id, l]));
const lines = (user.licenses ?? [])
.map((id) => lookup.get(id))
.filter(Boolean)
.map((license) =>
license.siteUrl ? `${license.name} (${license.siteUrl})` : license.name,
);
return {
type: 'Action.ShowCard',
title: 'Licenses',
card: {
type: 'AdaptiveCard',
body: [{ type: 'TextBlock', text: lines.join('\n'), wrap: true }],
},
};
}
async function devicesAction(user) {
try {
const phones = await findWebexUserPhones(user.id);
if (!phones?.items?.length) return null;
const choices = phones.items.map((device) => ({
title:
device.type === 'phone'
? `${device.displayName} - ${device.product}`
: `${device.displayName} - ${device.type}`,
value: device.id,
}));
return {
type: 'Action.ShowCard',
title: 'Devices',
card: {
type: 'AdaptiveCard',
body: [
{
type: 'Input.ChoiceSet',
choices,
style: 'expanded',
isMultiSelect: true,
id: 'devices',
},
{
type: 'ActionSet',
actions: [
{
type: 'Action.Submit',
title: 'Show',
data: { action: 'showDevice' },
},
],
},
],
},
};
} catch (error) {
logger.error('Error getting phones:', error);
return null;
}
}
/**
* Build the informational user card. Renders whatever data we can retrieve
* and silently drops sections that fail (matching legacy behavior).
*/
export async function buildUserInfoCard(email) {
const user = await findWebexUser(email);
const infoCard = {
type: 'AdaptiveCard',
body: [userHeader(user), accountFacts(user)],
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
version: '1.3',
actions: [],
};
const [locAction, licAction, devAction] = await Promise.all([
locationAction(user),
licensesAction(user),
devicesAction(user),
]);
for (const action of [locAction, licAction, devAction]) {
if (action) infoCard.actions.push(action);
}
infoCard.actions.push({
type: 'Action.Submit',
title: 'Remove Card',
data: { action: 'deleteCard' },
});
return infoCard;
}

View file

@ -0,0 +1,63 @@
import { logger } from '../logger.js';
import { buildStoreLocation } from '../flows/buildStore.js';
import { stageStoreLocation } from '../flows/stageStore.js';
import { migrateStoreLocation } from '../flows/migrateStore.js';
import { getWebexDeviceDetail } from '../webex/devices.js';
async function runFlow(bot, trigger, verb, fn) {
const { storeInfo, userInfo } = trigger.attachmentAction.inputs;
bot.censor(trigger.attachmentAction.messageId);
bot.say(`${verb} ${storeInfo.name}.`);
try {
await fn(bot, storeInfo, userInfo);
bot.say(`Finished ${verb.toLowerCase()} ${storeInfo.name}.`);
} catch (error) {
logger.error(`${verb} failed for ${storeInfo?.name}:`, error);
bot.say(`Error ${verb.toLowerCase()} ${storeInfo?.name}: ${error.message}`);
}
}
async function showDevices(bot, trigger) {
const deviceIds = (trigger.attachmentAction.inputs.devices ?? '').split(',').filter(Boolean);
for (const deviceId of deviceIds) {
try {
const detail = await getWebexDeviceDetail(deviceId);
bot.say('markdown', `\`\`\` json\n${JSON.stringify(detail, null, 2)}\n\`\`\`\n`);
} catch (error) {
logger.error('Failed to fetch device detail:', error);
bot.say(`Failed to fetch device ${deviceId}: ${error.message}`);
}
}
}
export function register(framework) {
framework.on('attachmentAction', async (bot, trigger) => {
logger.debug('attachmentAction', JSON.stringify(trigger));
const action = trigger.attachmentAction.inputs.action;
switch (action) {
case 'buildStore':
await runFlow(bot, trigger, 'Building', buildStoreLocation);
break;
case 'stageStore':
await runFlow(bot, trigger, 'Staging', stageStoreLocation);
break;
case 'migrateStore':
await runFlow(bot, trigger, 'Migrating store', migrateStoreLocation);
break;
case 'showDevice':
await showDevices(bot, trigger);
break;
case 'deleteCard':
bot.censor(trigger.attachmentAction.messageId);
break;
default:
if (trigger.attachmentAction.inputs.responseTo === 'badInfo') {
bot.say(
'If information is not correct, please check Store Info Web or contact the administrator.',
);
}
break;
}
});
}

View file

@ -0,0 +1,29 @@
import { logger } from '../logger.js';
import { getStoreInfo } from '../integrations/siw.js';
import { findWebexUser } from '../webex/users.js';
import { buildStoreInfoCard } from '../cards/storeInfoCard.js';
import { parseStoreArg, storeEmail } from './helpers.js';
export function register(framework) {
framework.hears(
/\/buildstore/i,
async (bot, trigger) => {
logger.info(`${trigger.person.displayName} ran the buildStore command.`);
const storeNumber = parseStoreArg(trigger);
if (!storeNumber) {
bot.say("You didn't enter a store number.");
return;
}
try {
const storeInfo = await getStoreInfo(storeNumber);
const userInfo = await findWebexUser(storeEmail(storeNumber));
const card = buildStoreInfoCard(storeInfo, userInfo, 'buildStore');
bot.sendCard(card, 'Please use another client');
} catch (error) {
logger.error('buildStore command failed:', error);
bot.say('markdown', `Error running /buildstore:\n\`\`\`\n${error.message}\n\`\`\``);
}
},
'**/buildStore** <storeNumber> - Builds a store location for Webex Calling (New and remodels).',
);
}

36
src/commands/helpers.js Normal file
View file

@ -0,0 +1,36 @@
export function storeEmail(storeNumber) {
return `ae${String(storeNumber).padStart(5, '0')}@ae.com`;
}
/**
* Pull the first argument out of a webex-node-bot-framework trigger.
*
* For a message like `/storeInfo 792` matched by `/\/storeinfo/i`, the
* framework populates:
* trigger.command = "/storeInfo" // the matched substring
* trigger.prompt = " 792" // everything AFTER the match
*
* (See node_modules/webex-node-bot-framework/lib/framework.js
* calcCommandAndPrompt it does textForCommandAndPrompt.slice(match.index
* + match[0].length), which explicitly excludes the command itself.)
*
* This also works in @-mentioned group rooms because the framework strips
* the bot's display name from textForCommandAndPrompt before slicing.
*/
export function parseStoreArg(trigger) {
const prompt = (trigger?.prompt ?? '').trim();
if (prompt) return prompt.split(/\s+/)[0];
// Fallback for edge cases where prompt isn't populated: pull the token
// immediately after the command from trigger.args. args[0] is the
// command in DMs, so args[1] is our target. In mentioned group rooms
// args[0] is the bot name and args[1] is the command, so we search for
// the first token starting with '/' and take the one after it.
const args = Array.isArray(trigger?.args) ? trigger.args : [];
for (let i = 0; i < args.length - 1; i++) {
if (typeof args[i] === 'string' && args[i].startsWith('/')) {
return args[i + 1];
}
}
return undefined;
}

View file

@ -0,0 +1,33 @@
import { logger } from '../logger.js';
import { getStoreInfo } from '../integrations/siw.js';
import { findWebexUser } from '../webex/users.js';
import { buildStoreInfoCard } from '../cards/storeInfoCard.js';
import { parseStoreArg, storeEmail } from './helpers.js';
export function register(framework) {
framework.hears(
/\/migratestore/i,
async (bot, trigger) => {
logger.info(`${trigger.person.displayName} ran the migrateStore command.`);
const storeNumber = parseStoreArg(trigger);
if (!storeNumber) {
bot.say("You didn't enter a store number.");
return;
}
try {
const storeInfo = await getStoreInfo(storeNumber);
storeInfo.extension = `5${String(storeNumber).padStart(4, '0')}`;
const userInfo = await findWebexUser(storeEmail(storeNumber));
const card = buildStoreInfoCard(storeInfo, userInfo, 'migrateStore');
bot.sendCard(card, 'Please use another client');
} catch (error) {
logger.error('migrateStore command failed:', error);
bot.say(
'markdown',
`Error running /migratestore:\n\`\`\`\n${error.message}\n\`\`\``,
);
}
},
'**/migrateStore** <storeNumber> - Completes the store migration for an open store.',
);
}

View file

@ -0,0 +1,30 @@
import { logger } from '../logger.js';
import { getStoreInfo } from '../integrations/siw.js';
import { findWebexUser } from '../webex/users.js';
import { buildStoreInfoCard } from '../cards/storeInfoCard.js';
import { parseStoreArg, storeEmail } from './helpers.js';
export function register(framework) {
framework.hears(
/\/stagestore/i,
async (bot, trigger) => {
logger.info(`${trigger.person.displayName} ran the stageStore command.`);
const storeNumber = parseStoreArg(trigger);
if (!storeNumber) {
bot.say("You didn't enter a store number.");
return;
}
try {
const storeInfo = await getStoreInfo(storeNumber);
storeInfo.extension = `8${String(storeNumber).padStart(4, '0')}`;
const userInfo = await findWebexUser(storeEmail(storeNumber));
const card = buildStoreInfoCard(storeInfo, userInfo, 'stageStore');
bot.sendCard(card, 'Please use another client');
} catch (error) {
logger.error('stageStore command failed:', error);
bot.say('markdown', `Error running /stagestore:\n\`\`\`\n${error.message}\n\`\`\``);
}
},
'**/stageStore** <storeNumber> - Stages a store location for Webex Calling (Pre-migration).',
);
}

25
src/commands/storeInfo.js Normal file
View file

@ -0,0 +1,25 @@
import { logger } from '../logger.js';
import { buildUserInfoCard } from '../cards/userInfoCard.js';
import { parseStoreArg, storeEmail } from './helpers.js';
export function register(framework) {
framework.hears(
/\/storeinfo/i,
async (bot, trigger) => {
logger.info(`${trigger.person.displayName} ran the storeInfo command.`);
const storeNumber = parseStoreArg(trigger);
if (!storeNumber) {
bot.say("You didn't enter a store number.");
return;
}
try {
const card = await buildUserInfoCard(storeEmail(storeNumber));
bot.sendCard(card, 'Use a proper client...');
} catch (error) {
logger.error('storeInfo command failed:', error);
bot.say(`Error finding store user information: ${error.message}`);
}
},
'**/storeinfo** <storeNumber> - Shows store information',
);
}

25
src/commands/userInfo.js Normal file
View file

@ -0,0 +1,25 @@
import { logger } from '../logger.js';
import { buildUserInfoCard } from '../cards/userInfoCard.js';
import { parseStoreArg } from './helpers.js';
export function register(framework) {
framework.hears(
/\/userInfo/i,
async (bot, trigger) => {
logger.info(`${trigger.person.displayName} ran the userInfo command.`);
const email = parseStoreArg(trigger);
if (!email) {
bot.say("You didn't enter a user email.");
return;
}
try {
const card = await buildUserInfoCard(email);
bot.sendCard(card, 'Use a proper client...');
} catch (error) {
logger.error('userInfo command failed:', error);
bot.say(`Error finding user information: ${error.message}`);
}
},
'**/userInfo** <userEmail> - Shows user information',
);
}

86
src/config.js Normal file
View file

@ -0,0 +1,86 @@
import 'dotenv/config';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
function required(name) {
const value = process.env[name];
if (value === undefined || value === '') {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function optional(name, fallback) {
const value = process.env[name];
return value === undefined || value === '' ? fallback : value;
}
function resolvePath(value) {
return path.isAbsolute(value) ? value : path.resolve(projectRoot, value);
}
function integer(name, fallback) {
const value = process.env[name];
if (value === undefined || value === '') return fallback;
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
throw new Error(`Environment variable ${name} must be an integer between 1 and 65535`);
}
return parsed;
}
export const config = {
projectRoot,
nodeEnv: optional('NODE_ENV', 'development'),
logLevel: optional('LOG_LEVEL', 'info'),
ws: {
// Port this bot listens on for the remote SIW agent to connect back to.
port: integer('WS_PORT', 8080),
// Shared secret the agent must present as Bearer token (or ?token= query).
token: required('WS_TOKEN'),
},
webexBot: {
token: required('WEBEX_BOT_TOKEN'),
name: optional('WEBEX_BOT_NAME', 'AEO Call Provisioning'),
userName: optional('WEBEX_BOT_USERNAME', ''),
botId: optional('WEBEX_BOT_ID', ''),
webhookRequestJSONLocation: 'body',
removeWebhooksOnStart: true,
removeDeviceRegistrationsOnStart: true,
},
webexService: {
clientId: required('WEBEX_SVC_CLIENT_ID'),
clientSecret: required('WEBEX_SVC_CLIENT_SECRET'),
tokenStorePath: resolvePath(optional('WEBEX_TOKEN_STORE', './config/wbxTokens.json')),
},
twilio: {
accountSid: required('TWILIO_ACCOUNT_SID'),
authToken: required('TWILIO_AUTH_TOKEN'),
},
siw: {
baseUrl: optional('SIW_BASE_URL', 'https://storeinfoweb-prod.ae.com').replace(/\/$/, ''),
userName: required('SIW_USERNAME'),
password: required('SIW_PASSWORD'),
},
google: {
apiKey: required('GOOGLE_API_KEY'),
// Path to a service-account JSON key file. Optional — only set when
// future code needs google-auth-library-style credentials. The file
// itself lives outside of git.
serviceAccountKeyPath: (() => {
const raw = optional('GOOGLE_APPLICATION_CREDENTIALS', '');
return raw ? resolvePath(raw) : null;
})(),
},
};
export default config;

115
src/constants.js Normal file
View file

@ -0,0 +1,115 @@
// Organization-scoped identifiers pulled out of the legacy index.js.
// These are not secrets (they are Webex resource IDs and SIP details),
// but they are org-specific, so keeping them in one file makes future
// tenant/environment changes a single-file diff.
export const WEBEX_API_BASE = 'https://webexapis.com/v1';
export const ROUTE_GROUPS = {
US: {
id: 'Y2lzY29zcGFyazovL3VzL1JPVVRFX0dST1VQLzViMjc1ZmYzLTI0NTUtNDYwOC1iMzI4LTZlMThhOGViMDk1Mg',
name: 'Stores',
},
CA: {
id: 'Y2lzY29zcGFyazovL3VzL1JPVVRFX0dST1VQLzNlZjY5NmVlLThhYzUtNGI3Ni05M2YxLWQ1N2I0NTdiMmRjMA',
name: 'Stores Canada',
},
};
export const MUSIC_ON_HOLD = {
announcementId:
'Y2lzY29zcGFyazovL3VzL0FOTk9VTkNFTUVOVC9mNDExYTY2Yy1jMmRhLTQ3ODgtOTNhYi04OTU1NzBhYjM3N2Q',
fileName: 'MusicOnHold.wav',
};
// Webex license identifiers (subscription-specific).
export const LICENSES = {
// License to add when granting Webex Calling to a store user (BroadCloud).
STORE_CALLING:
'Y2lzY29zcGFyazovL3VzL0xJQ0VOU0UvZGI5Y2RjMzEtOGY4ZS00MGI0LTkxNmMtOGQ5ZTljOTJlNzBkOkJDU1REXzAwNDQyMWYyLWQ4NzUtNGNhYi04NjBmLWZlYWVmMzA0ZTExOA',
// License granted to store users during clean-up.
STORE_MEETINGS_ADD:
'Y2lzY29zcGFyazovL3VzL0xJQ0VOU0UvZGI5Y2RjMzEtOGY4ZS00MGI0LTkxNmMtOGQ5ZTljOTJlNzBkOkVFXzVhMjg5MDRmLWFmOGUtNDY3ZC1iMTY2LTQ1NWU5YjZjYTIwZF9hZW9zdG9yZXMud2ViZXguY29t',
// Licenses to remove during clean-up (obsolete trial/preview/free tiers).
LEGACY_TO_REMOVE: [
'Y2lzY29zcGFyazovL3VzL0xJQ0VOU0UvZGI5Y2RjMzEtOGY4ZS00MGI0LTkxNmMtOGQ5ZTljOTJlNzBkOkZUQ19hMjQ3MzgyOC1hOTgwLTQ3MmYtODE5ZC02YjljY2UwOGU5MmI',
'Y2lzY29zcGFyazovL3VzL0xJQ0VOU0UvZGI5Y2RjMzEtOGY4ZS00MGI0LTkxNmMtOGQ5ZTljOTJlNzBkOkNGX2VlNWI4YWU3LTExM2EtNGYyNC05ZjQyLTdhNWM0M2ZlNzllZg',
'Y2lzY29zcGFyazovL3VzL0xJQ0VOU0UvZGI5Y2RjMzEtOGY4ZS00MGI0LTkxNmMtOGQ5ZTljOTJlNzBkOkZUTV9mNWZkZTM1Zi00NzA0LTQ2MGEtODEwZi00YzVkMzUyNDFlNjk',
'Y2lzY29zcGFyazovL3VzL0xJQ0VOU0UvZGI5Y2RjMzEtOGY4ZS00MGI0LTkxNmMtOGQ5ZTljOTJlNzBkOk1TXzZiMzYxZmI5LTc5NmQtNGY0Yy1hNGI1LWIwODEzM2JmZmZlNw',
'Y2lzY29zcGFyazovL3VzL0xJQ0VOU0UvZGI5Y2RjMzEtOGY4ZS00MGI0LTkxNmMtOGQ5ZTljOTJlNzBkOkZNU185ZWNhNzgxNC0zMzEzLTQ2NGYtOTY0Mi0wMjM5ODc1YmM5Zjg',
'Y2lzY29zcGFyazovL3VzL0xJQ0VOU0UvZGI5Y2RjMzEtOGY4ZS00MGI0LTkxNmMtOGQ5ZTljOTJlNzBkOldYU0ZSRUVfYWExOWZjNTctZTFmOS00YTkwLTk2MjctYzM2ZjAwZGM5N2YxX2FlbzJnby10ZXN0LndlYmV4LmNvbQ',
'Y2lzY29zcGFyazovL3VzL0xJQ0VOU0UvZGI5Y2RjMzEtOGY4ZS00MGI0LTkxNmMtOGQ5ZTljOTJlNzBkOkVFXzIwNTM3OGM0LTA2NmYtNDM1ZC04MGZkLTc5MDdiMjJkMDBlMF9hZW8yZ28tcHJldmlldy53ZWJleC5jb20',
'Y2lzY29zcGFyazovL3VzL0xJQ0VOU0UvZGI5Y2RjMzEtOGY4ZS00MGI0LTkxNmMtOGQ5ZTljOTJlNzBkOldYU0ZSRUVfN2UxZjFhNDAtOTVjOC00YzkyLWI5MDEtMTA4MmZjNmQ2YWEwX2FlbzJnby53ZWJleC5jb20',
'Y2lzY29zcGFyazovL3VzL0xJQ0VOU0UvZGI5Y2RjMzEtOGY4ZS00MGI0LTkxNmMtOGQ5ZTljOTJlNzBkOkVFX2FjZTJlZjAyLTY2YzEtNDc0OC05YjhmLTRhOTdiNDIwMzdlM19hZW8yZ28ud2ViZXguY29t',
],
};
// Webex meeting sites to enroll every store user in.
export const MEETING_SITES = [
{ siteUrl: 'aeo2go.webex.com', accountType: 'attendee', operation: 'add' },
{ siteUrl: 'aeostores.webex.com', operation: 'add' },
];
export const DEFAULT_DEFAULT_MEETING_SITE = 'aeostores.webex.com';
// Caller-ID / voicemail defaults for a newly built location.
export const CALLER_ID = {
externalCallerIdName: 'American Eagle Outfitters',
locationFallbackNumber: '+15153186064',
customExternalCallerIdName: 'American Eagle',
};
export const VOICE_PORTAL_PASSCODE = '147369';
// Store brand -> greeting audio file (relative to project root).
export const GREETINGS = {
'American Eagle Outfitters': { file: 'greetings/AEGreeting.wav', label: 'AE Greeting' },
Aerie: { file: 'greetings/AerieGreeting.wav', label: 'Aerie Greeting' },
Offline: { file: 'greetings/OfflineGreeting.wav', label: 'Offline Greeting' },
Unsubscribed: { file: 'greetings/UnsubscribedGreeting.wav', label: 'Unsubscribed Greeting' },
};
// Timezone remapping. Webex Calling doesn't accept some IANA zones that Google returns,
// so we translate them to the nearest supported equivalent.
export const TIMEZONE_ALIASES = {
'America/Toronto': 'America/Montreal',
'America/Indiana/Indianapolis': 'America/New_York',
'America/Boise': 'America/Denver',
'America/Detroit': 'America/New_York',
'America/Matamoros': 'America/Chicago',
};
// Standard set of outgoing calling permissions applied to every store location.
export const OUTGOING_CALLING_PERMISSIONS = [
{ callType: 'INTERNAL_CALL', action: 'ALLOW', transferEnabled: true },
{ callType: 'LOCAL', action: 'ALLOW', transferEnabled: false },
{ callType: 'TOLL_FREE', action: 'ALLOW', transferEnabled: false },
{ callType: 'TOLL', action: 'ALLOW', transferEnabled: false },
{ callType: 'NATIONAL', action: 'ALLOW', transferEnabled: false },
{ callType: 'INTERNATIONAL', action: 'BLOCK', transferEnabled: false },
{ callType: 'OPERATOR_ASSISTED', action: 'BLOCK', transferEnabled: false },
{ callType: 'CHARGEABLE_DIRECTORY_ASSISTED', action: 'BLOCK', transferEnabled: false },
{ callType: 'SPECIAL_SERVICES_I', action: 'BLOCK', transferEnabled: false },
{ callType: 'SPECIAL_SERVICES_II', action: 'BLOCK', transferEnabled: false },
{ callType: 'PREMIUM_SERVICES_I', action: 'BLOCK', transferEnabled: false },
{ callType: 'PREMIUM_SERVICES_II', action: 'BLOCK', transferEnabled: false },
{ callType: 'CASUAL', action: 'BLOCK', transferEnabled: false },
{ callType: 'URL_DIALING', action: 'ALLOW', transferEnabled: true },
{ callType: 'UNKNOWN', action: 'ALLOW', transferEnabled: true },
];
export default {
WEBEX_API_BASE,
ROUTE_GROUPS,
MUSIC_ON_HOLD,
LICENSES,
MEETING_SITES,
DEFAULT_DEFAULT_MEETING_SITE,
CALLER_ID,
VOICE_PORTAL_PASSCODE,
GREETINGS,
TIMEZONE_ALIASES,
OUTGOING_CALLING_PERMISSIONS,
};

70
src/flows/buildStore.js Normal file
View file

@ -0,0 +1,70 @@
import {
addPhoneNumbersToLocation,
createLocation,
enableLocationForCalling,
updateInternalDialing,
updateLocationOutgoingPermission,
updateLocationRouteGroup,
updateLocationVoicemail,
updateLocationVoicePortal,
updateMusicOnHold,
} from '../webex/locations.js';
import { scheduleStoreDeviceSettings } from '../webex/devices.js';
import { createAllHoursSchedule } from '../webex/schedules.js';
import { uploadGreeting } from '../webex/announcements.js';
import { addWebexCallingToStoreUser, normalizeStoreUserLicenses } from '../webex/licensing.js';
import { updateUserCallExperience } from '../webex/users.js';
import { logger } from '../logger.js';
import { runStep } from './stepRunner.js';
import { greetingForBrand } from './greetingSelector.js';
/**
* "Build store" full green-field provisioning: create location, calling,
* greeting, user attach, license cleanup.
*/
export async function buildStoreLocation(bot, locationInfo, userInfo) {
logger.info(`Building location ${locationInfo.name}`);
const location = await createLocation(locationInfo);
bot.say(
'markdown',
`<blockquote class='success'>Created location ${location.name}.</blockquote>`,
);
await runStep(bot, 'Enabled location for Webex Calling', () =>
enableLocationForCalling(location),
);
await runStep(bot, 'Updated location Webex Calling connection', () =>
updateLocationRouteGroup(location),
);
await runStep(bot, 'Added phone numbers to location', () =>
addPhoneNumbersToLocation(location, locationInfo.phoneNumber),
);
await runStep(bot, 'Updated internal dialing', () => updateInternalDialing(location));
await runStep(bot, 'Updated location outgoing permission', () =>
updateLocationOutgoingPermission(location),
);
await runStep(bot, 'Updated music on hold', () => updateMusicOnHold(location));
await runStep(bot, 'Updated location voicemail', () => updateLocationVoicemail(location));
await runStep(bot, 'Updated location voice portal', () =>
updateLocationVoicePortal(location, locationInfo.vpExtension),
);
await runStep(bot, 'Created a schedule', () => createAllHoursSchedule(location));
await runStep(bot, 'Scheduled device changes', () => scheduleStoreDeviceSettings(location));
const { file, fileName } = greetingForBrand(locationInfo);
const greeting = await runStep(bot, 'Succeeded uploading greeting', () =>
uploadGreeting(location, file, fileName),
);
if (greeting) location.announcementId = greeting.id;
await runStep(bot, 'Updated store user', () =>
addWebexCallingToStoreUser(location, userInfo, locationInfo.extension),
);
await runStep(bot, 'Updated user call application experience', () =>
updateUserCallExperience(userInfo),
);
await runStep(bot, 'Fixed Licenses', () => normalizeStoreUserLicenses(userInfo));
bot.say('Build Complete!');
return location;
}

View file

@ -0,0 +1,13 @@
import { GREETINGS } from '../constants.js';
/**
* Pick the correct greeting audio file + filename for a given location's brand.
* Falls back to the AE greeting if the brand isn't recognized.
*/
export function greetingForBrand(locationInfo) {
const entry = GREETINGS[locationInfo.brand] ?? GREETINGS['American Eagle Outfitters'];
return {
file: entry.file,
fileName: `${locationInfo.storeNumber} - ${entry.label}.wav`,
};
}

75
src/flows/migrateStore.js Normal file
View file

@ -0,0 +1,75 @@
import {
addPhoneNumbersToLocation,
findWebexLocation,
updateLocationCallingIdentity,
} from '../webex/locations.js';
import { findLocationAnnouncements } from '../webex/announcements.js';
import { createStoreAutoAttendant } from '../webex/autoAttendants.js';
import { normalizeStoreUserLicenses } from '../webex/licensing.js';
import {
updateUserCallExperience,
updateUserCallerId,
updateUserExtension,
updateUserVoicemailSettings,
} from '../webex/users.js';
import { CALLER_ID } from '../constants.js';
import { logger } from '../logger.js';
import { runStep } from './stepRunner.js';
/**
* "Migrate store" finish the cut-over for a store that was previously staged.
* Attach the phone number, set caller ID, create the auto-attendant, and clean
* up the user's licensing/voicemail.
*/
export async function migrateStoreLocation(bot, locationInfo, userInfo) {
logger.info(`Migrating location ${locationInfo.name}`);
const matches = await findWebexLocation(locationInfo.name);
if (!matches.length) {
throw new Error(
`No existing Webex location found for ${locationInfo.name}. Did you stage first?`,
);
}
const location = matches[0];
await runStep(bot, 'Added phone numbers to location', () =>
addPhoneNumbersToLocation(location, locationInfo.phoneNumber),
);
await runStep(bot, 'Updated location Webex Calling Details', () =>
updateLocationCallingIdentity(location, locationInfo.phoneNumber),
);
await runStep(bot, 'Updated user extension', () =>
updateUserExtension(userInfo, locationInfo.extension),
);
await runStep(bot, 'Updated user call application experience', () =>
updateUserCallExperience(userInfo),
);
await runStep(bot, 'Updated user Caller ID Information', () =>
updateUserCallerId(userInfo, locationInfo.extension, locationInfo.phoneNumber, {
locationFallbackNumber: CALLER_ID.locationFallbackNumber,
customExternalCallerIdName: CALLER_ID.customExternalCallerIdName,
}),
);
await runStep(bot, 'Updated user voicemail settings', () =>
updateUserVoicemailSettings(userInfo),
);
const announcements = await runStep(bot, 'Located store announcement', () =>
findLocationAnnouncements(location),
);
if (announcements && announcements[0]) {
location.announcementId = announcements[0].id;
await runStep(bot, 'Created auto attendant', () =>
createStoreAutoAttendant(location, locationInfo),
);
} else {
bot.say(
'markdown',
"<blockquote class='failure'>Skipping auto attendant: no location announcement found.</blockquote>",
);
}
await runStep(bot, 'Fixed Licenses', () => normalizeStoreUserLicenses(userInfo));
bot.say('Migration Complete!');
return location;
}

57
src/flows/stageStore.js Normal file
View file

@ -0,0 +1,57 @@
import {
createLocation,
enableLocationForCalling,
updateInternalDialing,
updateLocationOutgoingPermission,
updateLocationVoicemail,
updateLocationVoicePortal,
updateMusicOnHold,
} from '../webex/locations.js';
import { scheduleStoreDeviceSettings } from '../webex/devices.js';
import { createAllHoursSchedule } from '../webex/schedules.js';
import { uploadGreeting } from '../webex/announcements.js';
import { addWebexCallingToStoreUser } from '../webex/licensing.js';
import { logger } from '../logger.js';
import { runStep } from './stepRunner.js';
import { greetingForBrand } from './greetingSelector.js';
/**
* "Stage store" pre-migration setup: location + calling + user, but without
* phone-number attachment or full licensing cleanup (that happens at migrate time).
*/
export async function stageStoreLocation(bot, locationInfo, userInfo) {
logger.info(`Staging location ${locationInfo.name}`);
const location = await createLocation(locationInfo);
bot.say(
'markdown',
`<blockquote class='success'>Created location ${location.name}.</blockquote>`,
);
await runStep(bot, 'Enabled location for Webex Calling', () =>
enableLocationForCalling(location),
);
await runStep(bot, 'Updated internal dialing', () => updateInternalDialing(location));
await runStep(bot, 'Updated location outgoing permission', () =>
updateLocationOutgoingPermission(location),
);
await runStep(bot, 'Updated music on hold', () => updateMusicOnHold(location));
await runStep(bot, 'Updated location voicemail', () => updateLocationVoicemail(location));
await runStep(bot, 'Updated location voice portal', () =>
updateLocationVoicePortal(location, locationInfo.vpExtension),
);
await runStep(bot, 'Created a schedule', () => createAllHoursSchedule(location));
await runStep(bot, 'Scheduled device changes', () => scheduleStoreDeviceSettings(location));
const { file, fileName } = greetingForBrand(locationInfo);
const greeting = await runStep(bot, 'Succeeded uploading greeting', () =>
uploadGreeting(location, file, fileName),
);
if (greeting) location.announcementId = greeting.id;
await runStep(bot, 'Updated store user', () =>
addWebexCallingToStoreUser(location, userInfo, locationInfo.extension),
);
bot.say('Staging Complete!');
return location;
}

21
src/flows/stepRunner.js Normal file
View file

@ -0,0 +1,21 @@
import { logger } from '../logger.js';
/**
* Run a provisioning step, log its outcome to console + bot chat, and return
* the step's resolved value (or undefined on failure). Never throws matches
* the legacy behavior where each step's error was captured but did not halt
* the flow.
*/
export async function runStep(bot, description, fn, { successMessage, failureMessage } = {}) {
try {
const result = await fn();
const msg = successMessage ?? description;
bot?.say('markdown', `<blockquote class='success'>${msg}</blockquote>`);
return result;
} catch (error) {
logger.error(`${description} failed:`, error);
const msg = failureMessage ?? `Error: ${description}`;
bot?.say('markdown', `<blockquote class='failure'>${msg}</blockquote>`);
return undefined;
}
}

67
src/http.js Normal file
View file

@ -0,0 +1,67 @@
import { logger } from './logger.js';
/**
* Wrapper around native fetch that transparently retries on HTTP 429 by
* honoring the Retry-After header (defaulting to 1 second if missing).
* Bounded by `maxRetries` to prevent infinite loops.
*/
export async function fetchWithRateLimit(url, options = {}, { maxRetries = 5 } = {}) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, options);
if (response.status !== 429) return response;
const retryAfterHeader = response.headers.get('retry-after');
const waitSeconds = Number(retryAfterHeader);
const delayMs = Number.isFinite(waitSeconds) && waitSeconds > 0 ? waitSeconds * 1000 : 1000;
logger.warn(
`429 from ${url}. Waiting ${delayMs}ms (attempt ${attempt + 1}/${maxRetries}).`,
);
await new Promise((r) => setTimeout(r, delayMs));
}
return fetch(url, options);
}
/**
* Send a JSON-bodied request and parse the response as JSON when there is one.
* Throws a descriptive Error on non-2xx responses.
*
* @param {string} method
* @param {string} url
* @param {object|string|undefined} body plain object (will be JSON.stringify'd) or pre-serialized string
* @param {object} [extra] extra fetch options merged in (headers, dispatcher, ...)
*/
export async function requestJson(method, url, body, extra = {}) {
const headers = {
'Content-Type': 'application/json',
...(extra.headers ?? {}),
};
const options = {
method,
headers,
...extra,
};
if (body !== undefined && body !== null) {
options.body = typeof body === 'string' ? body : JSON.stringify(body);
}
const response = await fetchWithRateLimit(url, options);
if (!response.ok) {
const errorText = await response.text().catch(() => '');
const error = new Error(
`HTTP ${response.status} ${response.statusText} for ${method} ${url}${errorText ? `\n${errorText}` : ''}`,
);
error.status = response.status;
error.body = errorText;
throw error;
}
if (response.status === 204) return undefined;
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
const text = await response.text();
return text === '' ? undefined : text;
}
const text = await response.text();
return text === '' ? undefined : JSON.parse(text);
}

95
src/index.js Normal file
View file

@ -0,0 +1,95 @@
import cron from 'node-cron';
import Framework from 'webex-node-bot-framework';
import { config } from './config.js';
import { logger } from './logger.js';
import { isAccessTokenExpiring, refreshAccessToken } from './webex/auth.js';
import { startWebSocketServer, stopWebSocketServer } from './services/websocket.js';
import { register as registerBuildStore } from './commands/buildStore.js';
import { register as registerStageStore } from './commands/stageStore.js';
import { register as registerMigrateStore } from './commands/migrateStore.js';
import { register as registerStoreInfo } from './commands/storeInfo.js';
import { register as registerUserInfo } from './commands/userInfo.js';
import { register as registerAttachmentActions } from './commands/attachmentActions.js';
const framework = new Framework(config.webexBot);
framework.on('initialized', () => {
framework.debug('Framework initialized successfully! [Press CTRL-C to quit]');
});
framework.on('log', (msg) => {
logger.info(msg);
});
registerBuildStore(framework);
registerStageStore(framework);
registerMigrateStore(framework);
registerStoreInfo(framework);
registerUserInfo(framework);
registerAttachmentActions(framework);
// Match `/help` (with the leading slash, matching the convention of every
// other command). The framework's string-phrase matcher only compares the
// FIRST whitespace-delimited token, so plain `'help'` wouldn't match
// `/help` — that's why we register a regex.
framework.hears(
/\/help\b/i,
(bot) => bot.say('markdown', framework.showHelp()),
'**/help** - get a list of my commands',
0,
);
// Catch-all — deliberately NOT using the `g` flag. `RegExp.prototype.test`
// with a `g` regex is stateful (lastIndex advances between calls), which
// caused this handler to silently drop the second-and-later messages the
// framework tested it against.
framework.hears(
/.+/,
(bot, trigger) => {
logger.debug('Unknown message:', trigger.message?.text);
bot.say(`Sorry, I don't know how to respond to "${trigger.message.text}"`);
bot.say('markdown', framework.showHelp());
},
'Unknown command handler',
99999,
);
// Periodic access-token refresh. The auth module collapses concurrent
// refreshes into a single request so overlapping ticks won't race.
cron.schedule('0 * * * * *', async () => {
try {
if (await isAccessTokenExpiring()) {
await refreshAccessToken();
}
} catch (error) {
logger.error('Scheduled token refresh failed:', error);
}
});
framework
.start()
.then(() => {
startWebSocketServer();
})
.catch((error) => {
logger.error('Failed to start framework:', error);
process.exit(1);
});
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
logger.info(`${signal} received. Shutting down...`);
try {
stopWebSocketServer();
await framework.stop();
} catch (error) {
logger.error('Error during shutdown:', error);
} finally {
process.exit(0);
}
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));

View file

@ -0,0 +1,52 @@
import { config } from '../config.js';
import { requestJson } from '../http.js';
/**
* Google Address Validation API. Returns the raw response body.
*/
export async function validateAddress(street, city, state, postalCode, country) {
const body = {
address: {
regionCode: country,
locality: city,
addressLines: [street],
administrativeArea: state,
postalCode,
},
};
const url = `https://addressvalidation.googleapis.com/v1:validateAddress?key=${encodeURIComponent(config.google.apiKey)}`;
return requestJson('POST', url, body);
}
/**
* Google Time Zone API for a given lat/lon at "now".
*/
export async function getTimeZone(latitude, longitude) {
const timestamp = Math.floor(Date.now() / 1000);
const url =
`https://maps.googleapis.com/maps/api/timezone/json?location=${latitude}%2C${longitude}` +
`&timestamp=${timestamp}&key=${encodeURIComponent(config.google.apiKey)}`;
return requestJson('POST', url);
}
/**
* Reduce a Google addressComponents array to a flat "num street, city, state zip" string.
*/
export function formatE911Address(addressData) {
const parts = {};
for (const component of addressData.result.address.addressComponents ?? []) {
parts[component.componentType] = component.componentName?.text ?? '';
}
return `${parts.street_number ?? ''} ${parts.route ?? ''}, ${parts.locality ?? ''}, ${
parts.administrative_area_level_1 ?? ''
} ${parts.postal_code ?? ''}`.trim();
}
export function formatSuite(addressData) {
for (const component of addressData.result.address.addressComponents ?? []) {
if (component.componentType === 'subpremise') {
return component.componentName?.text ?? '';
}
}
return '';
}

120
src/integrations/siw.js Normal file
View file

@ -0,0 +1,120 @@
import { config } from '../config.js';
import { proxyRequest } from '../services/websocket.js';
import { TIMEZONE_ALIASES } from '../constants.js';
import { validateAddress, getTimeZone } from './google.js';
import { validatePhoneNumber } from './twilio.js';
function siwAuthHeaders() {
const auth = Buffer.from(`${config.siw.userName}:${config.siw.password}`).toString('base64');
return { Authorization: `Basic ${auth}` };
}
function absoluteUrl(urlOrPath) {
if (urlOrPath.startsWith('http')) return urlOrPath;
const path = urlOrPath.startsWith('/') ? urlOrPath : `/${urlOrPath}`;
return `${config.siw.baseUrl}${path}`;
}
/**
* Fetch data from SIW via the on-prem remote agent. Accepts a full URL or a
* path (combined with config.siw.baseUrl). Returns the parsed response body.
*
* Throws when the agent isn't connected SIW is a hard dependency for the
* provisioning flows, so silent fallback would be misleading.
*
* `insecure: true` tells the remote agent to skip TLS verification for this
* specific request. SIW is served with an internal-CA cert that Node's
* default trust store doesn't know about; the agent is inside the
* corporate network reaching an internal-only host, so this is a scoped
* bypass rather than the process-wide NODE_TLS_REJECT_UNAUTHORIZED=0 that
* the original monolith used.
*/
export async function getSIWData(urlOrPath) {
const response = await proxyRequest({
method: 'GET',
url: absoluteUrl(urlOrPath),
headers: siwAuthHeaders(),
insecure: true,
});
return response?.data;
}
function normalizeTimeZone(timeZoneId) {
return TIMEZONE_ALIASES[timeZoneId] ?? timeZoneId;
}
function joinAddressLines(storeLocation) {
const parts = [
storeLocation.address,
storeLocation.address2,
storeLocation.address3,
storeLocation.address4,
]
.map((s) => (typeof s === 'string' ? s.trim() : ''))
.filter(Boolean);
return parts.join(', ');
}
/**
* Aggregate SIW/Twilio/Google data into the storeInfo object used by every
* provisioning flow. Mirrors the original getStoreInfo() from index.js but
* routed through the remote agent for SIW calls.
*/
export async function getStoreInfo(storeNumber) {
const padded = String(storeNumber).padStart(4, '0');
const [general, location] = await Promise.all([
getSIWData(`/api/StoreGeneral/${storeNumber}`),
getSIWData(`/api/StoreLocation/${storeNumber}`),
]);
if (!general || !location) {
throw new Error(`SIW returned no data for store ${storeNumber}.`);
}
const storeInfo = {
mallName: location.name,
name: `Store ${padded}`,
firstName: 'Store',
lastName: padded,
storeNumber: general.store_number,
brand: general.pimary_brand_name,
status: general.store_status_name,
extension: `5${padded}`,
vpExtension: `4${padded}`,
preferredLanguage: 'en_us',
announcementLanguage: 'en_us',
};
const twilioLookup = await validatePhoneNumber(location.phone);
storeInfo.phoneNumber = twilioLookup.phone_number;
const validated = await validateAddress(
joinAddressLines(location),
location.city,
location.state,
location.postal_code,
location.country_code,
);
storeInfo.address = {
address1: validated.result.address.formattedAddress,
city: validated.result.address.postalAddress.locality,
state: validated.result.address.postalAddress.administrativeArea,
postalCode: validated.result.address.postalAddress.postalCode,
country: validated.result.address.postalAddress.regionCode,
};
if (storeInfo.address.country === 'MX') {
storeInfo.preferredLanguage = 'es_es';
storeInfo.announcementLanguage = 'es_es';
storeInfo.phoneNumber = storeInfo.phoneNumber.replace('+1', '+52');
}
storeInfo.latitude = validated.result.geocode.location.latitude;
storeInfo.longitude = validated.result.geocode.location.longitude;
const tz = await getTimeZone(storeInfo.latitude, storeInfo.longitude);
storeInfo.timeZone = normalizeTimeZone(tz.timeZoneId);
return storeInfo;
}

View file

@ -0,0 +1,18 @@
import { config } from '../config.js';
import { requestJson } from '../http.js';
function twilioHeaders() {
const auth = Buffer.from(`${config.twilio.accountSid}:${config.twilio.authToken}`).toString(
'base64',
);
return { Authorization: `Basic ${auth}` };
}
/**
* Look up a phone number via Twilio's Lookup v2. Returns the raw response
* body; callers typically use `.phone_number` (E.164 normalized).
*/
export async function validatePhoneNumber(phoneNumber) {
const url = `https://lookups.twilio.com/v2/PhoneNumbers/${encodeURIComponent(phoneNumber)}`;
return requestJson('GET', url, undefined, { headers: twilioHeaders() });
}

25
src/logger.js Normal file
View file

@ -0,0 +1,25 @@
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
function currentLevel() {
const raw = (process.env.LOG_LEVEL ?? 'info').toLowerCase();
return LEVELS[raw] ?? LEVELS.info;
}
function timestamp() {
return new Date().toISOString();
}
function log(level, ...args) {
if (LEVELS[level] < currentLevel()) return;
const stream = level === 'error' || level === 'warn' ? console.error : console.log;
stream(`[${timestamp()}] [${level.toUpperCase()}]`, ...args);
}
export const logger = {
debug: (...a) => log('debug', ...a),
info: (...a) => log('info', ...a),
warn: (...a) => log('warn', ...a),
error: (...a) => log('error', ...a),
};
export default logger;

218
src/services/websocket.js Normal file
View file

@ -0,0 +1,218 @@
import { WebSocketServer, WebSocket } from 'ws';
import { config } from '../config.js';
import { logger } from '../logger.js';
/**
* Server side of the on-prem-agent WebSocket bridge. Symmetric to the client
* agent in the netanalyzer project (remoteAgent.js) the same binary can be
* pointed at this server by setting WS_URL/WS_TOKEN accordingly.
*
* The bot process listens on WS_PORT, one remote agent is allowed to be
* connected at a time, and callers use `proxyRequest({method, url, headers,
* auth, body})` which returns a Promise resolving to the agent's response.
*/
const PROXY_REQUEST_TIMEOUT_MS = 45000;
const PING_INTERVAL_MS = 30000;
const MAX_PENDING_REQUESTS = 200;
let wss = null;
let connectedAgent = null;
let pingInterval = null;
const pendingRequests = new Map();
function extractToken(req) {
const auth = req.headers['authorization'];
if (auth && /^Bearer\s+/i.test(auth)) {
return auth.replace(/^Bearer\s+/i, '').trim();
}
try {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
return url.searchParams.get('token');
} catch {
return null;
}
}
function rejectPending(reason) {
for (const { reject, timeout } of pendingRequests.values()) {
clearTimeout(timeout);
reject(new Error(reason));
}
pendingRequests.clear();
}
function handleAgentMessage(data) {
let response;
try {
response = JSON.parse(data);
} catch (error) {
logger.error('Failed to parse agent message:', error.message);
return;
}
const { requestId } = response;
if (!requestId || !pendingRequests.has(requestId)) {
logger.warn('Received response with unknown requestId:', requestId);
return;
}
const { resolve, reject, timeout } = pendingRequests.get(requestId);
clearTimeout(timeout);
pendingRequests.delete(requestId);
if (response.error) {
const err = new Error(response.error);
err.status = response.status;
err.data = response.data;
reject(err);
} else {
resolve(response);
}
}
export function startWebSocketServer() {
if (wss) return wss;
wss = new WebSocketServer({ port: config.ws.port });
wss.on('error', (err) => {
logger.error('WebSocket server error:', err.message);
});
wss.on('connection', (ws, req) => {
const token = extractToken(req);
const expected = config.ws.token;
const remote = req.socket.remoteAddress;
if (!expected) {
logger.error('WS_TOKEN not configured; refusing connection', { remote });
ws.close(1011, 'Server misconfigured');
return;
}
if (!token || token !== expected) {
logger.warn('Rejected agent connection: token mismatch', {
remote,
tokenPresent: !!token,
});
ws.close(1008, 'Invalid or missing token');
return;
}
if (connectedAgent && connectedAgent.readyState === WebSocket.OPEN) {
logger.warn('Replacing previously connected remote agent', { remote });
try {
connectedAgent.close(1013, 'Replaced by newer agent');
} catch {
// ignore
}
rejectPending('Remote agent replaced; in-flight requests dropped');
}
logger.info('Remote agent connected', { remote });
connectedAgent = ws;
ws.isAlive = true;
ws.on('message', handleAgentMessage);
ws.on('pong', () => {
ws.isAlive = true;
});
ws.on('error', (err) => {
logger.error('Remote agent socket error:', err.message);
});
ws.on('close', (code, reason) => {
logger.info('Remote agent disconnected', {
code,
reason: reason?.toString() || 'none',
});
if (connectedAgent === ws) {
connectedAgent = null;
rejectPending('Remote agent disconnected');
}
});
});
pingInterval = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, PING_INTERVAL_MS);
logger.info(`WebSocket server listening on port ${config.ws.port}`);
return wss;
}
export function stopWebSocketServer() {
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
if (connectedAgent) {
try {
connectedAgent.close();
} catch {
// ignore
}
connectedAgent = null;
}
if (wss) {
logger.info('Shutting down WebSocket server');
wss.close(() => logger.info('WebSocket server closed'));
wss = null;
}
rejectPending('Server shutting down');
}
export function isAgentConnected() {
return !!connectedAgent && connectedAgent.readyState === WebSocket.OPEN;
}
export class AgentNotConnectedError extends Error {
constructor(message = 'No remote SIW agent connected') {
super(message);
this.name = 'AgentNotConnectedError';
this.code = 'AGENT_NOT_CONNECTED';
}
}
/**
* Ask the connected remote agent to perform an HTTP request on our behalf.
* Resolves with `{status, data, headers}` from the agent, rejects with a
* regular Error (with `.status` when the agent proxied a non-2xx) on failure.
*/
export function proxyRequest(requestConfig) {
return new Promise((resolve, reject) => {
if (!isAgentConnected()) {
reject(new AgentNotConnectedError());
return;
}
if (pendingRequests.size >= MAX_PENDING_REQUESTS) {
reject(new Error('Too many in-flight proxy requests'));
return;
}
const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const timeout = setTimeout(() => {
pendingRequests.delete(requestId);
reject(new Error(`Proxy request timeout after ${PROXY_REQUEST_TIMEOUT_MS / 1000}s`));
}, PROXY_REQUEST_TIMEOUT_MS);
pendingRequests.set(requestId, { resolve, reject, timeout });
const payload = JSON.stringify({
action: 'proxyRequest',
requestId,
...requestConfig,
});
connectedAgent.send(payload, (err) => {
if (err) {
clearTimeout(timeout);
pendingRequests.delete(requestId);
reject(err);
}
});
});
}

View file

@ -0,0 +1,38 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { webexFetch, webexJson } from './client.js';
import { config } from '../config.js';
/**
* Upload a WAV greeting to the given location. Returns the parsed JSON
* response (includes the announcement `id`).
*/
export async function uploadGreeting(location, filePath, fileName) {
const absolute = path.isAbsolute(filePath)
? filePath
: path.resolve(config.projectRoot, filePath);
const buffer = await fs.readFile(absolute);
const form = new FormData();
form.append('file', new Blob([buffer], { type: 'audio/wav' }), fileName);
form.append('name', fileName);
const response = await webexFetch(
`/telephony/config/locations/${encodeURIComponent(location.id)}/announcements`,
{ method: 'POST', body: form },
);
if (!response.ok) {
const errorText = await response.text().catch(() => '');
throw new Error(
`Greeting upload failed for ${location.name}: HTTP ${response.status} ${response.statusText} ${errorText}`,
);
}
return response.json();
}
export async function findLocationAnnouncements(location) {
const data = await webexJson(
'GET',
`/telephony/config/announcements?locationId=${encodeURIComponent(location.id)}`,
);
return data?.announcements ?? [];
}

93
src/webex/auth.js Normal file
View file

@ -0,0 +1,93 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { config } from '../config.js';
import { logger } from '../logger.js';
const TOKEN_URL = 'https://webexapis.com/v1/access_token';
let cachedTokens = null;
let refreshing = null;
async function ensureLoaded() {
if (cachedTokens) return cachedTokens;
try {
const raw = await fs.readFile(config.webexService.tokenStorePath, 'utf8');
cachedTokens = JSON.parse(raw);
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error(
`Webex service-account token store not found at ${config.webexService.tokenStorePath}. ` +
'Create it with valid access_token, refresh_token, expiresOn (ISO or locale string).',
);
}
throw error;
}
return cachedTokens;
}
async function persist() {
if (!cachedTokens) return;
const target = config.webexService.tokenStorePath;
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, JSON.stringify(cachedTokens, null, 4));
}
/**
* Returns the current service-account access token. Callers should invoke this
* for every request so refreshes are picked up automatically.
*/
export async function getAccessToken() {
const tokens = await ensureLoaded();
return tokens.token.access_token;
}
/**
* Returns true when the current access token is within `withinMs` of expiring.
* Defaults to a 2h (7200000ms) window to match the legacy cron behavior.
*/
export async function isAccessTokenExpiring(withinMs = 2 * 60 * 60 * 1000) {
const tokens = await ensureLoaded();
const expiresOn = new Date(tokens.expiresOn);
return expiresOn.getTime() - Date.now() <= withinMs;
}
/**
* Refresh the service-account access token using the stored refresh token.
* Concurrent calls collapse into a single in-flight refresh.
*/
export async function refreshAccessToken() {
if (refreshing) return refreshing;
refreshing = (async () => {
const tokens = await ensureLoaded();
const params = new URLSearchParams({
grant_type: 'refresh_token',
client_id: config.webexService.clientId,
client_secret: config.webexService.clientSecret,
refresh_token: tokens.token.refresh_token,
});
logger.info('Refreshing Webex service-account access token');
const response = await fetch(TOKEN_URL, { method: 'POST', body: params });
if (!response.ok) {
const errorText = await response.text().catch(() => '');
throw new Error(
`Token refresh failed: HTTP ${response.status} ${response.statusText} ${errorText}`,
);
}
const json = await response.json();
cachedTokens.token = json;
cachedTokens.refresh_token = json.refresh_token;
cachedTokens.expiresOn = new Date(Date.now() + json.expires_in * 1000).toISOString();
cachedTokens.refreshBy = new Date(
Date.now() + json.refresh_token_expires_in * 1000,
).toISOString();
await persist();
logger.info(
`Token refreshed. Expires: ${cachedTokens.expiresOn}, refresh by: ${cachedTokens.refreshBy}`,
);
return cachedTokens;
})().finally(() => {
refreshing = null;
});
return refreshing;
}

View file

@ -0,0 +1,50 @@
import { webexJson } from './client.js';
/**
* Build the standard store auto-attendant (business hours: transfer to store,
* after hours: default greeting with 0 -> exit).
*/
export async function createStoreAutoAttendant(location, locationInfo) {
const body = {
name: locationInfo.name,
enabled: true,
phoneNumber: locationInfo.phoneNumber,
extension: null,
tollFreeNumber: false,
firstName: locationInfo.firstName,
lastName: locationInfo.lastName,
languageCode: location.preferredLanguage,
businessSchedule: 'All Hours',
extensionDialing: 'ENTERPRISE',
nameDialing: 'ENTERPRISE',
timeZone: location.timeZone,
businessHoursMenu: {
greeting: 'CUSTOM',
extensionEnabled: false,
keyConfigurations: [
{
key: '1',
description: 'Transfer to Store',
action: 'TRANSFER_WITHOUT_PROMPT',
value: locationInfo.extension,
},
],
audioAnnouncementFile: {
id: location.announcementId,
fileName: `${location.name} - announcement.wav`,
mediaFileType: 'WAV',
level: 'LOCATION',
},
},
afterHoursMenu: {
greeting: 'DEFAULT',
extensionEnabled: true,
keyConfigurations: [{ key: '0', action: 'EXIT' }],
},
};
return webexJson(
'POST',
`/telephony/config/locations/${encodeURIComponent(location.id)}/autoAttendants`,
body,
);
}

71
src/webex/client.js Normal file
View file

@ -0,0 +1,71 @@
import { fetchWithRateLimit, requestJson } from '../http.js';
import { WEBEX_API_BASE } from '../constants.js';
import { getAccessToken } from './auth.js';
function absolute(url) {
return url.startsWith('http')
? url
: `${WEBEX_API_BASE}${url.startsWith('/') ? '' : '/'}${url}`;
}
async function authHeaders(extra = {}) {
const token = await getAccessToken();
return {
Authorization: `Bearer ${token}`,
...extra,
};
}
/**
* Send a JSON request to the Webex API as the service account. Returns the
* parsed JSON body, or undefined for 204 responses. Throws on non-2xx.
*/
export async function webexJson(method, url, body) {
const headers = await authHeaders();
return requestJson(method, absolute(url), body, { headers });
}
/**
* Send a raw request (used for multipart uploads). Returns the fetch Response.
*/
export async function webexFetch(url, options = {}) {
const headers = await authHeaders(options.headers ?? {});
return fetchWithRateLimit(absolute(url), { ...options, headers });
}
/**
* GET a Webex list endpoint that supports RFC-5988 cursor pagination and
* concatenate all pages into a single array. `initialUrl` may be relative.
* `itemsKey` defaults to "items".
*/
export async function webexListAll(initialUrl, { itemsKey = 'items' } = {}) {
const collected = [];
let nextUrl = absolute(initialUrl);
const headers = await authHeaders();
while (nextUrl) {
const response = await fetchWithRateLimit(nextUrl, { method: 'GET', headers });
if (!response.ok) {
const errorText = await response.text().catch(() => '');
throw new Error(
`HTTP ${response.status} for GET ${nextUrl}${errorText ? `\n${errorText}` : ''}`,
);
}
const page = await response.json();
const items = page[itemsKey] ?? [];
collected.push(...items);
const linkHeader = response.headers.get('link');
nextUrl = parseNextLink(linkHeader);
}
return collected;
}
function parseNextLink(linkHeader) {
if (!linkHeader) return null;
for (const part of linkHeader.split(',')) {
const match = part.trim().match(/^<([^>]+)>;\s*rel="?next"?/);
if (match) return match[1];
}
return null;
}

168
src/webex/devices.js Normal file
View file

@ -0,0 +1,168 @@
import { webexJson } from './client.js';
const STORE_DEVICE_CUSTOMIZATIONS = {
ata: {
audioCodecPriority: {
selection: 'REGIONAL',
primary: 'G711a',
secondary: 'G711u',
tertiary: 'G729a',
},
ataDtmfMode: 'STRICT',
ataDtmfMethod: 'AVT',
cdpEnabled: true,
lldpEnabled: true,
qosEnabled: true,
vlan: { enabled: false, value: 1 },
webAccessEnabled: false,
nightlyResyncEnabled: true,
snmp: {
enabled: false,
trustedIP: '0.0.0.0/0.0.0.0',
getCommunity: 'public',
setCommunity: 'private',
snmpV3Enabled: false,
},
},
dect: {
audioCodecPriority: {
selection: 'REGIONAL',
primary: 'G729',
secondary: 'G711a',
tertiary: 'G711u',
},
cdpEnabled: true,
lldpEnabled: true,
qosEnabled: true,
vlan: { enabled: false, value: 0 },
webAccessEnabled: true,
nightlyResyncEnabled: true,
},
mpp: {
pnacEnabled: true,
audioCodecPriority: {
selection: 'REGIONAL',
primary: 'OPUS',
secondary: 'G722',
tertiary: 'G711u',
},
backlightTimer: 'FIVE_MIN',
background: { image: 'NONE' },
displayNameFormat: 'PERSON_NUMBER',
cdpEnabled: true,
defaultLoggingLevel: 'STANDARD',
dndServicesEnabled: false,
acd: { enabled: false, displayCallqueueAgentSoftkeys: 'LAST_PAGE' },
shortInterdigitTimer: 3,
longInterdigitTimer: 5,
lineKeyLabelFormat: 'PERSON_EXTENSION',
lineKeyLEDPattern: 'DEFAULT',
lldpEnabled: true,
mppUserWebAccessEnabled: false,
offHookTimer: 30,
phoneLanguage: 'PERSON_LANGUAGE',
poeMode: 'NORMAL',
qosEnabled: true,
screenTimeout: { enabled: false, value: 300 },
vlan: { enabled: false, value: 1, pcPort: 1 },
wifiNetwork: { enabled: false, authenticationMethod: 'NONE' },
callHistory: 'WEBEX_UNIFIED_CALL_HISTORY',
contacts: 'XSI_DIRECTORY',
webexMeetingsEnabled: false,
usbPorts: { enabled: false, sideUsbEnabled: false, rearUsbEnabled: false },
volumeSettings: {
ringerVolume: 9,
speakerVolume: 11,
handsetVolume: 10,
headsetVolume: 10,
eHookEnabled: true,
allowEndUserOverrideEnabled: false,
},
cfExpandedSoftKey: 'ALL_CALL_FORWARDS',
httpProxy: {
mode: 'OFF',
autoDiscoveryEnabled: true,
port: '3128',
authSettingsEnabled: false,
},
bluetooth: { enabled: false, mode: 'PHONE' },
passThroughPortEnabled: false,
userPasswordOverrideEnabled: false,
activeCallFocusEnabled: false,
peerFirmwareEnabled: true,
noiseCancellation: { enabled: true, allowEndUserOverrideEnabled: false },
dialAssistEnabled: true,
callsPerLine: 4,
nightlyResyncEnabled: true,
missedCallNotificationEnabled: true,
softKeyLayout: {
softKeyMenu: {
idleKeyList:
'guestin|;guestout|;acd_login|;acd_logout|;astate|;redial|;newcall|;cfwd|;recents|;dnd|;unpark|;psk1|;gpickup|;pickup|;dir|4;miss|5;selfview|;messages|;meetings',
offHookKeyList: 'endcall|1;redial|2;dir|3;lcr|4;unpark|5;pickup|6;gpickup|7',
dialingInputKeyList: 'dial|1;cancel|2;delchar|3;left|5;right|6',
progressingKeyList: 'endcall|2',
connectedKeyList:
'hold;endcall;xfer;conf;xferLx;confLx;bxfer;phold;redial;dir;park;crdstart;crdstop;crdpause;crdresume;adhocparticipants',
connectedVideoKeyList:
'hold;endcall;xfer;conf;xferLx;confLx;bxfer;phold;redial;dir;park;crdstart;crdstop;crdpause;crdresume;adhocparticipants',
startTransferKeyList: 'endcall|2;xfer|3',
startConferenceKeyList: 'endcall|2;conf|3',
conferencingKeyList: 'endcall;join;crdstart;crdstop;crdpause;crdresume',
releasingKeyList: 'endcall|2',
holdKeyList: 'resume|1;endcall|2;newcall|3;redial|4;dir|5;adhocparticipants',
ringingKeyList: 'answer|1;ignore|2',
sharedActiveKeyList: 'newcall|1;psk1|2;dir|3;back|4',
sharedHeldKeyList: 'resume|1;dir|4',
},
psk: { psk1: 'fnc=sd;ext=*11;nme=Call Pull' },
softKeyMenuDefaults: {
idleKeyList:
'guestin|;guestout|;acd_login|;acd_logout|;astate|;redial|;newcall|;cfwd|;recents|;dnd|;unpark|;psk1|;gpickup|;pickup|;dir|4;miss|5;selfview|;messages',
offHookKeyList: 'endcall|1;redial|2;dir|3;lcr|4;unpark|5;pickup|6;gpickup|7',
dialingInputKeyList: 'dial|1;cancel|2;delchar|3;left|5;right|6',
progressingKeyList: 'endcall|2',
connectedKeyList:
'hold;endcall;xfer;conf;xferLx;confLx;bxfer;phold;redial;dir;park;crdstart;crdstop;crdpause;crdresume',
connectedVideoKeyList:
'hold;endcall;xfer;conf;xferLx;confLx;bxfer;phold;redial;dir;park;crdstart;crdstop;crdpause;crdresume',
startTransferKeyList: 'endcall|2;xfer|3',
startConferenceKeyList: 'endcall|2;conf|3',
conferencingKeyList: 'endcall;join;crdstart;crdstop;crdpause;crdresume',
releasingKeyList: 'endcall|2',
holdKeyList: 'resume|1;endcall|2;newcall|3;redial|4;dir|5',
ringingKeyList: 'answer|1;ignore|2',
sharedActiveKeyList: 'newcall|1;psk1|2;dir|3;back|4',
sharedHeldKeyList: 'resume|1;dir|4',
},
pskDefaults: { psk1: 'fnc=sd;ext=*11;nme=Call Pull' },
},
backgroundImage8875: 'VIOLET_DARK',
backlightTimer68XX78XX: 'ALWAYS_ON',
voiceFeedbackAccessibilityEnabled: true,
},
wifi: {
audioCodecPriority: {
selection: 'REGIONAL',
primary: 'OPUS',
secondary: 'G722',
tertiary: 'G711u',
},
ldap: {},
webAccess: { enabled: true },
},
};
export async function scheduleStoreDeviceSettings(location) {
const body = {
locationId: location.id,
locationCustomizationsEnabled: true,
customizations: STORE_DEVICE_CUSTOMIZATIONS,
customEnabled: true,
};
return webexJson('POST', '/telephony/config/jobs/devices/callDeviceSettings', body);
}
export async function getWebexDeviceDetail(deviceId) {
return webexJson('GET', `/devices/${encodeURIComponent(deviceId)}`);
}

38
src/webex/licensing.js Normal file
View file

@ -0,0 +1,38 @@
import { webexJson } from './client.js';
import { LICENSES, MEETING_SITES } from '../constants.js';
/**
* Attach the store-calling license to a user at a given location + extension
* and enroll them in the standard meeting sites.
*/
export async function addWebexCallingToStoreUser(location, user, extension) {
const operation = {
personId: user.id,
licenses: [
{
id: LICENSES.STORE_CALLING,
operation: 'add',
properties: { locationId: location.id, extension },
},
],
siteUrls: MEETING_SITES,
};
return webexJson('PATCH', '/licenses/users', operation);
}
/**
* Clean up legacy trial/preview licenses for a store user, ensure the
* canonical meeting site is set, and add the current store meetings license.
*/
export async function normalizeStoreUserLicenses(user) {
const licenses = [
...LICENSES.LEGACY_TO_REMOVE.map((id) => ({ id, operation: 'remove' })),
{ id: LICENSES.STORE_MEETINGS_ADD, operation: 'add' },
];
const operation = {
personId: user.id,
licenses,
siteUrls: MEETING_SITES,
};
return webexJson('PATCH', '/licenses/users', operation);
}

164
src/webex/locations.js Normal file
View file

@ -0,0 +1,164 @@
import { webexJson } from './client.js';
import {
CALLER_ID,
MUSIC_ON_HOLD,
OUTGOING_CALLING_PERMISSIONS,
ROUTE_GROUPS,
VOICE_PORTAL_PASSCODE,
} from '../constants.js';
function routeGroupFor(location) {
return location.address.country === 'CA' ? ROUTE_GROUPS.CA : ROUTE_GROUPS.US;
}
export async function findWebexLocation(locationName) {
const url = `/locations?name=${encodeURIComponent(locationName)}`;
const data = await webexJson('GET', url);
return data.items ?? [];
}
export async function findWebexLocationById(locationId) {
return webexJson('GET', `/locations/${encodeURIComponent(locationId)}`);
}
export async function getLocationCallingDetails(locationId) {
return webexJson('GET', `/telephony/config/locations/${encodeURIComponent(locationId)}`);
}
export async function createLocation(locationInfo) {
const body = {
name: locationInfo.name,
timeZone: locationInfo.timeZone,
announcementLanguage: locationInfo.announcementLanguage,
preferredLanguage: locationInfo.preferredLanguage,
address: {
address1: locationInfo.address.address1,
city: locationInfo.address.city,
state: locationInfo.address.state,
postalCode: locationInfo.address.postalCode,
country: locationInfo.address.country,
},
latitude: locationInfo.latitude,
longitude: locationInfo.longitude,
};
return webexJson('POST', '/locations', body);
}
export async function enableLocationForCalling(location) {
const body = {
id: location.id,
name: location.name,
timeZone: location.timeZone,
announcementLanguage: location.preferredLanguage,
preferredLanguage: location.preferredLanguage,
address: location.address,
};
return webexJson('POST', '/telephony/config/locations', body);
}
export async function addPhoneNumbersToLocation(location, phoneNumber) {
const body = { phoneNumbers: [phoneNumber], state: 'ACTIVE' };
return webexJson(
'POST',
`/telephony/config/locations/${encodeURIComponent(location.id)}/numbers`,
body,
);
}
/**
* Point the location at its route group (used before phone numbers exist).
*/
export async function updateLocationRouteGroup(location) {
const routeGroup = routeGroupFor(location);
const body = {
connection: { id: routeGroup.id, type: 'ROUTE_GROUP' },
};
return webexJson('PUT', `/telephony/config/locations/${encodeURIComponent(location.id)}`, body);
}
/**
* Set the location's caller-ID/external-caller-ID after phone numbers exist.
*/
export async function updateLocationCallingIdentity(location, phoneNumber) {
const body = {
callingLineId: {
name: CALLER_ID.externalCallerIdName,
phoneNumber,
},
externalCallerIdName: phoneNumber.replace('+1', ''),
};
return webexJson('PUT', `/telephony/config/locations/${encodeURIComponent(location.id)}`, body);
}
export async function updateInternalDialing(location) {
const routeGroup = routeGroupFor(location);
const body = {
enableUnknownExtensionRoutePolicy: true,
unknownExtensionRouteIdentity: {
id: routeGroup.id,
name: routeGroup.name,
type: 'ROUTE_GROUP',
},
};
return webexJson(
'PUT',
`/telephony/config/locations/${encodeURIComponent(location.id)}/internalDialing`,
body,
);
}
export async function updateLocationOutgoingPermission(location) {
const body = { callingPermissions: OUTGOING_CALLING_PERMISSIONS };
return webexJson(
'PUT',
`/telephony/config/locations/${encodeURIComponent(location.id)}/outgoingPermission`,
body,
);
}
export async function updateMusicOnHold(location) {
const body = {
callHoldEnabled: true,
callParkEnabled: true,
greeting: 'CUSTOM',
audioFile: {
id: MUSIC_ON_HOLD.announcementId,
fileName: MUSIC_ON_HOLD.fileName,
mediaFileType: 'WAV',
level: 'ORGANIZATION',
},
};
return webexJson(
'PUT',
`/telephony/config/locations/${encodeURIComponent(location.id)}/musicOnHold`,
body,
);
}
export async function updateLocationVoicemail(location) {
const body = { voicemailTranscriptionEnabled: false };
return webexJson(
'PUT',
`/telephony/config/locations/${encodeURIComponent(location.id)}/voicemail`,
body,
);
}
export async function updateLocationVoicePortal(location, vpExtension) {
const body = {
name: `VM - ${location.name}`,
languageCode: location.preferredLanguage,
extension: vpExtension,
firstName: 'VM',
lastName: location.name,
passcode: {
newPasscode: VOICE_PORTAL_PASSCODE,
confirmPasscode: VOICE_PORTAL_PASSCODE,
},
};
return webexJson(
'PUT',
`/telephony/config/locations/${encodeURIComponent(location.id)}/voicePortal`,
body,
);
}

46
src/webex/schedules.js Normal file
View file

@ -0,0 +1,46 @@
import { webexJson } from './client.js';
const DAYS = [
{ name: 'Sunday', startDate: '2023-06-04', flag: 'sunday' },
{ name: 'Monday', startDate: '2023-06-05', flag: 'monday' },
{ name: 'Tuesday', startDate: '2023-06-06', flag: 'tuesday' },
{ name: 'Wednesday', startDate: '2023-06-07', flag: 'wednesday' },
{ name: 'Thursday', startDate: '2023-06-01', flag: 'thursday' },
{ name: 'Friday', startDate: '2023-06-02', flag: 'friday' },
{ name: 'Saturday', startDate: '2023-06-03', flag: 'saturday' },
];
function weeklyEvent({ name, startDate, flag }) {
const recurWeekly = {
sunday: false,
monday: false,
tuesday: false,
wednesday: false,
thursday: false,
friday: false,
saturday: false,
[flag]: true,
};
return {
name,
startDate,
endDate: startDate,
startTime: '00:00',
endTime: '23:59',
allDayEnabled: false,
recurrence: { recurForEver: true, recurWeekly },
};
}
export async function createAllHoursSchedule(location) {
const body = {
name: 'All Hours',
type: 'businessHours',
events: DAYS.map(weeklyEvent),
};
return webexJson(
'POST',
`/telephony/config/locations/${encodeURIComponent(location.id)}/schedules`,
body,
);
}

106
src/webex/users.js Normal file
View file

@ -0,0 +1,106 @@
import { webexJson, webexListAll } from './client.js';
export async function findWebexUser(email) {
const url = `/people?email=${encodeURIComponent(email)}&callingData=true`;
const data = await webexJson('GET', url);
if (!data?.items || data.items.length !== 1) {
throw new Error(`Found ${data?.items?.length ?? 0} users for ${email}, expected 1.`);
}
return data.items[0];
}
export async function findWebexUserPhones(userId) {
return webexJson('GET', `/devices?personId=${encodeURIComponent(userId)}`);
}
export async function getWebexDeviceDetail(deviceId) {
return webexJson('GET', `/devices/${encodeURIComponent(deviceId)}`);
}
export async function getAllUsers() {
return webexListAll('/people?callingData=false&max=1000');
}
export async function getWebexLicenses() {
const data = await webexJson('GET', '/licenses');
return data.items;
}
/**
* Legacy user-update flow used by the display-name fix cron. Preserves the
* exact payload the old code sent.
*/
export async function updateWebexUser(user) {
const body = {
displayName: `${user.firstName} ${user.lastName}`,
licenses: user.licenses,
loginEnabled: true,
};
return webexJson('PUT', `/people/${encodeURIComponent(user.id)}`, body);
}
export async function updateUserExtension(user, extension) {
const body = {
extension,
displayName: user.displayName,
licenses: user.licenses,
};
return webexJson('PUT', `/people/${encodeURIComponent(user.id)}?callingData=true`, body);
}
export async function updateUserCallExperience(user) {
const body = {
ringDevicesForClickToDialCallsEnabled: false,
ringDevicesForGroupPageEnabled: false,
ringDevicesForCallParkEnabled: false,
browserClientEnabled: false,
desktopClientEnabled: false,
tabletClientEnabled: false,
mobileClientEnabled: false,
};
return webexJson('PUT', `/people/${encodeURIComponent(user.id)}/features/applications`, body);
}
export async function updateUserCallerId(user, extension, phoneNumber, options) {
const body = {
types: ['LOCATION_NUMBER', 'CUSTOM'],
selected: 'LOCATION_NUMBER',
extensionNumber: extension,
locationNumber: options.locationFallbackNumber,
tollFreeLocationNumber: false,
firstName: user.firstName,
lastName: user.lastName,
blockInForwardCallsEnabled: false,
externalCallerIdNamePolicy: 'OTHER',
customExternalCallerIdName: options.customExternalCallerIdName,
locationExternalCallerIdName: phoneNumber.replace('+1', ''),
};
return webexJson('PUT', `/people/${encodeURIComponent(user.id)}/features/callerId`, body);
}
export async function updateUserVoicemailSettings(user) {
const body = {
enabled: true,
sendAllCalls: { enabled: false },
sendBusyCalls: { enabled: false, greeting: 'DEFAULT', greetingUploaded: false },
sendUnansweredCalls: {
enabled: false,
greeting: 'DEFAULT',
greetingUploaded: false,
numberOfRings: 3,
systemMaxNumberOfRings: 20,
},
notifications: { enabled: false },
transferToNumber: { enabled: false },
emailCopyOfMessage: { enabled: false },
messageStorage: { mwiEnabled: true, storageType: 'INTERNAL' },
faxMessage: { enabled: false },
voiceMessageForwardingEnabled: true,
};
return webexJson('PUT', `/people/${encodeURIComponent(user.id)}/features/voicemail`, body);
}
export async function setDefaultMeetingSite(userEmail, siteUrl) {
const url = `/meetingPreferences/sites?defaultSite=true&userEmail=${encodeURIComponent(userEmail)}`;
return webexJson('PUT', url, { siteUrl });
}