From 461caa8086af4f97db634de6999cb90f0dd8bec5 Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Mon, 6 Jul 2026 15:56:01 -0400 Subject: [PATCH] Consistency + cleanup pass - Route Twilio Lookup v2 through the on-prem remote agent so all third-party calls (SIW, Google, Twilio) share the same network path and future IP allow-lists / corp proxies don't break it silently - Delete unused GOOGLE_APPLICATION_CREDENTIALS / serviceAccountKeyPath wiring in config.js, .env.example, and README (never read by any code) - Extract 150 lines of STORE_DEVICE_CUSTOMIZATIONS out of src/webex/devices.js into src/webex/deviceCustomizations.js so device config diffs are self-contained and devices.js stays focused on API - runStep learns a {critical: true} option that re-throws on failure instead of the legacy always-swallow behavior; mark enableLocationForCalling critical in buildStore + stageStore so a failure there aborts the flow cleanly rather than cascading into dozens of downstream 404s Co-authored-by: Cursor --- .env.example | 9 +- README.md | 11 +- src/config.js | 7 -- src/flows/buildStore.js | 7 +- src/flows/stageStore.js | 7 +- src/flows/stepRunner.js | 25 ++++- src/integrations/twilio.js | 19 +++- src/webex/deviceCustomizations.js | 160 ++++++++++++++++++++++++++++++ src/webex/devices.js | 154 +--------------------------- 9 files changed, 212 insertions(+), 187 deletions(-) create mode 100644 src/webex/deviceCustomizations.js diff --git a/.env.example b/.env.example index c4d0ea7..42d79c5 100644 --- a/.env.example +++ b/.env.example @@ -36,15 +36,10 @@ WS_TOKEN=change-me-to-a-long-random-string # --- Google --- # Simple REST API key used by Address Validation + Time Zone endpoints. +# All Google calls are proxied through the on-prem remote agent (see WS_* +# above) so requests originate from the IP subnet the key allow-lists. 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 diff --git a/README.md b/README.md index 41fbdff..7eccdd5 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,9 @@ greetings, build auto-attendants, and clean up user licensing. - 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 and Google APIs from an - IP-whitelisted subnet, to run the remote agent (see +- An on-prem host that can reach Store Info Web, Google APIs, and Twilio + Lookup from an IP-whitelisted subnet, to run the remote agent (see [Remote agent (SIW + Google)](#remote-agent-siw--google) below). -- 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 @@ -67,7 +62,7 @@ Some services can only be reached from inside the corporate network: about. - **Google Maps / Address Validation** — the API key is IP-restricted, so requests direct from the cloud bot IP get `API_KEY_IP_ADDRESS_BLOCKED - (403)`. +(403)`. To bridge the gap, the bot hosts a WebSocket server; a small on-prem agent dials in and proxies HTTP requests back and forth. Both SIW and Google diff --git a/src/config.js b/src/config.js index cc33f45..5961c5e 100644 --- a/src/config.js +++ b/src/config.js @@ -73,13 +73,6 @@ export const config = { 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; - })(), }, }; diff --git a/src/flows/buildStore.js b/src/flows/buildStore.js index ee27173..8688dcd 100644 --- a/src/flows/buildStore.js +++ b/src/flows/buildStore.js @@ -30,8 +30,11 @@ export async function buildStoreLocation(bot, locationInfo, userInfo) { `
Created location ${location.name}.
`, ); - await runStep(bot, 'Enabled location for Webex Calling', () => - enableLocationForCalling(location), + await runStep( + bot, + 'Enabled location for Webex Calling', + () => enableLocationForCalling(location), + { critical: true }, ); await runStep(bot, 'Updated location Webex Calling connection', () => updateLocationRouteGroup(location), diff --git a/src/flows/stageStore.js b/src/flows/stageStore.js index a594771..7b64c70 100644 --- a/src/flows/stageStore.js +++ b/src/flows/stageStore.js @@ -27,8 +27,11 @@ export async function stageStoreLocation(bot, locationInfo, userInfo) { `
Created location ${location.name}.
`, ); - await runStep(bot, 'Enabled location for Webex Calling', () => - enableLocationForCalling(location), + await runStep( + bot, + 'Enabled location for Webex Calling', + () => enableLocationForCalling(location), + { critical: true }, ); await runStep(bot, 'Updated internal dialing', () => updateInternalDialing(location)); await runStep(bot, 'Updated location outgoing permission', () => diff --git a/src/flows/stepRunner.js b/src/flows/stepRunner.js index 2c2177e..fb10178 100644 --- a/src/flows/stepRunner.js +++ b/src/flows/stepRunner.js @@ -2,11 +2,24 @@ 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. + * the step's resolved value. + * + * Options: + * - successMessage: text to show on success (defaults to description) + * - failureMessage: text to show on failure (defaults to "Error: {description}") + * - critical: when true, a failure re-throws so the caller aborts the flow. + * Use for steps that everything downstream depends on (e.g. enabling a + * new location for Webex Calling — every subsequent Calling API call will + * 404 otherwise). Non-critical steps swallow the error and return undefined, + * matching legacy behavior so a single flaky API call doesn't abort the + * entire provision. */ -export async function runStep(bot, description, fn, { successMessage, failureMessage } = {}) { +export async function runStep( + bot, + description, + fn, + { successMessage, failureMessage, critical = false } = {}, +) { try { const result = await fn(); const msg = successMessage ?? description; @@ -14,8 +27,10 @@ export async function runStep(bot, description, fn, { successMessage, failureMes return result; } catch (error) { logger.error(`${description} failed:`, error); - const msg = failureMessage ?? `Error: ${description}`; + const baseMsg = failureMessage ?? `Error: ${description}`; + const msg = critical ? `${baseMsg} — aborting (critical step).` : baseMsg; bot?.say('markdown', `
${msg}
`); + if (critical) throw error; return undefined; } } diff --git a/src/integrations/twilio.js b/src/integrations/twilio.js index 8a25e65..0c84089 100644 --- a/src/integrations/twilio.js +++ b/src/integrations/twilio.js @@ -1,7 +1,15 @@ import { config } from '../config.js'; -import { requestJson } from '../http.js'; +import { proxyRequest } from '../services/websocket.js'; -function twilioHeaders() { +// Twilio's Lookup v2 has no IP allow-list requirement today, but every other +// third-party call in this bot (SIW, Google) is already proxied through the +// on-prem agent. Doing the same for Twilio keeps the network surface +// consistent — if Twilio ever adds IP restrictions, or the bot moves behind +// a corporate egress proxy, this call keeps working without a code change. +// `insecure` is not set here: Twilio uses public CAs that Node trusts, and +// the agent's host reaches lookups.twilio.com without an SSL-inspecting hop. + +function twilioAuthHeaders() { const auth = Buffer.from(`${config.twilio.accountSid}:${config.twilio.authToken}`).toString( 'base64', ); @@ -14,5 +22,10 @@ function twilioHeaders() { */ export async function validatePhoneNumber(phoneNumber) { const url = `https://lookups.twilio.com/v2/PhoneNumbers/${encodeURIComponent(phoneNumber)}`; - return requestJson('GET', url, undefined, { headers: twilioHeaders() }); + const response = await proxyRequest({ + method: 'GET', + url, + headers: twilioAuthHeaders(), + }); + return response?.data; } diff --git a/src/webex/deviceCustomizations.js b/src/webex/deviceCustomizations.js new file mode 100644 index 0000000..634b55f --- /dev/null +++ b/src/webex/deviceCustomizations.js @@ -0,0 +1,160 @@ +// Per-device-family calling customizations applied to every store location +// as part of buildStore / stageStore. Sent verbatim to +// POST /telephony/config/jobs/devices/callDeviceSettings under `customizations`. +// +// This is intentionally a data-only module so device config changes are a +// clean, self-contained diff. Anything that changes here changes what gets +// pushed to phones store-wide, so review carefully. + +export 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 }, + }, +}; diff --git a/src/webex/devices.js b/src/webex/devices.js index d3350db..7bf3cc1 100644 --- a/src/webex/devices.js +++ b/src/webex/devices.js @@ -1,157 +1,5 @@ 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 }, - }, -}; +import { STORE_DEVICE_CUSTOMIZATIONS } from './deviceCustomizations.js'; export async function scheduleStoreDeviceSettings(location) { const body = {