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:
commit
93b060bc8b
59 changed files with 15368 additions and 0 deletions
44
.dockerignore
Normal file
44
.dockerignore
Normal 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
50
.env.example
Normal 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
39
.gitignore
vendored
Normal 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
6
.prettierignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
greetings/
|
||||
buildingFile.csv
|
||||
locationFile.csv
|
||||
package-lock.json
|
||||
config/wbxTokens.json
|
||||
8
.prettierrc.json
Normal file
8
.prettierrc.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"tabWidth": 4,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all",
|
||||
"semi": true,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
30
Dockerfile
Normal file
30
Dockerfile
Normal 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
197
README.md
Normal 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.
|
||||
16
docker/remote-agent/.env.example
Normal file
16
docker/remote-agent/.env.example
Normal 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
|
||||
54
docker/remote-agent/Dockerfile
Normal file
54
docker/remote-agent/Dockerfile
Normal 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"]
|
||||
152
docker/remote-agent/README.md
Normal file
152
docker/remote-agent/README.md
Normal 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.
|
||||
136
docker/remote-agent/deploy/README.md
Normal file
136
docker/remote-agent/deploy/README.md
Normal 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.
|
||||
26
docker/remote-agent/deploy/docker-compose.yml
Normal file
26
docker/remote-agent/deploy/docker-compose.yml
Normal 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'
|
||||
138
docker/remote-agent/deploy/install.sh
Executable file
138
docker/remote-agent/deploy/install.sh
Executable 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"
|
||||
40
docker/remote-agent/docker-compose.yml
Normal file
40
docker/remote-agent/docker-compose.yml
Normal 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'
|
||||
135
docker/remote-agent/inspect-bundle.sh
Executable file
135
docker/remote-agent/inspect-bundle.sh
Executable 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."
|
||||
19
docker/remote-agent/package.json
Normal file
19
docker/remote-agent/package.json
Normal 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
334
docker/remote-agent/package.sh
Executable 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"
|
||||
129
docker/remote-agent/remoteAgent.js
Normal file
129
docker/remote-agent/remoteAgent.js
Normal 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
30
eslint.config.js
Normal 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
BIN
greetings/AEGreeting.wav
Normal file
Binary file not shown.
BIN
greetings/AerieGreeting.wav
Normal file
BIN
greetings/AerieGreeting.wav
Normal file
Binary file not shown.
BIN
greetings/OfflineGreeting.wav
Normal file
BIN
greetings/OfflineGreeting.wav
Normal file
Binary file not shown.
BIN
greetings/UnsubscribedGreeting.wav
Normal file
BIN
greetings/UnsubscribedGreeting.wav
Normal file
Binary file not shown.
11319
package-lock.json
generated
Normal file
11319
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
40
package.json
Normal file
40
package.json
Normal 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
36
scripts/fixStorePhones.js
Normal 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
92
scripts/generate911Csv.js
Normal 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);
|
||||
});
|
||||
66
src/cards/storeInfoCard.js
Normal file
66
src/cards/storeInfoCard.js
Normal 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
185
src/cards/userInfoCard.js
Normal 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;
|
||||
}
|
||||
63
src/commands/attachmentActions.js
Normal file
63
src/commands/attachmentActions.js
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
29
src/commands/buildStore.js
Normal file
29
src/commands/buildStore.js
Normal 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
36
src/commands/helpers.js
Normal 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;
|
||||
}
|
||||
33
src/commands/migrateStore.js
Normal file
33
src/commands/migrateStore.js
Normal 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.',
|
||||
);
|
||||
}
|
||||
30
src/commands/stageStore.js
Normal file
30
src/commands/stageStore.js
Normal 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
25
src/commands/storeInfo.js
Normal 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
25
src/commands/userInfo.js
Normal 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
86
src/config.js
Normal 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
115
src/constants.js
Normal 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
70
src/flows/buildStore.js
Normal 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;
|
||||
}
|
||||
13
src/flows/greetingSelector.js
Normal file
13
src/flows/greetingSelector.js
Normal 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
75
src/flows/migrateStore.js
Normal 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
57
src/flows/stageStore.js
Normal 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
21
src/flows/stepRunner.js
Normal 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
67
src/http.js
Normal 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
95
src/index.js
Normal 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'));
|
||||
52
src/integrations/google.js
Normal file
52
src/integrations/google.js
Normal 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}` +
|
||||
`×tamp=${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
120
src/integrations/siw.js
Normal 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;
|
||||
}
|
||||
18
src/integrations/twilio.js
Normal file
18
src/integrations/twilio.js
Normal 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
25
src/logger.js
Normal 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
218
src/services/websocket.js
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
38
src/webex/announcements.js
Normal file
38
src/webex/announcements.js
Normal 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
93
src/webex/auth.js
Normal 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;
|
||||
}
|
||||
50
src/webex/autoAttendants.js
Normal file
50
src/webex/autoAttendants.js
Normal 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
71
src/webex/client.js
Normal 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
168
src/webex/devices.js
Normal 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
38
src/webex/licensing.js
Normal 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
164
src/webex/locations.js
Normal 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
46
src/webex/schedules.js
Normal 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
106
src/webex/users.js
Normal 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 });
|
||||
}
|
||||
Loading…
Reference in a new issue