@velaro/cli 1.4.106 → 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 +47 -6
- package/lib/commands/billing-automation.js +1 -1
- 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/ops-groups.js +188 -0
- package/lib/commands/parity-staff-tools.js +192 -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/package.json +1 -1
package/bin/velaro.js
CHANGED
|
@@ -130,12 +130,18 @@ import { bundleCommand } from '../lib/commands/bundle.js';
|
|
|
130
130
|
import { chatWindowCommand } from '../lib/commands/chat-window.js';
|
|
131
131
|
import { featureDiscoveryCommand } from '../lib/commands/feature-discovery.js';
|
|
132
132
|
import { tourPolicyCommand } from '../lib/commands/tour-policy.js';
|
|
133
|
+
import { livefluenceCommand, missedCommand, serviceCommand, pageCommand, uploadedCommand, msCommand, outlookCommand, oauthCommand, provisionCommand, sseCommand, skillCommand, v10Command, whatsappCommand, mobileCommand, signalrCommand, supportGroupCommand, pricingGroupCommand, scraperGroupCommand, tourPolicyGroupCommand } from '../lib/commands/parity-staff-tools.js';
|
|
133
134
|
import { scraperCommand } from '../lib/commands/scraper.js';
|
|
134
135
|
import { usageCommand } from '../lib/commands/usage.js';
|
|
135
136
|
import { campaignsCommand } from '../lib/commands/campaigns.js';
|
|
136
137
|
import { senderDomainHealthCommand } from '../lib/commands/sender-domain-health.js';
|
|
137
138
|
import { contactsCommand } from '../lib/commands/contacts.js';
|
|
138
139
|
import { contactListCommand } from '../lib/commands/contact-list.js';
|
|
140
|
+
import { recycleBinCommand, aiUsageCommand, siteInfoCommand, billingReceiptsCommand } from '../lib/commands/endpoint-parity.js';
|
|
141
|
+
import { adminCommand, appleCommand, forceExpireSiteCommand, backfillCommand, kpiCommand, auditTrailCommand, codeCommand, crmCommand, hangfireCommand } from '../lib/commands/diag-groups.js';
|
|
142
|
+
import { aiFollowupsCommand, businessActionCommand, callCommand, conversationCommand, conversionCommand, emailCommand, ivrCommand } from '../lib/commands/ops-groups.js';
|
|
143
|
+
import { entitlementGroup } from '../lib/commands/entitlement.js';
|
|
144
|
+
import { featureDiscoveryGroup } from '../lib/commands/feature-discovery.js';
|
|
139
145
|
import { startUpdateCheck } from '../lib/update-check.js';
|
|
140
146
|
import { printBanner, printBannerIfFirstRun } from '../lib/banner.js';
|
|
141
147
|
import { createRequire } from 'module';
|
|
@@ -256,10 +262,10 @@ await yargs(hideBin(process.argv))
|
|
|
256
262
|
.command(signCommand)
|
|
257
263
|
.command(widgetContainerCommand)
|
|
258
264
|
.command(inlineWidgetConfigCommand)
|
|
259
|
-
.command(
|
|
260
|
-
.command(
|
|
265
|
+
.command(pricingGroupCommand)
|
|
266
|
+
.command(entitlementGroup)
|
|
261
267
|
.command({ command: 'subscription', desc: 'Apply/diff SubscriptionPackage packages on a site (superadmin only)', builder: subscriptionCommand, handler: () => {} })
|
|
262
|
-
.command(
|
|
268
|
+
.command(supportGroupCommand)
|
|
263
269
|
.command(routingCommand)
|
|
264
270
|
.command(diagnosticsCommand)
|
|
265
271
|
.command(aiSpendCommand)
|
|
@@ -281,14 +287,49 @@ await yargs(hideBin(process.argv))
|
|
|
281
287
|
.command(surveyCommand)
|
|
282
288
|
.command(bundleCommand)
|
|
283
289
|
.command(chatWindowCommand)
|
|
284
|
-
.command(
|
|
290
|
+
.command(featureDiscoveryGroup)
|
|
291
|
+
.command(scraperGroupCommand)
|
|
292
|
+
.command(tourPolicyGroupCommand)
|
|
293
|
+
.command(livefluenceCommand)
|
|
294
|
+
.command(missedCommand)
|
|
295
|
+
.command(serviceCommand)
|
|
296
|
+
.command(pageCommand)
|
|
297
|
+
.command(uploadedCommand)
|
|
298
|
+
.command(msCommand)
|
|
299
|
+
.command(outlookCommand)
|
|
300
|
+
.command(oauthCommand)
|
|
301
|
+
.command(provisionCommand)
|
|
302
|
+
.command(sseCommand)
|
|
303
|
+
.command(skillCommand)
|
|
304
|
+
.command(v10Command)
|
|
305
|
+
.command(whatsappCommand)
|
|
306
|
+
.command(mobileCommand)
|
|
307
|
+
.command(signalrCommand)
|
|
285
308
|
.command(usageCommand)
|
|
286
|
-
.command({ command: 'scraper', desc: 'Velaro staff: scraper / upload / chunk usage for any site', builder: scraperCommand, handler: () => {} })
|
|
287
|
-
.command({ command: 'tour-policy', desc: 'Velaro staff: guided-tour auto-open policy (versioned, rollback-able)', builder: tourPolicyCommand, handler: () => {} })
|
|
288
309
|
.command(campaignsCommand)
|
|
289
310
|
.command(senderDomainHealthCommand)
|
|
290
311
|
.command(contactsCommand)
|
|
291
312
|
.command(contactListCommand)
|
|
313
|
+
.command(recycleBinCommand)
|
|
314
|
+
.command(aiUsageCommand)
|
|
315
|
+
.command(siteInfoCommand)
|
|
316
|
+
.command(billingReceiptsCommand)
|
|
317
|
+
.command(adminCommand)
|
|
318
|
+
.command(appleCommand)
|
|
319
|
+
.command(forceExpireSiteCommand)
|
|
320
|
+
.command(backfillCommand)
|
|
321
|
+
.command(kpiCommand)
|
|
322
|
+
.command(auditTrailCommand)
|
|
323
|
+
.command(codeCommand)
|
|
324
|
+
.command(crmCommand)
|
|
325
|
+
.command(hangfireCommand)
|
|
326
|
+
.command(aiFollowupsCommand)
|
|
327
|
+
.command(businessActionCommand)
|
|
328
|
+
.command(callCommand)
|
|
329
|
+
.command(conversationCommand)
|
|
330
|
+
.command(conversionCommand)
|
|
331
|
+
.command(emailCommand)
|
|
332
|
+
.command(ivrCommand)
|
|
292
333
|
.demandCommand(1, 'Specify a command. Run "velaro --help" for options.')
|
|
293
334
|
.strict()
|
|
294
335
|
.help()
|
|
@@ -35,7 +35,7 @@ async function listRules() {
|
|
|
35
35
|
async function setGlobal(appliesTo, mode) {
|
|
36
36
|
if (!GATES.includes(appliesTo)) fail(`appliesTo must be one of: ${GATES.join(', ')}`);
|
|
37
37
|
if (!MODES.includes(mode)) fail(`mode must be one of: ${MODES.join(', ')}`);
|
|
38
|
-
await request('PUT', `/api/BillingAutomation/global/${appliesTo}`, { mode });
|
|
38
|
+
await request('PUT', `/api/BillingAutomation/global/${appliesTo}`, { mode }); // controller action: SaveGlobal
|
|
39
39
|
console.log(`Global ${appliesTo} set to ${mode}.`);
|
|
40
40
|
}
|
|
41
41
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// MCP parity command groups: admin, apple, force-expire-site, backfill, kpi, audit-trail, code, crm, hangfire.
|
|
2
|
+
// Each subcommand calls the same REST route as the MCP tool of the same name (mcp/server.js, mcp/staff-server.js).
|
|
3
|
+
// Staff-only routes are enforced by the backend.
|
|
4
|
+
import { get, post, del, messagingGet, messagingPost } from '../api.js';
|
|
5
|
+
import { show, qs, needYes, group } from '../parity-util.js';
|
|
6
|
+
|
|
7
|
+
export const adminCommand = { command: 'admin', ...group('Velaro staff: admin API diagnostics', [
|
|
8
|
+
['startup-diagnostics', 'Recent AdminStartupDiagnostics rows (migrations, startup exceptions)',
|
|
9
|
+
(y) => y.option('context-label', { type: 'string' }).option('count', { type: 'number' }),
|
|
10
|
+
async (a) => show(await get(`/SuperAdmin/StartupDiagnostics${qs({ contextLabel: a['context-label'], count: a.count })}`))],
|
|
11
|
+
]) };
|
|
12
|
+
|
|
13
|
+
export const appleCommand = { command: 'apple', ...group('Velaro staff: Apple Messages for Business diagnostics', [
|
|
14
|
+
['health <siteId>', 'Apple Messages health for a site',
|
|
15
|
+
(y) => y.positional('siteId', { type: 'number' }),
|
|
16
|
+
async (a) => show(await messagingGet(`/SupportTools/sites/${a.siteId}/apple/health`))],
|
|
17
|
+
['test-control <siteId>', 'Send an Apple test-control action to a conversation',
|
|
18
|
+
(y) => y.positional('siteId', { type: 'number' })
|
|
19
|
+
.option('control', { type: 'string', demandOption: true, describe: 'Control name' })
|
|
20
|
+
.option('conversation-id', { type: 'number' }),
|
|
21
|
+
async (a) => show(await messagingPost(`/SupportTools/sites/${a.siteId}/apple/test-control`, { control: a.control, conversationId: a['conversation-id'] }))],
|
|
22
|
+
]) };
|
|
23
|
+
|
|
24
|
+
export const forceExpireSiteCommand = {
|
|
25
|
+
command: 'force-expire-site <siteId>',
|
|
26
|
+
describe: 'Velaro staff: force-expire a site subscription (destructive, needs --yes)',
|
|
27
|
+
builder: (y) => y.positional('siteId', { type: 'number' }).option('yes', { type: 'boolean', describe: 'Confirm' }),
|
|
28
|
+
handler: async (a) => {
|
|
29
|
+
needYes(a, `force-expire site ${a.siteId}`);
|
|
30
|
+
show(await messagingPost(`/SuperAdmin/subscription/${a.siteId}/force-expire`));
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const backfillCommand = { command: 'backfill', ...group('Velaro staff: per-site data backfills (preview unless --apply --yes)', [
|
|
35
|
+
['types', 'List backfill types', null, async () => show(await messagingGet('/SuperAdmin/backfill-types'))],
|
|
36
|
+
['run <siteId>', 'Preview or apply a backfill for a site',
|
|
37
|
+
(y) => y.positional('siteId', { type: 'number' })
|
|
38
|
+
.option('type', { type: 'string', demandOption: true })
|
|
39
|
+
.option('from', { type: 'string', demandOption: true, describe: 'yyyy-MM-dd' })
|
|
40
|
+
.option('to', { type: 'string', demandOption: true, describe: 'yyyy-MM-dd' })
|
|
41
|
+
.option('apply', { type: 'boolean', describe: 'Write the backfill (default is a preview)' })
|
|
42
|
+
.option('yes', { type: 'boolean', describe: 'Confirm --apply' }),
|
|
43
|
+
async (a) => {
|
|
44
|
+
if (a.apply) needYes(a, 'apply a backfill');
|
|
45
|
+
show(await messagingPost(`/SuperAdmin/sites/${encodeURIComponent(a.siteId)}/backfill`, { type: a.type, from: a.from, to: a.to, apply: a.apply === true }));
|
|
46
|
+
}],
|
|
47
|
+
]) };
|
|
48
|
+
|
|
49
|
+
export const kpiCommand = { command: 'kpi', ...group('Live operations KPIs', [
|
|
50
|
+
['snapshot', 'Real-time ops snapshot: agents, queues, waits, channels, teams', null, async () => show(await get('/LiveDashboard/snapshot'))],
|
|
51
|
+
]) };
|
|
52
|
+
|
|
53
|
+
export const auditTrailCommand = { command: 'audit-trail', ...group('Audit trail of configuration changes', [
|
|
54
|
+
['list', 'List audit entries',
|
|
55
|
+
(y) => y.option('entity-type', { type: 'string' }).option('entity-id', { type: 'string' }).option('take', { type: 'number', default: 200 }),
|
|
56
|
+
async (a) => show(await get(`/AuditTrail${qs({ entityType: a['entity-type'], entityId: a['entity-id'], take: Math.min(500, a.take ?? 200) })}`))],
|
|
57
|
+
]) };
|
|
58
|
+
|
|
59
|
+
export const codeCommand = { command: 'code', ...group('Push files to a Velaro repo branch (needs GITHUB_TOKEN)', [
|
|
60
|
+
['push-files', 'Create one commit with files on dev/main/master (destructive, needs --yes)',
|
|
61
|
+
(y) => y.option('message', { type: 'string', demandOption: true })
|
|
62
|
+
.option('file', { type: 'array', demandOption: true, describe: 'repoPath=localPath, repeatable' })
|
|
63
|
+
.option('repo', { type: 'string', default: 'velaro-admin' })
|
|
64
|
+
.option('branch', { type: 'string', default: 'dev' })
|
|
65
|
+
.option('yes', { type: 'boolean' }),
|
|
66
|
+
async (a) => {
|
|
67
|
+
needYes(a, 'push a commit');
|
|
68
|
+
const token = process.env.GITHUB_TOKEN;
|
|
69
|
+
if (!token) throw new Error('GITHUB_TOKEN env var is not set.');
|
|
70
|
+
const { readFileSync } = await import('node:fs');
|
|
71
|
+
const files = a.file.map((f) => { const [p, l] = String(f).split('='); return { path: p, content: readFileSync(l || p, 'utf8') }; });
|
|
72
|
+
const REPOS = ['velaro-admin', 'velaro-messaging', 'velaro-shared', 'velaro-shared-frontend'];
|
|
73
|
+
if (!REPOS.includes(a.repo)) throw new Error(`Repo not allowed. Allowed: ${REPOS.join(', ')}`);
|
|
74
|
+
if (!['dev', 'master', 'main'].includes(a.branch)) throw new Error('Branch must be dev, master or main.');
|
|
75
|
+
const norm = (p) => p.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\//, '');
|
|
76
|
+
if (files.some((f) => norm(f.path) === '.github' || norm(f.path).startsWith('.github/'))) throw new Error('Cannot push to .github/ paths.');
|
|
77
|
+
const base = `https://api.github.com/repos/velaro/${a.repo}`;
|
|
78
|
+
const headers = { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', 'Content-Type': 'application/json' };
|
|
79
|
+
const gh = async (path, opts = {}) => {
|
|
80
|
+
const r = await fetch(base + path, { headers, ...opts });
|
|
81
|
+
if (!r.ok) throw new Error(`GitHub API ${path} -> ${r.status}: ${await r.text()}`);
|
|
82
|
+
return r.json();
|
|
83
|
+
};
|
|
84
|
+
const head = (await gh(`/git/refs/heads/${a.branch}`)).object.sha;
|
|
85
|
+
const baseTree = (await gh(`/git/commits/${head}`)).tree.sha;
|
|
86
|
+
const tree = await Promise.all(files.map(async (f) => {
|
|
87
|
+
const b = await gh('/git/blobs', { method: 'POST', body: JSON.stringify({ content: f.content, encoding: 'utf-8' }) });
|
|
88
|
+
return { path: f.path, mode: '100644', type: 'blob', sha: b.sha };
|
|
89
|
+
}));
|
|
90
|
+
const nt = await gh('/git/trees', { method: 'POST', body: JSON.stringify({ base_tree: baseTree, tree }) });
|
|
91
|
+
const c = await gh('/git/commits', { method: 'POST', body: JSON.stringify({ message: a.message, tree: nt.sha, parents: [head] }) });
|
|
92
|
+
await gh(`/git/refs/heads/${a.branch}`, { method: 'PATCH', body: JSON.stringify({ sha: c.sha }) });
|
|
93
|
+
console.log(`Pushed to velaro/${a.repo}@${a.branch} commit ${c.sha.slice(0, 8)}\n${files.map((f) => ' ' + f.path).join('\n')}`);
|
|
94
|
+
}],
|
|
95
|
+
]) };
|
|
96
|
+
|
|
97
|
+
export const crmCommand = { command: 'crm', ...group('Velaro staff: CRM email-campaign engine audit', [
|
|
98
|
+
['email-campaign-audit', 'Row counts for the legacy CRM email-campaign tables (safe-to-retire check)', null,
|
|
99
|
+
async () => show(await messagingGet('/SuperAdmin/crm-email-campaign-audit'))],
|
|
100
|
+
]) };
|
|
101
|
+
|
|
102
|
+
export const hangfireCommand = { command: 'hangfire', ...group('Velaro staff: failed Hangfire jobs', [
|
|
103
|
+
['list-failed', 'List failed jobs', null, async () => show(await get('/DatabaseTool/FailedJobs'))],
|
|
104
|
+
['retry <id>', 'Requeue one failed job', (y) => y.positional('id', { type: 'string' }),
|
|
105
|
+
async (a) => show(await post(`/DatabaseTool/FailedJobs/${a.id}/retry`))],
|
|
106
|
+
['delete-failed <id>', 'Delete one failed job (needs --yes)', (y) => y.positional('id', { type: 'string' }).option('yes', { type: 'boolean' }),
|
|
107
|
+
async (a) => { needYes(a, `delete failed job ${a.id}`); show(await del(`/DatabaseTool/FailedJobs/${a.id}`)); }],
|
|
108
|
+
]) };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// CLI halves of the MCP parity tools in mcp/parity/endpoints.customer.js and endpoints.staff.js.
|
|
2
|
+
// Each handler names the controller action it calls.
|
|
3
|
+
import { request } from '../api.js';
|
|
4
|
+
|
|
5
|
+
const show = (r) => console.log(JSON.stringify(r, null, 2));
|
|
6
|
+
const int = (y, k, d) => y.option(k, { type: 'number', demandOption: true, describe: d });
|
|
7
|
+
|
|
8
|
+
export const recycleBinCommand = {
|
|
9
|
+
command: 'recycle-bin',
|
|
10
|
+
describe: 'List and restore soft-deleted contact lists, members, segments and KB articles/topics (one id at a time)',
|
|
11
|
+
builder: (y) => y
|
|
12
|
+
.command({ command: 'contact-lists', describe: 'List deleted contact lists', handler: async () => show(await request('GET', '/ContactLists/deleted')) }) // controller action: GetDeletedLists
|
|
13
|
+
.command({ command: 'restore-contact-list', describe: 'Restore one contact list', builder: (z) => int(z, 'id', 'List id'), handler: async (a) => show(await request('POST', `/ContactLists/${a.id}/restore`)) }) // controller action: RestoreList
|
|
14
|
+
.command({ command: 'members', describe: 'List deleted members of a list', builder: (z) => int(z, 'list', 'List id'), handler: async (a) => show(await request('GET', `/ContactLists/${a.list}/Members/deleted`)) }) // controller action: GetDeletedMembers
|
|
15
|
+
.command({ command: 'restore-member', describe: 'Restore one list member', builder: (z) => int(int(z, 'list', 'List id'), 'member', 'Member id'), handler: async (a) => show(await request('POST', `/ContactLists/${a.list}/Members/${a.member}/restore`)) }) // controller action: RestoreMember
|
|
16
|
+
.command({ command: 'segments', describe: 'List deleted segments', handler: async () => show(await request('GET', '/ContactSegments/deleted')) }) // controller action: ListDeleted (ContactSegments)
|
|
17
|
+
.command({ command: 'kb-articles', describe: 'List deleted KB articles', handler: async () => show(await request('GET', '/api/kb/articles/deleted')) }) // controller action: ListDeleted (KBArticles)
|
|
18
|
+
.command({ command: 'restore-kb-article', describe: 'Restore one KB article', builder: (z) => int(z, 'id', 'Article id'), handler: async (a) => show(await request('POST', `/api/kb/articles/${a.id}/restore`)) }) // controller action: RestoreDeleted (KBArticles)
|
|
19
|
+
.command({ command: 'kb-topics', describe: 'List deleted KB topics', handler: async () => show(await request('GET', '/api/kb/topics/deleted')) }) // controller action: ListDeleted (KBTopics)
|
|
20
|
+
.command({ command: 'restore-kb-topic', describe: 'Restore one KB topic', builder: (z) => int(z, 'id', 'Topic id'), handler: async (a) => show(await request('POST', `/api/kb/topics/${a.id}/restore`)) }) // controller action: RestoreDeleted (KBTopics)
|
|
21
|
+
.demandCommand(1),
|
|
22
|
+
handler: () => {},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const aiUsageCommand = {
|
|
26
|
+
command: 'ai-usage',
|
|
27
|
+
describe: 'Your site AI usage summary and budget alert',
|
|
28
|
+
builder: (y) => y
|
|
29
|
+
.command({ command: 'summary', describe: 'AI usage summary', handler: async () => show(await request('GET', '/AiUsageReport/summary')) }) // controller action: GetSummary
|
|
30
|
+
.command({
|
|
31
|
+
command: 'budget-alert-set', describe: 'Update the budget alert',
|
|
32
|
+
builder: (z) => z.option('enabled', { type: 'boolean' }).option('threshold-percent', { type: 'number' }).option('recipients', { type: 'string', describe: 'Comma-separated emails' }),
|
|
33
|
+
handler: async (a) => show(await request('PUT', '/AiUsageReport/budget-alert', { enabled: a.enabled, thresholdPercent: a.thresholdPercent, recipientEmails: a.recipients })), // controller action: UpdateBudgetAlert
|
|
34
|
+
})
|
|
35
|
+
.command({ command: 'budget-alert-delete', describe: 'Delete the budget alert', handler: async () => show(await request('DELETE', '/AiUsageReport/budget-alert')) }) // controller action: DeleteBudgetAlert
|
|
36
|
+
.demandCommand(1),
|
|
37
|
+
handler: () => {},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const siteInfoCommand = {
|
|
41
|
+
command: 'site-info',
|
|
42
|
+
describe: 'Read-only site settings, entitlements and platform uptime for your site',
|
|
43
|
+
builder: (y) => y
|
|
44
|
+
.command({ command: 'settings', describe: 'Site settings', handler: async () => show(await request('GET', '/api/SiteSettings')) }) // controller action: GetSiteSettings
|
|
45
|
+
.command({ command: 'entitlements', describe: 'Your resolved entitlements', handler: async () => show(await request('GET', '/Entitlements/my')) }) // controller action: GetMyEntitlements
|
|
46
|
+
.command({ command: 'uptime', describe: 'Platform uptime', handler: async () => show(await request('GET', '/PlatformUptime')) }) // controller action: GetAsync (PlatformUptime)
|
|
47
|
+
.command({ command: 'last-copilot-session', describe: 'Your last Copilot session summary', handler: async () => show(await request('GET', '/Copilot/LastSession')) }) // controller action: LastSession
|
|
48
|
+
.command({ command: 'sender-domain-health', describe: 'Sender domain health', handler: async () => show(await request('GET', '/SenderDomains/Health')) }) // controller action: GetHealth
|
|
49
|
+
.command({ command: 'netsuite-interaction-summary', describe: 'NetSuite AI interaction summary for a conversation', builder: (z) => int(z, 'conversation', 'Conversation id'), handler: async (a) => show(await request('POST', `/NetSuite/interaction-summary?conversationId=${a.conversation}`)) }) // controller action: GetInteractionSummary
|
|
50
|
+
.demandCommand(1),
|
|
51
|
+
handler: () => {},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const billingReceiptsCommand = {
|
|
55
|
+
command: 'billing-receipts-run',
|
|
56
|
+
describe: 'Staff only: run the payment receipt job now',
|
|
57
|
+
handler: async () => show(await request('POST', '/api/BillingNotifications/receipts/run-now')), // controller action: RunReceiptsNow
|
|
58
|
+
};
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
// velaro entitlement — query and manage site feature entitlements (superadmin only)
|
|
2
2
|
import { request } from '../api.js';
|
|
3
3
|
|
|
4
|
+
async function syncStripe(a) {
|
|
5
|
+
const r = await request('PUT', `Entitlements/admin/sync-stripe/${a.site}`, {
|
|
6
|
+
planKey: a.plan,
|
|
7
|
+
status: a.status,
|
|
8
|
+
cancelAtPeriodEnd: a['cancel-at-period-end'] ?? false,
|
|
9
|
+
currentPeriodEnd: a['current-period-end'] ?? null,
|
|
10
|
+
trialEndAt: a['trial-end-at'] ?? null,
|
|
11
|
+
stripeCustomerId: a['stripe-customer-id'],
|
|
12
|
+
stripeSubscriptionId: a['stripe-subscription-id'],
|
|
13
|
+
});
|
|
14
|
+
console.log(`Site ${a.site} synced: status=${r.status} plan=${r.planKey || 'unchanged'}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
4
17
|
async function getSite(args) {
|
|
5
18
|
if (!args.site) { console.error('Usage: velaro entitlement get --site 1032'); process.exit(1); }
|
|
6
19
|
const result = await request('GET', `Entitlements/${args.site}`);
|
|
@@ -289,6 +302,16 @@ export function entitlementCommand(yargs) {
|
|
|
289
302
|
.option('at', { type: 'string', desc: 'ISO 8601 timestamp to lock entitlement resolution to' })
|
|
290
303
|
.option('unlock', { type: 'boolean', desc: 'Clear an existing lock (resolves against current plan version again)' })
|
|
291
304
|
, a => setLock(a))
|
|
305
|
+
.command('sync-stripe', 'Sync the Stripe subscription state into its entitlement record (superadmin only)', y => y
|
|
306
|
+
.option('site', { type: 'number', demandOption: true, desc: 'Site ID' })
|
|
307
|
+
.option('status', { type: 'string', demandOption: true, desc: 'Stripe subscription status, e.g. active, trialing, canceled' })
|
|
308
|
+
.option('plan', { type: 'string', desc: 'Plan key, e.g. plan.professional' })
|
|
309
|
+
.option('cancel-at-period-end', { type: 'boolean', desc: 'Subscription cancels at period end' })
|
|
310
|
+
.option('current-period-end', { type: 'string', desc: 'ISO 8601 current period end' })
|
|
311
|
+
.option('trial-end-at', { type: 'string', desc: 'ISO 8601 trial end' })
|
|
312
|
+
.option('stripe-customer-id', { type: 'string', desc: 'Stripe customer id' })
|
|
313
|
+
.option('stripe-subscription-id', { type: 'string', desc: 'Stripe subscription id' })
|
|
314
|
+
, a => syncStripe(a))
|
|
292
315
|
.command('trial <action>', 'Trial governance (superadmin): get | recommend | not-paying | grant | hold | reactivate | set-limit | approve-limit | deny-limit | set-text', y => y
|
|
293
316
|
.option('site', { type: 'number', desc: 'Site ID' })
|
|
294
317
|
.option('feature', { type: 'string', desc: 'Feature key, e.g. EnableIvr' })
|
|
@@ -305,5 +328,8 @@ export function entitlementCommand(yargs) {
|
|
|
305
328
|
.option('key', { type: 'string', demandOption: true, desc: 'Feature key, e.g. EnableIvr' })
|
|
306
329
|
.option('rollback', { type: 'boolean', desc: 'Pass to roll back this flag to Subscription.cs (default: go live)' })
|
|
307
330
|
, a => setCutover(a))
|
|
308
|
-
.demandCommand(1, 'Specify a subcommand: get | list-features | enterprise-reports | report-packages | set-override | remove-override | seed | seed-plans | backfill | backfill-sap-tiers | backfill-legacy-write-tiers | reconcile | comp | set-lock | trial | list-cutover | set-cutover');
|
|
331
|
+
.demandCommand(1, 'Specify a subcommand: get | list-features | enterprise-reports | report-packages | set-override | remove-override | seed | seed-plans | backfill | backfill-sap-tiers | backfill-legacy-write-tiers | reconcile | comp | set-lock | sync-stripe | trial | list-cutover | set-cutover');
|
|
309
332
|
}
|
|
333
|
+
|
|
334
|
+
// Registered group object (the CLI/MCP parity validator reads `command:` from a named const).
|
|
335
|
+
export const entitlementGroup = { command: 'entitlement', desc: 'Query and manage site feature entitlements (superadmin only)', builder: entitlementCommand, handler: () => {} };
|
|
@@ -38,3 +38,6 @@ export function featureDiscoveryCommand(yargs) {
|
|
|
38
38
|
, a => requestFeature(a))
|
|
39
39
|
.demandCommand(1, 'Specify a subcommand: list | request');
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
// Registered group object (the CLI/MCP parity validator reads `command:` from a named const).
|
|
43
|
+
export const featureDiscoveryGroup = { command: 'feature-discovery', desc: 'View and act on feature-discovery suggestions for this site', builder: featureDiscoveryCommand, handler: () => {} };
|
|
@@ -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: () => {} };
|
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
|
+
}
|
package/lib/log-search.test.js
CHANGED
|
@@ -321,7 +321,7 @@ test('fetchRetry429 retries only 429 and returns the final response after the re
|
|
|
321
321
|
});
|
|
322
322
|
|
|
323
323
|
test('bot_full_diagnostic: errors use the server-side level=ERROR filter, patterns are a separate read; neither is the old text regex', () => {
|
|
324
|
-
for (const rel of ['../../mcp/staff-server.js'
|
|
324
|
+
for (const rel of ['../../mcp/staff-server.js']) { // bot_full_diagnostic is staff-only since MCP 2.0.0
|
|
325
325
|
const src = fs.readFileSync(path.join(here, rel), 'utf8');
|
|
326
326
|
const a = src.indexOf('const velaroLoggerP = safeJson');
|
|
327
327
|
const body = src.slice(a, a + 2600);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Shared helpers for the MCP-parity command groups (diag-groups.js, ops-groups.js).
|
|
2
|
+
export const show = (r) => console.log(typeof r === 'string' ? r : JSON.stringify(r, null, 2));
|
|
3
|
+
export const qs = (o) => {
|
|
4
|
+
const p = new URLSearchParams();
|
|
5
|
+
for (const [k, v] of Object.entries(o)) if (v !== undefined && v !== null && v !== '') p.set(k, String(v));
|
|
6
|
+
const s = p.toString();
|
|
7
|
+
return s ? `?${s}` : '';
|
|
8
|
+
};
|
|
9
|
+
export const run = (fn) => async (argv) => {
|
|
10
|
+
try { await fn(argv); } catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
|
|
11
|
+
};
|
|
12
|
+
export const needYes = (argv, what) => {
|
|
13
|
+
if (!argv.yes) { console.error(`Refusing to ${what} without --yes.`); process.exit(1); }
|
|
14
|
+
};
|
|
15
|
+
// Build a yargs group body (spread into `{ command: 'name', ...group(...) }` so the parity validator sees the command name): subs = [[command, describe, builderFn|null, handler]]
|
|
16
|
+
export function group(describe, subs) {
|
|
17
|
+
return {
|
|
18
|
+
describe,
|
|
19
|
+
builder: (y) => {
|
|
20
|
+
for (const [command, desc, b, handler] of subs) {
|
|
21
|
+
y.command({ command, describe: desc, builder: b || ((x) => x), handler: run(handler) });
|
|
22
|
+
}
|
|
23
|
+
return y.demandCommand(1, `Specify a subcommand: ${subs.map((s) => s[0].split(' ')[0]).join(' | ')}`);
|
|
24
|
+
},
|
|
25
|
+
handler: () => {},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -42,7 +42,7 @@ import { readFileSync } from 'node:fs';
|
|
|
42
42
|
import { fileURLToPath } from 'node:url';
|
|
43
43
|
import { dirname, join as pjoin } from 'node:path';
|
|
44
44
|
const root = pjoin(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
45
|
-
for (const f of ['mcp/
|
|
45
|
+
for (const f of ['mcp/staff-server.js', 'server/Velaro.Admin/Controllers/StaffMcpController.cs']) {
|
|
46
46
|
test(`${f} registers sign_versions tools`, () => {
|
|
47
47
|
const s = readFileSync(pjoin(root, f), 'utf8');
|
|
48
48
|
for (const t of ['sign_versions_list', 'sign_versions_diff', 'sign_versions_upload']) assert.ok(s.includes(t), `${t} missing in ${f}`);
|
package/package.json
CHANGED