const { getStoreDetail } = require('../integrations/storeDetail'); const { parseStoreNumber } = require('../utils/validate'); const { isAgentConnected } = require('../services/websocket'); const { STORE_MODES } = require('../constants'); const { chunkReport } = require('../utils/chunkReport'); const logger = require('../utils/logger'); const REMOTE_AGENT_WARNING = '⚠️ **Remote agent is not connected.** SIW data (store info, registers, ' + 'printers, payment terminals) will be unavailable. Meraki and MDM sections ' + 'will still appear. Start `remoteAgent.js` on the internal host to restore ' + 'full data.'; /** * Map of `st` subcommand keyword → mode. Order matters only for documentation. * `iphone` is an alias for `ios` (carried over from the old `store` command). */ const MODE_KEYWORDS = Object.freeze({ network: STORE_MODES.NETWORK, pos: STORE_MODES.POS, ios: STORE_MODES.IOS, iphone: STORE_MODES.IOS, phone: STORE_MODES.PHONE, av: STORE_MODES.AV, }); const MODE_LABELS = Object.freeze({ [STORE_MODES.INFO]: 'Info', [STORE_MODES.NETWORK]: 'Network', [STORE_MODES.POS]: 'POS', [STORE_MODES.IOS]: 'iOS', [STORE_MODES.PHONE]: 'Phone', [STORE_MODES.AV]: 'AV', }); /** * Get the user-facing command text from a webex-node-bot-framework trigger, * with the leading "@BotName " stripped in group spaces. The framework * exposes this via `trigger.command + trigger.prompt` after a regex match; * we fall back to the raw message text when those aren't populated (e.g. * direct callers in tests). */ function getCommandText(trigger) { const command = trigger?.command ?? ''; const prompt = trigger?.prompt ?? ''; if (command || prompt) return `${command}${prompt}`; return trigger?.message?.text || ''; } /** * Parse a `st [subcommand]` message. Returns the store number and * the chosen mode. If no number is found, mode is null. */ function parseStoreCommand(text) { const storeNumber = parseStoreNumber(text); if (!storeNumber) return { storeNumber: null, mode: null }; const lower = ` ${text.toLowerCase()} `; let mode = STORE_MODES.INFO; for (const [keyword, value] of Object.entries(MODE_KEYWORDS)) { // Match keyword as a whole word so "phone" doesn't trip on "phones". if (new RegExp(`\\b${keyword}\\b`).test(lower)) { mode = value; break; } } return { storeNumber, mode }; } function usageMessage() { return [ '**Usage:** `st [number] [subcommand]`', '', '**Example:** `st 305`', '', '**Subcommands:**', '- `st 305` — store info (location, brand, status, environment)', '- `st 305 network` — switches, APs, store server(s)', '- `st 305 pos` — POS devices (registers, mobile registers, customer displays, printers, payment terminals)', '- `st 305 ios` — iOS devices (iPhones)', '- `st 305 phone` — wired (78xx) + DECT bases & handsets via Webex', '- `st 305 av` — A/V hardware (Atlas AMPs + MDM-tracked Apple TVs, video walls, music, LED)', '', 'Type `help` for the full reference.', ].join('\n'); } async function handleStoreCommand(bot, trigger) { const { storeNumber, mode } = parseStoreCommand(getCommandText(trigger)); if (!storeNumber) { return bot.say('markdown', usageMessage()); } const modeLabel = MODE_LABELS[mode] || mode; bot.say('markdown', `🔍 Analyzing Store **${storeNumber}** (${modeLabel})...`); // SIW depends on the remote agent. INFO and POS both need SIW data; warn up // front if the agent is down. NETWORK / IOS / PHONE / AV don't use SIW so // they're unaffected. const modesThatNeedSiw = new Set([STORE_MODES.INFO, STORE_MODES.POS]); if (modesThatNeedSiw.has(mode) && !isAgentConnected()) { logger.warn('st command running without remote agent', { storeNumber, mode }); await bot.say('markdown', REMOTE_AGENT_WARNING); } try { const report = await getStoreDetail(storeNumber, mode); const chunks = chunkReport(report); if (chunks.length === 0) { await bot.say('markdown', '_No data to display for this view._'); return; } for (let i = 0; i < chunks.length; i++) { await bot.say('markdown', chunks[i]); // Small gap between multi-chunk replies so Webex keeps them in order // visually. Single-chunk replies (the common case) have no delay. if (i < chunks.length - 1) await new Promise(r => setTimeout(r, 300)); } } catch (err) { logger.error('Store analysis error', { storeNumber, mode, error: err.message }); bot.say('markdown', `❌ Error analyzing store **${storeNumber}**:\n${err.message}`); } } async function handleHelpCommand(bot, trigger) { const text = getCommandText(trigger).toLowerCase().trim(); let response; if (/\bst\b|store/.test(text) && text !== 'help') { response = [ '**`st` — Store Commands**', '', '- `st [number]` — store info (location, brand, status, environment)', '- `st [number] network` — switches, APs, store server(s)', '- `st [number] pos` — POS devices (registers, mobile registers, customer displays, printers, payment terminals)', '- `st [number] ios` — iOS devices (mainly iPhones; alias: `iphone`)', '- `st [number] phone` — wired (78xx) and DECT bases + handsets via Webex Service App', '- `st [number] av` — A/V hardware: Atlas AMPs + MDM-tracked Apple TVs, video walls, music, LED displays', '', '**Tip:** type `st` without a number for a quick usage example.', ].join('\n'); } else { response = [ '**StoreHealthAnalyzer — Help**', '', '**Store Commands**', '- `st [number]` — store info (location, brand, status, environment)', '- `st [number] network` — switches, APs, store server(s)', '- `st [number] pos` — POS devices (registers, mobile registers, customer displays, printers, payment terminals)', '- `st [number] ios` — iOS devices (mainly iPhones)', '- `st [number] phone` — wired (78xx) + DECT bases & handsets via Webex', '- `st [number] av` — A/V hardware (Atlas AMPs + MDM Apple TVs / VW / MSC / LED)', '', '**Tips**', '- Works in both group spaces and 1:1 chats.', '- Type `st` without a number for usage examples.', '- Use `help st` for command-specific help.', '- Try `st 782` to get started.', ].join('\n'); } bot.say('markdown', response); } module.exports = { handleStoreCommand, handleHelpCommand, parseStoreCommand, getCommandText, MODE_LABELS, };