@velaro/cli 1.4.105 → 1.4.107
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/velaro.js +55 -6
- package/lib/commands/ai-config.js +1 -1
- package/lib/commands/billing-automation.js +1 -1
- package/lib/commands/csat-override.js +14 -0
- package/lib/commands/diag-groups.js +108 -0
- package/lib/commands/endpoint-parity.js +58 -0
- package/lib/commands/entitlement.js +27 -1
- package/lib/commands/feature-discovery.js +3 -0
- package/lib/commands/knowledge.js +114 -0
- package/lib/commands/ops-groups.js +188 -0
- package/lib/commands/parity-staff-tools.js +192 -0
- package/lib/commands/skill-usage.js +39 -0
- package/lib/commands/teams-phone.js +15 -3
- package/lib/commands/visitor-monitor.js +61 -0
- package/lib/log-search.js +43 -0
- package/lib/log-search.test.js +1 -1
- package/lib/parity-util.js +27 -0
- package/lib/sign-versions.test.js +1 -1
- package/lib/skill-usage.test.js +36 -0
- package/package.json +1 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// MCP parity command groups: ai-followups, business-action, call, conversation, conversion, email, ivr.
|
|
2
|
+
// Each subcommand calls the same REST route as the MCP tool of the same name (mcp/server.js).
|
|
3
|
+
import { get, post, put, request, messagingGet, messagingPost, messagingPut } from '../api.js';
|
|
4
|
+
import { show, qs, needYes, group } from '../parity-util.js';
|
|
5
|
+
|
|
6
|
+
const exclusiveEnd = (d) => { const x = new Date(`${d}T00:00:00Z`); x.setUTCDate(x.getUTCDate() + 1); return x.toISOString().slice(0, 10); };
|
|
7
|
+
const rangeQs = (a, extra = {}) => qs({ start: a.start, end: a.end ? exclusiveEnd(a.end) : undefined, ...extra });
|
|
8
|
+
const rangeOpts = (y) => y.option('start', { type: 'string', describe: 'yyyy-MM-dd' }).option('end', { type: 'string', describe: 'yyyy-MM-dd (inclusive)' });
|
|
9
|
+
|
|
10
|
+
export const aiFollowupsCommand = { command: 'ai-followups', ...group('AI follow-ups site setting', [
|
|
11
|
+
['get', 'Show whether AI follow-ups are enabled for this site', null,
|
|
12
|
+
async () => { const r = await messagingGet('/SiteSettings/AiFollowUps'); show({ enabled: r?.enabled ?? null }); }],
|
|
13
|
+
['set', 'Enable/disable AI follow-ups (omit --enabled to clear back to the default)',
|
|
14
|
+
(y) => y.option('enabled', { type: 'boolean' }),
|
|
15
|
+
async (a) => {
|
|
16
|
+
await messagingPut('/SiteSettings/AiFollowUps', { enabled: typeof a.enabled === 'boolean' ? a.enabled : null });
|
|
17
|
+
const r = await messagingGet('/SiteSettings/AiFollowUps');
|
|
18
|
+
show({ enabled: r?.enabled ?? null });
|
|
19
|
+
}],
|
|
20
|
+
]) };
|
|
21
|
+
|
|
22
|
+
export const businessActionCommand = { command: 'business-action', ...group('Business actions (list, route free text, execute)', [
|
|
23
|
+
['list', 'List available business actions', null, async () => show(await messagingGet('/BusinessActions'))],
|
|
24
|
+
['route', 'Route free text to the best matching business actions',
|
|
25
|
+
(y) => y.option('text', { type: 'string', demandOption: true }).option('max', { type: 'number' }),
|
|
26
|
+
async (a) => show(await messagingPost('/BusinessActions/Route', { text: a.text, max: a.max }))],
|
|
27
|
+
['execute <name>', 'Execute a business action (needs --yes)',
|
|
28
|
+
(y) => y.positional('name', { type: 'string' })
|
|
29
|
+
.option('request', { type: 'string', demandOption: true, describe: 'Request body as a JSON object' })
|
|
30
|
+
.option('idempotency-key', { type: 'string' })
|
|
31
|
+
.option('conversation-id', { type: 'string' })
|
|
32
|
+
.option('yes', { type: 'boolean' }),
|
|
33
|
+
async (a) => {
|
|
34
|
+
needYes(a, `execute business action ${a.name}`);
|
|
35
|
+
const body = JSON.parse(a.request);
|
|
36
|
+
if (!body || typeof body !== 'object') throw new Error('--request must be a JSON object.');
|
|
37
|
+
show(await messagingPost(`/BusinessActions/${encodeURIComponent(a.name)}/Execute${qs({ idempotencyKey: a['idempotency-key'], conversationId: a['conversation-id'] })}`, body));
|
|
38
|
+
}],
|
|
39
|
+
]) };
|
|
40
|
+
|
|
41
|
+
export const callCommand = { command: 'call', ...group('Call center analytics and call activity', [
|
|
42
|
+
['center-report', 'Call center report: volume, speed, abandon, bot', rangeOpts,
|
|
43
|
+
async (a) => show(await get(`/CallCenterAnalytics/Report${rangeQs(a)}`))],
|
|
44
|
+
['team-breakdown', 'Per-team call center breakdown (Advanced Analytics add-on)',
|
|
45
|
+
(y) => rangeOpts(y).option('sla-threshold-seconds', { type: 'number' }),
|
|
46
|
+
async (a) => show(await get(`/CallCenterAnalytics/TeamBreakdown${rangeQs(a, { slaThresholdSeconds: a['sla-threshold-seconds'] })}`))],
|
|
47
|
+
['channel-breakdown', 'Per-channel call center breakdown (Advanced Analytics add-on)',
|
|
48
|
+
(y) => rangeOpts(y).option('sla-threshold-seconds', { type: 'number' }),
|
|
49
|
+
async (a) => show(await get(`/CallCenterAnalytics/ChannelBreakdown${rangeQs(a, { slaThresholdSeconds: a['sla-threshold-seconds'] })}`))],
|
|
50
|
+
['activity', 'Call activity history (voice call records plus auto-dialer campaign calls)',
|
|
51
|
+
(y) => y.option('agent-id', { type: 'number' }).option('direction', { type: 'string' }).option('origin', { type: 'string' })
|
|
52
|
+
.option('provider-slug', { type: 'string' }).option('disposition', { type: 'string' })
|
|
53
|
+
.option('days', { type: 'number', default: 30 }).option('page', { type: 'number', default: 1 }).option('page-size', { type: 'number', default: 50 }),
|
|
54
|
+
async (a) => {
|
|
55
|
+
const q = qs({ agentId: a['agent-id'], direction: a.direction, origin: a.origin, providerSlug: a['provider-slug'], dateRangeDays: a.days, disposition: a.disposition, page: a.page, pageSize: a['page-size'] });
|
|
56
|
+
let items = [];
|
|
57
|
+
let failures = 0;
|
|
58
|
+
try { const r = await messagingGet(`/Voice/CallRecords/history${q}`); items = items.concat(Array.isArray(r?.items) ? r.items : []); } catch { failures++; }
|
|
59
|
+
if (!a.origin || a.origin === 'AutoDialerCampaign') {
|
|
60
|
+
try { const r = await get(`/CallActivity/CampaignCalls${q}`); items = items.concat(Array.isArray(r?.items) ? r.items : []); } catch { failures++; }
|
|
61
|
+
} else failures += 0;
|
|
62
|
+
if (failures >= 2) throw new Error('Failed to load call activity from either source.');
|
|
63
|
+
items.sort((x, y) => new Date(y.startedAt).getTime() - new Date(x.startedAt).getTime());
|
|
64
|
+
show(items);
|
|
65
|
+
}],
|
|
66
|
+
]) };
|
|
67
|
+
|
|
68
|
+
// conversation_* run through the messaging admin-tools gateway (same JSON-RPC the MCP server uses).
|
|
69
|
+
async function adminTool(name, args) {
|
|
70
|
+
const r = await messagingPost('/mcp/v1/admin-tools/call', { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } });
|
|
71
|
+
if (r?.error) throw new Error(r.error.message || JSON.stringify(r.error));
|
|
72
|
+
const text = r?.result?.content?.[0]?.text;
|
|
73
|
+
if (text === undefined) throw new Error('Empty response from admin-tools gateway');
|
|
74
|
+
return text;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const conversationCommand = { command: 'conversation', ...group('Conversations (list, get, search)', [
|
|
78
|
+
['list', 'List recent conversations',
|
|
79
|
+
(y) => y.option('limit', { type: 'number' }).option('channel', { type: 'string' }).option('status', { type: 'string', describe: 'open or closed' }).option('agent-id', { type: 'number' }),
|
|
80
|
+
async (a) => show(await adminTool('conversation_list', { limit: a.limit, channel: a.channel, status: a.status, agentId: a['agent-id'] }))],
|
|
81
|
+
['get <id>', 'Get one conversation', (y) => y.positional('id', { type: 'number' }),
|
|
82
|
+
async (a) => show(await adminTool('conversation_get', { id: a.id }))],
|
|
83
|
+
['search <q>', 'Search conversations by phone number or call SID',
|
|
84
|
+
(y) => y.positional('q', { type: 'string' }).option('limit', { type: 'number' }),
|
|
85
|
+
async (a) => show(await adminTool('conversation_search', { q: a.q, limit: a.limit }))],
|
|
86
|
+
]) };
|
|
87
|
+
|
|
88
|
+
export const conversionCommand = { command: 'conversion', ...group('Conversion goals and attribution report', [
|
|
89
|
+
['list-goals', 'List conversion goals', null, async () => show(await get('/Conversions/goals'))],
|
|
90
|
+
['create-goal', 'Create a conversion goal',
|
|
91
|
+
(y) => y.option('name', { type: 'string', demandOption: true })
|
|
92
|
+
.option('trigger-type', { type: 'string', demandOption: true })
|
|
93
|
+
.option('trigger-url', { type: 'string' }).option('event-name', { type: 'string' })
|
|
94
|
+
.option('default-amount', { type: 'number', default: 0 })
|
|
95
|
+
.option('revenue-variable-name', { type: 'string' })
|
|
96
|
+
.option('allow-once', { type: 'boolean', default: false })
|
|
97
|
+
.option('attribution-window-hours', { type: 'number', default: 24 }),
|
|
98
|
+
async (a) => show(await post('/Conversions/goals', {
|
|
99
|
+
name: a.name, triggerType: a['trigger-type'], triggerUrl: a['trigger-url'] || null, eventName: a['event-name'] || null,
|
|
100
|
+
defaultAmount: a['default-amount'] ?? 0, revenueVariableName: a['revenue-variable-name'] || null,
|
|
101
|
+
allowOnce: a['allow-once'] ?? false, attributionWindowHours: a['attribution-window-hours'] ?? 24, isEnabled: true,
|
|
102
|
+
}))],
|
|
103
|
+
['report', 'Conversion attribution report', (y) => y.option('days', { type: 'number', default: 30 }),
|
|
104
|
+
async (a) => show(await get(`/Conversions/report?days=${a.days ?? 30}`))],
|
|
105
|
+
]) };
|
|
106
|
+
|
|
107
|
+
export const emailCommand = { command: 'email', ...group('Velaro staff: email delivery queue and stats', [
|
|
108
|
+
['queue-status', 'Email delivery queue status', (y) => y.option('site-id', { type: 'number' }),
|
|
109
|
+
async (a) => show(await messagingGet(`/SuperAdmin/email-delivery/status${qs({ siteId: a['site-id'] })}`))],
|
|
110
|
+
['delivery-stats', 'Email delivery stats', (y) => y.option('site-id', { type: 'number' }).option('days', { type: 'number' }).option('hours', { type: 'number' }),
|
|
111
|
+
async (a) => show(await messagingGet(`/SuperAdmin/email-delivery/stats${qs({ siteId: a['site-id'], days: a.days, hours: a.hours })}`))],
|
|
112
|
+
['queue-replay', 'Replay stuck emails (dry run unless --apply --yes)',
|
|
113
|
+
(y) => y.option('apply', { type: 'boolean', describe: 'Actually replay (default is a dry run)' }).option('yes', { type: 'boolean' }),
|
|
114
|
+
async (a) => {
|
|
115
|
+
if (a.apply) needYes(a, 'replay the email queue');
|
|
116
|
+
show(await messagingPost(`/SuperAdmin/email-delivery/replay?dryRun=${a.apply ? 'false' : 'true'}`));
|
|
117
|
+
}],
|
|
118
|
+
]) };
|
|
119
|
+
|
|
120
|
+
export const ivrCommand = { command: 'ivr', ...group('IVR voice configurations, phone lines and business hours', [
|
|
121
|
+
['list-voice-configs', 'List IVR voice configurations', null, async () => show(await get('/IVRVoiceConfigurations'))],
|
|
122
|
+
['get-voice-config <id>', 'Show one voice configuration', (y) => y.positional('id', { type: 'number' }),
|
|
123
|
+
async (a) => show(await get(`/IVRVoiceConfigurations/${a.id}`))],
|
|
124
|
+
['create-voice-config', 'Create a voice configuration with the standard ElevenLabs/Deepgram defaults',
|
|
125
|
+
(y) => y.option('name', { type: 'string', demandOption: true })
|
|
126
|
+
.option('voice-id', { type: 'string' }).option('welcome-greeting', { type: 'string' })
|
|
127
|
+
.option('bridge-number', { type: 'string' }).option('no-agent-fallback', { type: 'string' })
|
|
128
|
+
.option('business-hours-json', { type: 'string', describe: 'ivrBusinessHoursJson, e.g. {"tz":"America/Los_Angeles","days":{"mon":{"start":"09:00","end":"17:00"}}}; omit for 24/7' }),
|
|
129
|
+
async (a) => show(await post('/IVRVoiceConfigurations', {
|
|
130
|
+
name: a.name, voiceProvider: 'ElevenLabs', voiceId: a['voice-id'] ?? 'XB0fDUnXU5powFXDhCwa', model: 'eleven_turbo_v2_5',
|
|
131
|
+
speed: 0.85, stability: 1.0, similarity: 0.90, transcriptionProvider: 'Deepgram', transcriptionLanguage: 'en-US',
|
|
132
|
+
speechModel: 'nova-3-general', interruptible: 'speech', interruptSensitivity: 'medium', transferMode: 'triage',
|
|
133
|
+
escalationBridgeEnabled: true, escalationCallbackEnabled: true, noAgentFallback: a['no-agent-fallback'] ?? 'take_message',
|
|
134
|
+
afterHoursFallback: 'take_message', ivrBusinessHoursJson: a['business-hours-json'] ?? null,
|
|
135
|
+
welcomeGreeting: a['welcome-greeting'] ?? null, alwaysTransferNumber: a['bridge-number'] ?? null,
|
|
136
|
+
}))],
|
|
137
|
+
['update-voice-config <id>', 'Update fields on a voice configuration (only the flags you pass change)',
|
|
138
|
+
(y) => y.positional('id', { type: 'number' })
|
|
139
|
+
.option('idle-check-in-seconds', { type: 'number' }).option('idle-check-in-message', { type: 'string' })
|
|
140
|
+
.option('idle-hangup-seconds', { type: 'number' })
|
|
141
|
+
.option('escalation-bridge-enabled', { type: 'boolean' }).option('escalation-callback-enabled', { type: 'boolean' })
|
|
142
|
+
.option('transfer-no-answer-message', { type: 'string' }).option('welcome-greeting', { type: 'string' })
|
|
143
|
+
.option('always-transfer-number', { type: 'string' }).option('transfer-mode', { type: 'string' })
|
|
144
|
+
.option('no-agent-fallback', { type: 'string' }).option('after-hours-fallback', { type: 'string' })
|
|
145
|
+
.option('patch-json', { type: 'string', describe: 'Extra fields to merge, as a JSON object' }),
|
|
146
|
+
async (a) => {
|
|
147
|
+
const existing = await get(`/IVRVoiceConfigurations/${a.id}`);
|
|
148
|
+
if (!existing) throw new Error(`Voice config ${a.id} not found.`);
|
|
149
|
+
const map = { 'idle-check-in-seconds': 'idleCheckInSeconds', 'idle-check-in-message': 'idleCheckInMessage', 'idle-hangup-seconds': 'idleHangupSeconds',
|
|
150
|
+
'escalation-bridge-enabled': 'escalationBridgeEnabled', 'escalation-callback-enabled': 'escalationCallbackEnabled',
|
|
151
|
+
'transfer-no-answer-message': 'transferNoAnswerMessage', 'welcome-greeting': 'welcomeGreeting', 'always-transfer-number': 'alwaysTransferNumber',
|
|
152
|
+
'transfer-mode': 'transferMode', 'no-agent-fallback': 'noAgentFallback', 'after-hours-fallback': 'afterHoursFallback' };
|
|
153
|
+
const body = { ...existing, ...(a['patch-json'] ? JSON.parse(a['patch-json']) : {}) };
|
|
154
|
+
for (const [flag, key] of Object.entries(map)) if (a[flag] !== undefined) body[key] = a[flag];
|
|
155
|
+
show(await put(`/IVRVoiceConfigurations/${a.id}`, body));
|
|
156
|
+
}],
|
|
157
|
+
['set-business-hours <id>', 'Set (or clear) the business-hours JSON on a voice configuration',
|
|
158
|
+
(y) => y.positional('id', { type: 'number' })
|
|
159
|
+
.option('hours-json', { type: 'string', describe: 'ivrBusinessHoursJson string, or 24/7 to clear' }),
|
|
160
|
+
async (a) => {
|
|
161
|
+
const existing = await get(`/IVRVoiceConfigurations/${a.id}`);
|
|
162
|
+
if (!existing) throw new Error(`Voice config ${a.id} not found.`);
|
|
163
|
+
const h = a['hours-json'];
|
|
164
|
+
if (h === undefined) throw new Error('--hours-json is required (use 24/7 to clear).');
|
|
165
|
+
if (!['24/7', 'always', 'none', 'all'].includes(h.trim().toLowerCase())) JSON.parse(h);
|
|
166
|
+
const clear = ['24/7', 'always', 'none', 'all'].includes(h.trim().toLowerCase());
|
|
167
|
+
show(await put(`/IVRVoiceConfigurations/${a.id}`, { ...existing, ivrBusinessHoursJson: clear ? null : h }));
|
|
168
|
+
}],
|
|
169
|
+
['agent-availability', 'Agent availability plus each voice config fallback actions', null, async () => {
|
|
170
|
+
const snap = await get('/LiveDashboard/snapshot');
|
|
171
|
+
const cfgs = await get('/IVRVoiceConfigurations');
|
|
172
|
+
show({
|
|
173
|
+
agentsOnline: snap?.agentsOnline ?? 0, agentsAvailable: snap?.agentsAvailable ?? snap?.agentsOnline ?? 0, agentsTotal: snap?.agentsTotal ?? 0,
|
|
174
|
+
fallbacks: (Array.isArray(cfgs) ? cfgs : []).map((c) => ({ id: c.id, name: c.name, noAgentFallback: c.noAgentFallback ?? 'take_message', afterHoursFallback: c.afterHoursFallback ?? 'take_message' })),
|
|
175
|
+
});
|
|
176
|
+
}],
|
|
177
|
+
['list-lines', 'List IVR phone lines', null, async () => show(await get('/IVRVoiceConfigurations/lines'))],
|
|
178
|
+
['update-line <id>', 'Set a phone line bridge number and/or voice configuration',
|
|
179
|
+
(y) => y.positional('id', { type: 'number' })
|
|
180
|
+
.option('default-transfer-number', { type: 'string' }).option('voice-config-id', { type: 'number' }),
|
|
181
|
+
async (a) => {
|
|
182
|
+
const body = {};
|
|
183
|
+
if (a['default-transfer-number'] !== undefined) body.defaultTransferNumber = a['default-transfer-number'];
|
|
184
|
+
if (a['voice-config-id'] !== undefined) body.ivrVoiceConfigurationId = a['voice-config-id'];
|
|
185
|
+
if (!Object.keys(body).length) throw new Error('Provide --default-transfer-number and/or --voice-config-id.');
|
|
186
|
+
show(await request('PATCH', `/IVRVoiceConfigurations/lines/${a.id}`, body));
|
|
187
|
+
}],
|
|
188
|
+
]) };
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// CLI parity for MCP tool groups that previously had no CLI command group (parity-close 2026-10-23).
|
|
2
|
+
// Every subcommand calls the SAME REST route the matching MCP tool handler calls (mcp/server.js,
|
|
3
|
+
// mcp/staff-server.js). Admin-API routes go through request(); messaging-API routes through
|
|
4
|
+
// messagingRequest(). Staff-only tools are enforced server-side (the CLI only passes your token).
|
|
5
|
+
// Impactful subcommands need --yes. Naming: `velaro <group> <tool-name-without-group-prefix>`.
|
|
6
|
+
import { request, messagingRequest } from '../api.js';
|
|
7
|
+
import { supportCommand } from './support.js';
|
|
8
|
+
import { pricingCommand } from './pricing.js';
|
|
9
|
+
import { scraperCommand } from './scraper.js';
|
|
10
|
+
import { tourPolicyCommand } from './tour-policy.js';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
const out = (r) => console.log(typeof r === 'string' ? r : JSON.stringify(r, null, 2));
|
|
15
|
+
const S = (describe, req) => ({ type: 'string', describe, ...(req ? { demandOption: true } : {}) });
|
|
16
|
+
const N = (describe, req) => ({ type: 'number', describe, ...(req ? { demandOption: true } : {}) });
|
|
17
|
+
const B = (describe) => ({ type: 'boolean', describe });
|
|
18
|
+
const csv = (v) => (v === undefined ? undefined : String(v).split(',').map((x) => x.trim()).filter(Boolean));
|
|
19
|
+
const csvN = (v) => csv(v)?.map(Number);
|
|
20
|
+
const enc = encodeURIComponent;
|
|
21
|
+
// '@file.json' reads a file; otherwise the string is parsed as JSON.
|
|
22
|
+
const json = (v) => (v === undefined ? undefined : JSON.parse(String(v).startsWith('@') ? fs.readFileSync(String(v).slice(1), 'utf8') : v));
|
|
23
|
+
const qs = (o) => {
|
|
24
|
+
const sp = new URLSearchParams();
|
|
25
|
+
for (const [k, v] of Object.entries(o)) if (v !== undefined && v !== null && v !== '') sp.set(k, String(v));
|
|
26
|
+
const s = sp.toString();
|
|
27
|
+
return s ? `?${s}` : '';
|
|
28
|
+
};
|
|
29
|
+
const pick = (a, map) => Object.fromEntries(Object.entries(map).filter(([f]) => a[f] !== undefined).map(([f, k]) => [k, a[f]]));
|
|
30
|
+
const exclusiveEnd = (d) => (/^\d{4}-\d{2}-\d{2}$/.test(d || '') ? new Date(Date.parse(d) + 86400000).toISOString().slice(0, 10) : d);
|
|
31
|
+
|
|
32
|
+
// Messaging admin-tools gateway (same endpoint the MCP uses for ms_bookings_*/outlook_calendar_* range/cancel tools).
|
|
33
|
+
async function gatewayTool(name, args) {
|
|
34
|
+
const r = await messagingRequest('POST', '/mcp/v1/admin-tools/call', { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } });
|
|
35
|
+
if (r?.error) throw new Error(`${name}: ${r.error.message || JSON.stringify(r.error)}`);
|
|
36
|
+
return r?.result?.content?.[0]?.text ?? r;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function call(s, a) {
|
|
40
|
+
const fn = s.api === 'msg' ? messagingRequest : request;
|
|
41
|
+
return fn(s.method || 'GET', s.path(a), s.body ? s.body(a) : undefined);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function addRoutes(y, specs) {
|
|
45
|
+
for (const s of specs) {
|
|
46
|
+
y.command(s.name, s.desc, (yy) => {
|
|
47
|
+
for (const [k, v] of Object.entries(s.opts || {})) yy.option(k, v);
|
|
48
|
+
if (s.yes || s.needsYes) yy.option('yes', B('Confirm this impactful action'));
|
|
49
|
+
return yy;
|
|
50
|
+
}, async (a) => {
|
|
51
|
+
try {
|
|
52
|
+
if ((s.yes || (s.needsYes && s.needsYes(a))) && !a.yes) { console.error(`Refusing without --yes: ${s.desc}`); process.exit(1); }
|
|
53
|
+
out(s.run ? await s.run(a) : await call(s, a));
|
|
54
|
+
} catch (e) { console.error(e.message); process.exit(1); }
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return y;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const site = { site: N('Site id', true) };
|
|
61
|
+
const SP = (a) => `SupportTools/sites/${a.site}`;
|
|
62
|
+
const rpcOpts = { args: S('Extra tool arguments as JSON or @file.json') };
|
|
63
|
+
const rpcArgs = (a, map) => ({ ...pick(a, map), ...(json(a.args) || {}) });
|
|
64
|
+
|
|
65
|
+
export const SUPPORT_EXTRA = [
|
|
66
|
+
{ name: 'user-lookup', desc: 'Search users by email/name across sites (staff)', opts: { query: S('Search text', true) }, path: (a) => `SupportTools/users/search?q=${enc(a.query)}` },
|
|
67
|
+
{ name: 'site-lookup', desc: 'Search sites by name/URL/admin (staff)', opts: { query: S('Search text', true) }, path: (a) => `SupportTools/sites/search?q=${enc(a.query)}` },
|
|
68
|
+
{ name: 'change-username', desc: 'Change a user login email (staff)', yes: true, method: 'POST', opts: { 'user-id': N('User id', true), 'new-email': S('New email', true) }, path: (a) => `SupportTools/users/${a.userId}/change-username`, body: (a) => ({ newEmail: a.newEmail }) },
|
|
69
|
+
{ name: 'list-admin-users', desc: 'List Velaro staff/admin users', path: () => 'SupportTools/admin-users' },
|
|
70
|
+
{ name: 'list-staff', desc: 'List Velaro staff with roles', path: () => 'SupportTools/admin-users' },
|
|
71
|
+
{ name: 'add-admin-user', desc: 'Grant staff admin access to an email', yes: true, method: 'POST', opts: { email: S('Email', true) }, path: () => 'SupportTools/admin-users', body: (a) => ({ email: a.email }) },
|
|
72
|
+
{ name: 'remove-admin-user', desc: 'Remove staff admin access for a user id', yes: true, method: 'DELETE', opts: { 'user-id': N('User id', true) }, path: (a) => `SupportTools/admin-users/${a.userId}` },
|
|
73
|
+
{ name: 'set-staff-role', desc: 'Set a staff user role', yes: true, method: 'PATCH', opts: { 'user-id': N('User id', true), role: S('Role', true) }, path: (a) => `SupportTools/admin-users/${a.userId}/role`, body: (a) => ({ role: a.role }) },
|
|
74
|
+
{ name: 'integration-logs', desc: 'Integration log entries for a site', opts: { ...site, page: N('Page'), 'page-size': N('Page size (max 100)'), integration: S('Integration'), severity: S('Severity') }, path: (a) => `${SP(a)}/integration-logs${qs({ page: a.page ?? 1, pageSize: Math.min(100, a.pageSize ?? 50), integration: a.integration, severity: a.severity && a.severity !== 'All' ? a.severity : undefined })}` },
|
|
75
|
+
{ name: 'site-health', desc: 'Site health: subscription, agents, last login, 7-day errors', opts: site, path: (a) => `${SP(a)}/health` },
|
|
76
|
+
{ name: 'site-agents', desc: 'Agents on a site', opts: site, path: (a) => `${SP(a)}/agents` },
|
|
77
|
+
{ name: 'site-activity', desc: 'Activity log for a site', opts: { ...site, page: N('Page'), 'page-size': N('Page size (max 200)'), category: S('Category') }, path: (a) => `${SP(a)}/activity${qs({ page: a.page ?? 1, pageSize: Math.min(200, a.pageSize ?? 50), category: a.category })}` },
|
|
78
|
+
{ name: 'extend-subscription', desc: 'Extend a site subscription by N days', yes: true, method: 'POST', opts: { ...site, days: N('Days', true) }, path: (a) => `${SP(a)}/extend-subscription`, body: (a) => ({ days: a.days }) },
|
|
79
|
+
{ name: 'set-user-active', desc: 'Activate/deactivate a user on a site', yes: true, method: 'POST', opts: { ...site, 'user-id': N('User id', true), active: { type: 'boolean', demandOption: true, describe: 'true or false' } }, path: (a) => `SupportTools/users/${a.userId}/sites/${a.site}/set-active`, body: (a) => ({ isActive: a.active }) },
|
|
80
|
+
{ name: 'bulk-set-flags', desc: 'Set several subscription flags at once (JSON object)', yes: true, method: 'POST', opts: { ...site, flags: S('JSON object or @file.json', true) }, path: (a) => `${SP(a)}/subscription/bulk-flags`, body: (a) => ({ flags: json(a.flags) }) },
|
|
81
|
+
{ name: 'create-agent', desc: 'Create an agent on any site (staff)', api: 'msg', method: 'POST', opts: { ...site, email: S('Email', true), 'first-name': S('First name'), 'last-name': S('Last name'), roles: S('Comma list of roles'), 'team-ids': S('Comma list of team ids'), 'max-chats': N('Max chats (0 = default)') }, path: (a) => `/SupportTools/sites/${a.site}/agents`, body: (a) => ({ email: a.email, firstName: a.firstName, lastName: a.lastName, roles: csv(a.roles), teamIds: csvN(a.teamIds), maxChats: a.maxChats ?? 0 }) },
|
|
82
|
+
{ name: 'schedule-adherence', desc: 'Schedule adherence diagnostics for a site', api: 'msg', opts: { ...site, role: S('role'), 'agent-id': N('Agent id (required when role=agent)'), start: S('Start'), end: S('End'), tz: S('Timezone') }, path: (a) => `/Reports/sites/${a.site}/schedule-adherence-diagnostics${qs({ detail: 'true', role: a.role, agentId: a.agentId, start: a.start, end: a.end, tz: a.tz })}` },
|
|
83
|
+
{ name: 'appointments-list-sites', desc: 'Sites with Velaro Appointments enabled', api: 'msg', path: () => '/Appointments/admin/sites-with-appointments' },
|
|
84
|
+
{ name: 'diagnose-after-hours', desc: 'After-hours routing diagnostic data for a site', opts: site, path: (a) => `${SP(a)}/after-hours-routing` },
|
|
85
|
+
{ name: 'diagnose', desc: 'One-shot site diagnosis: health, recent errors, activity, failed Hangfire jobs', opts: site, run: async (a) => {
|
|
86
|
+
const g = (p) => request('GET', p).catch((e) => ({ error: e.message }));
|
|
87
|
+
const [health, integrationErrors, aiToolErrors, activity, failedJobs] = await Promise.all([
|
|
88
|
+
g(`${SP(a)}/health`), g(`${SP(a)}/integration-logs?severity=Error&pageSize=10`), g(`${SP(a)}/integration-logs?severity=Error&category=AiTool&pageSize=10`), g(`${SP(a)}/activity?pageSize=5`), g('DatabaseTool/FailedJobs')]);
|
|
89
|
+
return { health, integrationErrors, aiToolErrors, activity, failedJobs };
|
|
90
|
+
} },
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const SCRAPER_EXTRA = [
|
|
94
|
+
{ name: 'staff-crawl', desc: 'Trigger a crawl for any site (staff)', method: 'POST', opts: { ...site, urls: S('Comma list of URLs', true), name: S('Job name'), 'crawl-depth': N('Crawl depth (default 1)'), 'max-pages': N('Max pages (default 25)') }, path: (a) => `ScraperJobs/StaffCreate/${a.site}`, body: (a) => ({ name: a.name || `staff-triggered-${a.site}`, urls: csv(a.urls), crawlDepth: a.crawlDepth ?? 1, maxPages: a.maxPages ?? 25 }) },
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
const LIVEFLUENCE = [
|
|
98
|
+
{ name: 'export', desc: 'Export V10 routing rules for a V10 site', opts: { 'v10-site-id': N('V10 site id', true), 'v20-site-id': N('V20 site id'), 'include-non-routing': B('Include non-routing rules') }, path: (a) => `DatabaseTool/V10Export/${a.v10SiteId}${qs({ v20SiteId: a.v20SiteId, includeNonRouting: a.includeNonRouting ? 'true' : undefined })}` },
|
|
99
|
+
{ name: 'create-teams', desc: 'Create V20 teams from V10 groups (use --dry-run first)', method: 'POST', needsYes: (a) => !a.dryRun, opts: { 'v10-site-id': N('V10 site id', true), 'v20-site-id': N('V20 site id', true), 'dry-run': B('Preview only'), 'group-names': S('Comma list of V10 group names') }, path: () => 'DatabaseTool/V10CreateTeams', body: (a) => ({ v10SiteId: a.v10SiteId, v20SiteId: a.v20SiteId, dryRun: !!a.dryRun, groupNames: csv(a.groupNames) ?? [] }) },
|
|
100
|
+
{ name: 'site-workflows-list', desc: 'List a site workflows', opts: { 'site-id': N('Site id', true) }, path: (a) => `DatabaseTool/SiteWorkflows/${a.siteId}` },
|
|
101
|
+
{ name: 'site-workflows-set-enabled', desc: 'Enable/disable workflows by id or name (use --dry-run first)', method: 'POST', needsYes: (a) => !a.dryRun, opts: { 'site-id': N('Site id', true), 'workflow-ids': S('Comma list of ids'), names: S('Comma list of names'), enabled: B('Enable (omit to disable)'), 'dry-run': B('Preview only'), 'force-partial': B('Allow partial match'), 'v10-site-id': N('V10 site id') }, path: () => 'DatabaseTool/SiteWorkflows/SetEnabled', body: (a) => ({ siteId: a.siteId, workflowIds: csvN(a.workflowIds) ?? [], names: csv(a.names) ?? [], enabled: !!a.enabled, dryRun: !!a.dryRun, forcePartial: !!a.forcePartial, v10SiteId: a.v10SiteId ?? null }) },
|
|
102
|
+
{ name: 'site-workflow-create', desc: 'Create a workflow from a nodes blob', method: 'POST', needsYes: (a) => !a.dryRun, opts: { 'site-id': N('Site id', true), name: S('Name', true), 'trigger-type': S('Trigger (default conversation_opened)'), 'nodes-blob': S('Nodes JSON or @file.json', true), enabled: B('Enable on create'), 'dry-run': B('Preview only') }, path: () => 'DatabaseTool/SiteWorkflows/Create', body: (a) => ({ siteId: a.siteId, name: a.name, triggerType: a.triggerType ?? 'conversation_opened', nodesBlob: String(a.nodesBlob).startsWith('@') ? fs.readFileSync(String(a.nodesBlob).slice(1), 'utf8') : a.nodesBlob, enabled: !!a.enabled, dryRun: !!a.dryRun }) },
|
|
103
|
+
{ name: 'site-workflow-set-action', desc: 'Point a workflow action at a target', method: 'POST', needsYes: (a) => !a.dryRun, opts: { 'site-id': N('Site id', true), 'workflow-id': N('Workflow id', true), 'action-type': S('Action type', true), 'target-id': N('Target id', true), 'dry-run': B('Preview only') }, path: () => 'DatabaseTool/SiteWorkflows/SetAction', body: (a) => ({ siteId: a.siteId, workflowId: a.workflowId, actionType: a.actionType, targetId: a.targetId, dryRun: !!a.dryRun }) },
|
|
104
|
+
{ name: 'site-rules-list', desc: 'List a site routing rules', opts: { 'site-id': N('Site id', true) }, path: (a) => `DatabaseTool/SiteRules/${a.siteId}` },
|
|
105
|
+
{ name: 'site-rules-set-enabled', desc: 'Enable/disable routing rules', method: 'POST', needsYes: (a) => !a.dryRun, opts: { 'site-id': N('Site id', true), 'rule-ids': S('Comma list of ids'), trigger: S('Trigger filter'), enabled: B('Enable (omit to disable)'), 'dry-run': B('Preview only') }, path: () => 'DatabaseTool/SiteRules/SetEnabled', body: (a) => ({ siteId: a.siteId, ruleIds: csvN(a.ruleIds) ?? [], trigger: a.trigger ?? null, enabled: !!a.enabled, dryRun: !!a.dryRun }) },
|
|
106
|
+
{ name: 'seed-url-rules', desc: 'Seed URL rules for an AI config', method: 'POST', needsYes: (a) => !a.dryRun, opts: { 'site-id': N('Site id', true), 'ai-config-id': N('AI config id', true), rules: S('Rules JSON or @file.json', true), 'dry-run': B('Preview only') }, path: () => 'DatabaseTool/V10SeedUrlRules', body: (a) => ({ siteId: a.siteId, aiConfigId: a.aiConfigId, dryRun: !!a.dryRun, rules: json(a.rules) }) },
|
|
107
|
+
{ name: 'conversion-advisor', desc: 'Advise on V10 to V20 conversion (apply with --apply --yes)', method: 'POST', needsYes: (a) => !!a.apply, opts: { 'v10-site-id': N('V10 site id'), 'v20-site-id': N('V20 site id', true), apply: B('Apply recommendations'), 'include-non-routing': B('Include non-routing'), intents: S('Intents JSON array') }, path: () => 'DatabaseTool/ConversionAdvisor', body: (a) => { const intents = json(a.intents); return { v10SiteId: intents?.length ? null : (a.v10SiteId ?? null), v20SiteId: a.v20SiteId, apply: !!a.apply, includeNonRouting: !!a.includeNonRouting, intents: intents ?? null }; } },
|
|
108
|
+
{ name: 'migrate', desc: 'Migrate V10 routing to V20 workflows (use --dry-run first)', method: 'POST', needsYes: (a) => !a.dryRun, opts: { 'v10-site-id': N('V10 site id', true), 'v20-site-id': N('V20 site id'), 'dry-run': B('Preview only'), enable: B('Enable created workflows'), 'include-non-routing': B('Include non-routing'), 'route-bot-groups-to-team': B('Route bot groups to team'), 'team-mappings': S('JSON or @file'), 'ai-config-mappings': S('JSON or @file') }, path: () => 'DatabaseTool/V10Migrate', body: (a) => ({ v10SiteId: a.v10SiteId, v20SiteId: a.v20SiteId ?? null, dryRun: !!a.dryRun, enable: !!a.enable, includeNonRouting: !!a.includeNonRouting, routeBotGroupsToTeam: !!a.routeBotGroupsToTeam, teamMappings: json(a.teamMappings) ?? [], aiConfigMappings: json(a.aiConfigMappings) ?? [] }) },
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
const TABLES = {
|
|
112
|
+
missed: [{ name: 'chat-diagnostics', desc: 'Missed chat diagnostics with routing snapshots', opts: { start: S('Start date'), end: S('End date'), reason: S('Reason filter'), 'team-id': N('Team id'), site: N('Site id (staff)') }, path: (a) => `CallCenterAnalytics/MissedChatDiagnostics${qs({ start: a.start, end: a.end, reason: a.reason, teamId: a.teamId, siteId: a.site })}` }],
|
|
113
|
+
service: [{ name: 'level-report', desc: 'Service level (SLA) report', opts: { 'start-date': S('Start'), 'end-date': S('End (inclusive date)'), granularity: S('day|week|month') }, path: (a) => `ServiceLevel${qs({ startDate: a.startDate, endDate: exclusiveEnd(a.endDate), granularity: a.granularity })}` }],
|
|
114
|
+
page: [
|
|
115
|
+
{ name: 'widget-list', desc: 'List page widgets', path: () => 'PageWidgets' },
|
|
116
|
+
{ name: 'widget-get', desc: 'Get a page widget', opts: { id: N('Widget id', true) }, path: (a) => `PageWidgets/${a.id}` },
|
|
117
|
+
{ name: 'widget-toggle', desc: 'Enable or disable a page widget', opts: { id: N('Widget id', true), enabled: { type: 'boolean', demandOption: true, describe: 'true or false' } }, run: async (a) => { const w = await request('GET', `PageWidgets/${a.id}`); w.isEnabled = !!a.enabled; await request('PUT', `PageWidgets/${a.id}`, w); return `Widget ${a.id} ${a.enabled ? 'enabled' : 'disabled'}.`; } },
|
|
118
|
+
],
|
|
119
|
+
uploaded: [
|
|
120
|
+
{ name: 'files-list', desc: 'List uploaded AI training files', api: 'msg', path: () => '/uploadedFiles/list/AI' },
|
|
121
|
+
{ name: 'file-reindex', desc: 'Re-index an uploaded file', api: 'msg', method: 'POST', opts: { 'file-id': N('File id'), 'file-name': S('File name'), 'index-name': S('Index name') }, path: () => '/uploadedFiles/reindex', body: (a) => { if (!a.fileId && !a.fileName) throw new Error('--file-id or --file-name is required'); return pick(a, { fileId: 'id', fileName: 'fileName', indexName: 'indexName' }); } },
|
|
122
|
+
],
|
|
123
|
+
ms: [
|
|
124
|
+
{ name: 'bookings-status', desc: 'Microsoft Bookings status for a site (staff)', opts: site, path: (a) => `MicrosoftBookings/superadmin/status?siteId=${a.site}` },
|
|
125
|
+
{ name: 'bookings-configure', desc: 'Configure Microsoft Bookings for a site (staff)', method: 'PUT', opts: { ...site, 'business-id': S('Business id', true), 'business-display-name': S('Display name'), disabled: B('Disable the integration') }, path: (a) => `MicrosoftBookings/superadmin/config?siteId=${a.site}`, body: (a) => ({ businessId: a.businessId, businessDisplayName: a.businessDisplayName ?? '', isEnabled: !a.disabled }) },
|
|
126
|
+
{ name: 'bookings-check-availability-range', desc: 'Bookings availability range for a service', opts: { 'service-id': S('Service id', true), ...rpcOpts }, run: (a) => gatewayTool('ms_bookings_check_availability_range', rpcArgs(a, { serviceId: 'service_id' })) },
|
|
127
|
+
{ name: 'bookings-cancel-appointment', desc: 'Cancel a Bookings appointment', yes: true, opts: { 'appointment-id': S('Appointment id', true), ...rpcOpts }, run: (a) => gatewayTool('ms_bookings_cancel_appointment', rpcArgs(a, { appointmentId: 'appointment_id' })) },
|
|
128
|
+
{ name: 'bookings-reschedule-appointment', desc: 'Reschedule a Bookings appointment', yes: true, opts: { 'appointment-id': S('Appointment id', true), 'new-date': S('YYYY-MM-DD', true), 'new-start-time': S('HH:mm', true), ...rpcOpts }, run: (a) => gatewayTool('ms_bookings_reschedule_appointment', rpcArgs(a, { appointmentId: 'appointment_id', newDate: 'new_date', newStartTime: 'new_start_time' })) },
|
|
129
|
+
],
|
|
130
|
+
outlook: [
|
|
131
|
+
{ name: 'calendar-status', desc: 'Outlook calendar status for a site (staff)', opts: site, path: (a) => `OutlookCalendar/superadmin/status?siteId=${a.site}` },
|
|
132
|
+
{ name: 'calendar-test-connection', desc: 'Test the Outlook calendar connection (staff)', opts: { ...site, 'organizer-email': S('Organizer email') }, path: (a) => `OutlookCalendar/superadmin/test-connection?siteId=${a.site}${a.organizerEmail ? `&organizerEmail=${enc(a.organizerEmail)}` : ''}` },
|
|
133
|
+
{ name: 'calendar-get-available-range', desc: 'Available calendar time range', opts: rpcOpts, run: (a) => gatewayTool('outlook_calendar_get_available_range', rpcArgs(a, {})) },
|
|
134
|
+
{ name: 'calendar-cancel', desc: 'Cancel an Outlook calendar event', yes: true, opts: { 'event-id': S('Event id', true), ...rpcOpts }, run: (a) => gatewayTool('outlook_calendar_cancel', rpcArgs(a, { eventId: 'event_id' })) },
|
|
135
|
+
],
|
|
136
|
+
oauth: [
|
|
137
|
+
{ name: 'list', desc: 'List stored OAuth app configs (staff)', path: () => 'SupportTools/oauth-configs' },
|
|
138
|
+
{ name: 'update', desc: 'Update an integration OAuth client id/secret (staff)', yes: true, method: 'PUT', opts: { integration: S('Integration key', true), 'client-id': S('Client id'), 'client-secret': S('Client secret') }, path: (a) => `SupportTools/oauth-configs/${enc(a.integration)}`, body: (a) => { if (!a.clientId && !a.clientSecret) throw new Error('Pass --client-id and/or --client-secret'); return { clientId: a.clientId, clientSecret: a.clientSecret }; } },
|
|
139
|
+
],
|
|
140
|
+
provision: [{ name: 'health', desc: 'Provisioning health checks (run after every staging deploy)', api: 'msg', path: () => '/SuperAdmin/provision-health' }],
|
|
141
|
+
sse: [{ name: 'visitor-diagnostic', desc: 'SSE visitor transport diagnostic', api: 'msg', path: () => '/Diagnostics/sse' }],
|
|
142
|
+
skill: [{ name: 'logs-for-site', desc: 'AI skill call logs for a site (staff)', opts: { ...site, take: N('Entries (max 200)'), 'errors-only': B('Errors only') }, path: (a) => `SuperAdmin/Diagnostics/skill-logs?siteId=${a.site}&take=${Math.min(a.take ?? 50, 200)}&errorsOnly=${!!a.errorsOnly}` }],
|
|
143
|
+
v10: [{ name: 'bot-turn-limits', desc: 'v10 sites with a MaximumBotTurnsPerMonth limit (staff)', path: () => 'BotAnalytics/V10BotTurnLimits' }],
|
|
144
|
+
whatsapp: [{ name: 'campaign-diagnostic', desc: 'WhatsApp campaign diagnostic for a site (staff)', api: 'msg', opts: site, path: (a) => `/Diagnostics/whatsapp-campaign/${a.site}` }],
|
|
145
|
+
mobile: [{ name: 'ota-trigger', desc: 'Trigger the mobile OTA build (GitHub Actions mobile-build.yml; needs GITHUB_TOKEN)', yes: true, run: async () => {
|
|
146
|
+
const token = process.env.GITHUB_TOKEN;
|
|
147
|
+
if (!token) throw new Error('GITHUB_TOKEN env var is not set.');
|
|
148
|
+
const r = await fetch('https://api.github.com/repos/velaro/velaro-messaging/actions/workflows/mobile-build.yml/dispatches', { method: 'POST', headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', 'Content-Type': 'application/json' }, body: JSON.stringify({ ref: 'master', inputs: { profile: 'preview' } }) });
|
|
149
|
+
if (r.status !== 204) throw new Error(`GitHub API returned ${r.status}: ${await r.text()}`);
|
|
150
|
+
return 'Mobile OTA triggered on GitHub Actions (mobile-build.yml -> master).';
|
|
151
|
+
} }],
|
|
152
|
+
signalr: [{ name: 'diagnostics', desc: 'Azure SignalR health (needs SIGNALR_INFRA_CONN / SIGNALR_PROD_CONN env)', opts: { instance: { type: 'string', choices: ['infra', 'legacy', 'both'], default: 'both' }, hub: S('Hub name, e.g. MessengerHub') }, run: async (a) => {
|
|
153
|
+
const targets = [['infra', 'velaro-eastus-prd-signalr (infra)', process.env.SIGNALR_INFRA_CONN], ['legacy', 'velaro-prod-us (legacy)', process.env.SIGNALR_PROD_CONN]].filter(([k]) => a.instance === 'both' || a.instance === k);
|
|
154
|
+
const res = {};
|
|
155
|
+
for (const [, label, conn] of targets) {
|
|
156
|
+
const ep = (/Endpoint=([^;]+)/i.exec(conn || '') || [])[1]; const key = (/AccessKey=([^;]+)/i.exec(conn || '') || [])[1];
|
|
157
|
+
if (!ep || !key) { res[label] = 'connection string not set in env'; continue; }
|
|
158
|
+
const endpoint = ep.replace(/\/+$/, '');
|
|
159
|
+
const jwt = (url) => { const h = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); const n = Math.floor(Date.now() / 1000); const p = Buffer.from(JSON.stringify({ aud: url, iat: n, exp: n + 300 })).toString('base64url'); return `${h}.${p}.${crypto.createHmac('sha256', Buffer.from(key, 'base64')).update(`${h}.${p}`).digest('base64url')}`; };
|
|
160
|
+
const get = async (url) => { const r = await fetch(url, { headers: { Authorization: `Bearer ${jwt(url)}` } }); return { status: r.status, body: (await r.text()).slice(0, 2000) }; };
|
|
161
|
+
res[label] = { health: await get(`${endpoint}/api/v1/health`), ...(a.hub ? { hub: await get(`${endpoint}/api/v1/hubs/${a.hub}`) } : {}) };
|
|
162
|
+
}
|
|
163
|
+
return res;
|
|
164
|
+
} }],
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const demand = (y, msg) => y.demandCommand(1, msg);
|
|
168
|
+
|
|
169
|
+
export const livefluenceCommand = {
|
|
170
|
+
command: 'livefluence', describe: 'Velaro staff: V10 (LiveFluence) to V20 migration and per-site workflow/rule tools',
|
|
171
|
+
builder: (y) => demand(addRoutes(y, LIVEFLUENCE), 'Specify a subcommand'), handler: () => {},
|
|
172
|
+
};
|
|
173
|
+
export const missedCommand = { command: 'missed', describe: 'Missed chat diagnostics', builder: (y) => demand(addRoutes(y, TABLES.missed), 'Specify a subcommand'), handler: () => {} };
|
|
174
|
+
export const serviceCommand = { command: 'service', describe: 'Service level (SLA) report', builder: (y) => demand(addRoutes(y, TABLES.service), 'Specify a subcommand'), handler: () => {} };
|
|
175
|
+
export const pageCommand = { command: 'page', describe: 'Page widgets (list, get, toggle)', builder: (y) => demand(addRoutes(y, TABLES.page), 'Specify a subcommand'), handler: () => {} };
|
|
176
|
+
export const uploadedCommand = { command: 'uploaded', describe: 'Uploaded AI training files (list, reindex)', builder: (y) => demand(addRoutes(y, TABLES.uploaded), 'Specify a subcommand'), handler: () => {} };
|
|
177
|
+
export const msCommand = { command: 'ms', describe: 'Microsoft Bookings (status, configure, availability, cancel, reschedule)', builder: (y) => demand(addRoutes(y, TABLES.ms), 'Specify a subcommand'), handler: () => {} };
|
|
178
|
+
export const outlookCommand = { command: 'outlook', describe: 'Outlook calendar (status, test connection, availability, cancel)', builder: (y) => demand(addRoutes(y, TABLES.outlook), 'Specify a subcommand'), handler: () => {} };
|
|
179
|
+
export const oauthCommand = { command: 'oauth', describe: 'Velaro staff: stored OAuth app configs', builder: (y) => demand(addRoutes(y, TABLES.oauth), 'Specify a subcommand'), handler: () => {} };
|
|
180
|
+
export const provisionCommand = { command: 'provision', describe: 'Velaro staff: provisioning health', builder: (y) => demand(addRoutes(y, TABLES.provision), 'Specify a subcommand'), handler: () => {} };
|
|
181
|
+
export const sseCommand = { command: 'sse', describe: 'Velaro staff: SSE visitor transport diagnostic', builder: (y) => demand(addRoutes(y, TABLES.sse), 'Specify a subcommand'), handler: () => {} };
|
|
182
|
+
export const skillCommand = { command: 'skill', describe: 'Velaro staff: AI skill logs for a site', builder: (y) => demand(addRoutes(y, TABLES.skill), 'Specify a subcommand'), handler: () => {} };
|
|
183
|
+
export const v10Command = { command: 'v10', describe: 'Velaro staff: v10 bot turn limits', builder: (y) => demand(addRoutes(y, TABLES.v10), 'Specify a subcommand'), handler: () => {} };
|
|
184
|
+
export const whatsappCommand = { command: 'whatsapp', describe: 'Velaro staff: WhatsApp campaign diagnostic', builder: (y) => demand(addRoutes(y, TABLES.whatsapp), 'Specify a subcommand'), handler: () => {} };
|
|
185
|
+
export const mobileCommand = { command: 'mobile', describe: 'Velaro staff: mobile OTA build trigger', builder: (y) => demand(addRoutes(y, TABLES.mobile), 'Specify a subcommand'), handler: () => {} };
|
|
186
|
+
export const signalrCommand = { command: 'signalr', describe: 'Velaro staff: Azure SignalR diagnostics', builder: (y) => demand(addRoutes(y, TABLES.signalr), 'Specify a subcommand'), handler: () => {} };
|
|
187
|
+
|
|
188
|
+
// Existing inline-registered groups, re-exposed as detectable command objects with the missing subcommands added.
|
|
189
|
+
export const supportGroupCommand = { command: 'support', describe: 'Staff SupportTools actions on a site (superadmin only)', builder: (y) => addRoutes(supportCommand(y), SUPPORT_EXTRA), handler: () => {} };
|
|
190
|
+
export const pricingGroupCommand = { command: 'pricing', describe: 'View and manage the pricing catalog', builder: pricingCommand, handler: () => {} };
|
|
191
|
+
export const scraperGroupCommand = { command: 'scraper', describe: 'Velaro staff: scraper / upload / chunk usage and crawl for any site', builder: (y) => addRoutes(scraperCommand(y), SCRAPER_EXTRA), handler: () => {} };
|
|
192
|
+
export const tourPolicyGroupCommand = { command: 'tour-policy', describe: 'Velaro staff: guided-tour auto-open policy (versioned, rollback-able)', builder: tourPolicyCommand, handler: () => {} };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { messagingGet } from '../api.js';
|
|
2
|
+
import { runCommand } from '../run.js';
|
|
3
|
+
|
|
4
|
+
const usd = (n) => `$${Number(n ?? 0).toFixed(2)}`;
|
|
5
|
+
|
|
6
|
+
// Pure formatter so the output shape is unit-testable without a network call.
|
|
7
|
+
export function formatSkillUsage(r) {
|
|
8
|
+
const lines = [];
|
|
9
|
+
lines.push(`Site ${r.siteId}, ${r.month}${r.isTrial ? ' (trial)' : ''}, overage: ${r.overageBehavior}`);
|
|
10
|
+
lines.push('');
|
|
11
|
+
lines.push('Billable usage (same numbers as the customer Billing page):');
|
|
12
|
+
for (const u of r.usage ?? []) {
|
|
13
|
+
const limit = u.unlimited ? 'unlimited' : u.limit.toLocaleString();
|
|
14
|
+
lines.push(` ${u.name.padEnd(24)} ${String(u.used.toLocaleString()).padStart(9)} / ${limit.padStart(9)} ${u.unlimited ? '' : `${u.percent}%`.padStart(5)} gate: ${u.mode ?? 'n/a'}`);
|
|
15
|
+
}
|
|
16
|
+
lines.push('');
|
|
17
|
+
lines.push('Calls by surface (staff, copilot and composite calls are never billed to the customer):');
|
|
18
|
+
const surfaces = r.callsBySurface ?? [];
|
|
19
|
+
if (!surfaces.length) lines.push(' (none recorded)');
|
|
20
|
+
for (const s of surfaces) lines.push(` ${String(s.surface).padEnd(20)} ${s.calls.toLocaleString()}`);
|
|
21
|
+
lines.push('');
|
|
22
|
+
lines.push('LLM cost this month by key source (BYOK removes this cost only; skill usage above still counts):');
|
|
23
|
+
const llm = r.llmCostThisMonth ?? [];
|
|
24
|
+
if (!llm.length) lines.push(' (none logged)');
|
|
25
|
+
for (const l of llm) lines.push(` ${String(l.keySource ?? 'unknown').padEnd(12)} ${usd(l.costUsd)} / ${l.calls} calls`);
|
|
26
|
+
return lines.join('\n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const skillUsageCommand = {
|
|
30
|
+
command: 'skill-usage <siteId>',
|
|
31
|
+
describe: 'Per-customer skill/integration usage vs plan limits, by surface, with LLM cost split (Velaro staff only)',
|
|
32
|
+
builder: (y) => y
|
|
33
|
+
.positional('siteId', { type: 'number', describe: 'Target site ID' })
|
|
34
|
+
.option('json', { type: 'boolean', default: false }),
|
|
35
|
+
handler: runCommand(async (argv) => {
|
|
36
|
+
const r = await messagingGet(`/SupportTools/sites/${argv.siteId}/skill-usage`);
|
|
37
|
+
console.log(argv.json ? JSON.stringify(r, null, 2) : formatSkillUsage(r));
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* velaro teams-phone status Show current configuration
|
|
5
5
|
* velaro teams-phone set --tenant-id ... Create or update the configuration
|
|
6
6
|
* velaro teams-phone remove Delete the configuration
|
|
7
|
-
* velaro teams-phone test
|
|
7
|
+
* velaro teams-phone test Run the inbound-calling readiness checks
|
|
8
8
|
*
|
|
9
9
|
* Backend contract: server/Velaro.Messaging/Controllers/Voice/TeamsPhoneConfigController.cs
|
|
10
10
|
* (velaro-messaging). Site is resolved server-side from the auth context — never
|
|
@@ -34,6 +34,9 @@ function printConfig(c) {
|
|
|
34
34
|
console.log(` Assigned phone number: ${c.assignedPhoneNumber ?? '(none)'}`);
|
|
35
35
|
console.log(` Webhook secret configured: ${c.webhookSecretConfigured ? `${GRN}yes${RST}` : `${DIM}no${RST}`}`);
|
|
36
36
|
const subColor = c.graphSubscriptionActive ? GRN : YEL;
|
|
37
|
+
if (typeof c.callingReady === 'boolean') {
|
|
38
|
+
console.log(` Inbound calling ready: ${c.callingReady ? GRN : YEL}${c.callingReady}${RST}`);
|
|
39
|
+
}
|
|
37
40
|
console.log(` Graph subscription active: ${subColor}${c.graphSubscriptionActive}${RST}` +
|
|
38
41
|
(c.graphSubscriptionExpiresAt ? ` (expires ${c.graphSubscriptionExpiresAt})` : ''));
|
|
39
42
|
console.log('');
|
|
@@ -82,7 +85,16 @@ async function removeHandler() {
|
|
|
82
85
|
async function testHandler() {
|
|
83
86
|
const result = await messagingPost('/TeamsPhone/Config/Test', {});
|
|
84
87
|
const color = result?.success ? GRN : RED;
|
|
85
|
-
console.log(`\n${color}${result?.success ? 'Success' : 'Failed'}${RST}: ${result?.message ?? '(no message)'}
|
|
88
|
+
console.log(`\n${color}${result?.success ? 'Success' : 'Failed'}${RST}: ${result?.message ?? '(no message)'}`);
|
|
89
|
+
if (typeof result?.callingReady === 'boolean') {
|
|
90
|
+
console.log(` Inbound calling ready: ${result.callingReady ? GRN : RED}${result.callingReady}${RST}`);
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(result?.checks)) {
|
|
93
|
+
for (const ck of result.checks) {
|
|
94
|
+
console.log(` ${ck?.ok ? GRN + 'PASS' : RED + 'FAIL'}${RST} ${ck?.name ?? ''}${ck?.message ? ': ' + ck.message : ''}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
console.log('');
|
|
86
98
|
if (!result?.success) process.exitCode = 1;
|
|
87
99
|
}
|
|
88
100
|
|
|
@@ -121,7 +133,7 @@ export const teamsPhoneCommand = {
|
|
|
121
133
|
})
|
|
122
134
|
.command({
|
|
123
135
|
command: 'test',
|
|
124
|
-
describe: '
|
|
136
|
+
describe: 'Run the Teams Phone readiness checks (consent, resource account, Graph reachability) and print each check',
|
|
125
137
|
builder: yy => yy,
|
|
126
138
|
handler: testHandler,
|
|
127
139
|
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { messagingGet, messagingPut } from '../api.js';
|
|
2
|
+
import { runCommand } from '../run.js';
|
|
3
|
+
|
|
4
|
+
// ── velaro visitor-monitor ────────────────────────────────────────────────────
|
|
5
|
+
// Visitor Monitor view preferences (per user) and site settings. siteId is applied
|
|
6
|
+
// server-side from the JWT (see cli/lib/api.js messagingRequest), never a CLI arg.
|
|
7
|
+
//
|
|
8
|
+
// Backend contract: velaro-messaging VisitorController (PR 2798)
|
|
9
|
+
// GET/PUT Visitor/preferences { columns[], groupBy, sortBy, sortDir, regionGrouping }
|
|
10
|
+
// GET/PUT Visitor/settings { storeLocationDetail?, activeWindowMinutes? } (window 5-240; >30 needs Advanced plan, else 403 feature_not_in_plan)
|
|
11
|
+
// GET Visitor/active live sessions + activeWindowMinutes + locationDetailEnabled
|
|
12
|
+
|
|
13
|
+
const show = data => console.log(JSON.stringify(data, null, 2));
|
|
14
|
+
|
|
15
|
+
export const visitorMonitorCommand = {
|
|
16
|
+
command: 'visitor-monitor <subcommand>',
|
|
17
|
+
describe: 'Visitor Monitor preferences, settings and live visitors',
|
|
18
|
+
builder: yargs => yargs
|
|
19
|
+
.command({
|
|
20
|
+
command: 'active',
|
|
21
|
+
describe: 'List live visitors',
|
|
22
|
+
handler: runCommand(async () => show(await messagingGet('/Visitor/active'))),
|
|
23
|
+
})
|
|
24
|
+
.command({
|
|
25
|
+
command: 'preferences <action>',
|
|
26
|
+
describe: 'get or set your saved Visitor Monitor view',
|
|
27
|
+
builder: y => y
|
|
28
|
+
.positional('action', { choices: ['get', 'set'] })
|
|
29
|
+
.option('columns', { type: 'string', describe: 'Comma-separated column keys (set)' })
|
|
30
|
+
.option('group-by', { type: 'string', describe: 'Group by key (set)' })
|
|
31
|
+
.option('sort-by', { type: 'string', describe: 'Sort key (set)' })
|
|
32
|
+
.option('sort-dir', { type: 'string', choices: ['asc', 'desc'], describe: 'Sort direction (set)' })
|
|
33
|
+
.option('region-grouping', { type: 'string', describe: 'region or city (set)' }),
|
|
34
|
+
handler: runCommand(async argv => {
|
|
35
|
+
if (argv.action === 'get') return show(await messagingGet('/Visitor/preferences'));
|
|
36
|
+
const body = {};
|
|
37
|
+
if (argv.columns !== undefined) body.columns = String(argv.columns).split(',').map(s => s.trim()).filter(Boolean);
|
|
38
|
+
if (argv['group-by'] !== undefined) body.groupBy = argv['group-by'];
|
|
39
|
+
if (argv['sort-by'] !== undefined) body.sortBy = argv['sort-by'];
|
|
40
|
+
if (argv['sort-dir'] !== undefined) body.sortDir = argv['sort-dir'];
|
|
41
|
+
if (argv['region-grouping'] !== undefined) body.regionGrouping = argv['region-grouping'];
|
|
42
|
+
show(await messagingPut('/Visitor/preferences', body));
|
|
43
|
+
}),
|
|
44
|
+
})
|
|
45
|
+
.command({
|
|
46
|
+
command: 'settings <action>',
|
|
47
|
+
describe: 'get or set site Visitor Monitor settings',
|
|
48
|
+
builder: y => y
|
|
49
|
+
.positional('action', { choices: ['get', 'set'] })
|
|
50
|
+
.option('store-location-detail', { type: 'boolean', describe: 'Store region/city + truncated IP (Advanced plan) (set)' })
|
|
51
|
+
.option('active-window-minutes', { type: 'number', describe: 'Minutes visitors stay listed, 5-240 (above 30 needs Advanced) (set)' }),
|
|
52
|
+
handler: runCommand(async argv => {
|
|
53
|
+
if (argv.action === 'get') return show(await messagingGet('/Visitor/settings'));
|
|
54
|
+
const body = {};
|
|
55
|
+
if (argv['store-location-detail'] !== undefined) body.storeLocationDetail = argv['store-location-detail'];
|
|
56
|
+
if (argv['active-window-minutes'] !== undefined) body.activeWindowMinutes = argv['active-window-minutes'];
|
|
57
|
+
show(await messagingPut('/Visitor/settings', body));
|
|
58
|
+
}),
|
|
59
|
+
})
|
|
60
|
+
.demandCommand(1),
|
|
61
|
+
};
|
package/lib/log-search.js
CHANGED
|
@@ -207,3 +207,46 @@ export function describeRawMeta(resp) {
|
|
|
207
207
|
const bestEffort = resp.exact === false ? ' exact=false (answered from the best-effort search index: it can lag and misses text that starts mid-word, so treat counts as a floor, not exact)' : '';
|
|
208
208
|
return `searchPath=${resp.searchPath} scannedRows=${resp.scannedRows} truncated=${t}${t ? ' (NOT proof of absence; continue with nextCursor or narrow the window)' : ''}${bestEffort}${resp.nextCursor ? ' nextCursor=present' : ''}`;
|
|
209
209
|
}
|
|
210
|
+
|
|
211
|
+
// ── Time window + raw query building (shared by server.js and staff-server.js velaro_logs_search) ──
|
|
212
|
+
// Bug fixed 2026-10-21: the tool only honored `last` and silently ignored from/to, so an explicit
|
|
213
|
+
// historical range returned the newest entries instead. from/to accept an ISO timestamp or a
|
|
214
|
+
// relative duration (30m, 2h, 1d = that long ago). `to` defaults to now, `from` to to - last (2h).
|
|
215
|
+
const DUR_RE = /^(\d+)(m|h|d)$/i;
|
|
216
|
+
const durMs = (s) => {
|
|
217
|
+
const m = DUR_RE.exec(String(s).trim());
|
|
218
|
+
const n = parseInt(m[1], 10);
|
|
219
|
+
const u = m[2].toLowerCase();
|
|
220
|
+
return u === 'm' ? n * 60000 : u === 'h' ? n * 3600000 : n * 86400000;
|
|
221
|
+
};
|
|
222
|
+
function parseWhen(label, v, nowMs) {
|
|
223
|
+
const s = String(v).trim();
|
|
224
|
+
if (DUR_RE.test(s)) return new Date(nowMs - durMs(s));
|
|
225
|
+
const d = new Date(s);
|
|
226
|
+
if (Number.isNaN(d.getTime())) throw new Error(`Invalid ${label} "${v}": use an ISO timestamp (2026-10-20T00:00:00Z) or a relative duration like 2h.`);
|
|
227
|
+
return d;
|
|
228
|
+
}
|
|
229
|
+
export function resolveLogWindow(args = {}, nowMs = Date.now()) {
|
|
230
|
+
const given = (v) => v !== undefined && v !== null && String(v).trim() !== '';
|
|
231
|
+
const to = given(args.to) ? parseWhen('to', args.to, nowMs) : new Date(nowMs);
|
|
232
|
+
const lastMs = DUR_RE.test(String(args.last ?? '').trim()) ? durMs(args.last) : 2 * 3600000;
|
|
233
|
+
const from = given(args.from) ? parseWhen('from', args.from, nowMs) : new Date(to.getTime() - lastMs);
|
|
234
|
+
if (from.getTime() >= to.getTime()) throw new Error(`Invalid window: from (${from.toISOString()}) must be before to (${to.toISOString()}).`);
|
|
235
|
+
return { from, to };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Query string for GET /superadmin/logs/search (same params as /internal/logs/search).
|
|
239
|
+
export function buildRawLogQuery(args, from, to) {
|
|
240
|
+
const params = new URLSearchParams({ from: from.toISOString(), to: to.toISOString(), take: String(args.take ?? 50) });
|
|
241
|
+
if (args.q) params.set('q', args.q);
|
|
242
|
+
if (args.level) params.set('level', String(args.level).toUpperCase());
|
|
243
|
+
if (args.logger) params.set('logger', args.logger);
|
|
244
|
+
if (args.integration) params.set('integration', args.integration);
|
|
245
|
+
if (args.siteId) params.set('siteId', String(args.siteId));
|
|
246
|
+
if (args.source) params.set('source', args.source);
|
|
247
|
+
if (args.platform) params.set('platform', args.platform);
|
|
248
|
+
if (args.environment) params.set('environment', args.environment);
|
|
249
|
+
if (args.correlationId) params.set('correlationId', args.correlationId);
|
|
250
|
+
if (args.regex) params.set('regex', 'true');
|
|
251
|
+
return params.toString();
|
|
252
|
+
}
|