plazbot-cli 0.4.1 → 0.5.0

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.
Files changed (76) hide show
  1. package/README.md +67 -111
  2. package/dist/cli.js +26 -11
  3. package/dist/commands/agent/ai-config.js +264 -63
  4. package/dist/commands/agent/chat.js +16 -11
  5. package/dist/commands/agent/copy.js +49 -13
  6. package/dist/commands/agent/create.js +62 -17
  7. package/dist/commands/agent/delete.js +50 -39
  8. package/dist/commands/agent/enable-widget.js +47 -27
  9. package/dist/commands/agent/export.js +179 -88
  10. package/dist/commands/agent/files.js +155 -73
  11. package/dist/commands/agent/get.js +24 -16
  12. package/dist/commands/agent/index.js +12 -10
  13. package/dist/commands/agent/list.js +20 -11
  14. package/dist/commands/agent/monitor.js +101 -53
  15. package/dist/commands/agent/on-message.js +55 -32
  16. package/dist/commands/agent/set.js +230 -82
  17. package/dist/commands/agent/templates.js +259 -156
  18. package/dist/commands/agent/tools.js +356 -123
  19. package/dist/commands/agent/update.js +69 -28
  20. package/dist/commands/agent/validate.js +72 -40
  21. package/dist/commands/agent/wizard.js +240 -329
  22. package/dist/commands/auth/browser-login.js +37 -31
  23. package/dist/commands/auth/login.js +49 -45
  24. package/dist/commands/auth/logout.js +20 -11
  25. package/dist/commands/auth/status.js +36 -12
  26. package/dist/commands/auth/workspace.js +61 -27
  27. package/dist/commands/portal/add-agent.js +106 -45
  28. package/dist/commands/portal/add-link.js +25 -28
  29. package/dist/commands/portal/clear-links.js +39 -19
  30. package/dist/commands/portal/create.js +66 -37
  31. package/dist/commands/portal/delete.js +40 -45
  32. package/dist/commands/portal/get.js +21 -54
  33. package/dist/commands/portal/index.js +12 -12
  34. package/dist/commands/portal/list.js +34 -52
  35. package/dist/commands/portal/shared.js +92 -0
  36. package/dist/commands/portal/update.js +100 -70
  37. package/dist/commands/whatsapp/broadcast.js +169 -77
  38. package/dist/commands/whatsapp/channels.js +167 -65
  39. package/dist/commands/whatsapp/chat.js +45 -46
  40. package/dist/commands/whatsapp/connect-flow.js +16 -15
  41. package/dist/commands/whatsapp/connect.js +36 -21
  42. package/dist/commands/whatsapp/delete-webhook.js +42 -25
  43. package/dist/commands/whatsapp/index.js +15 -16
  44. package/dist/commands/whatsapp/register-webhook.js +43 -26
  45. package/dist/commands/whatsapp/send-template.js +62 -61
  46. package/dist/commands/whatsapp/send.js +46 -33
  47. package/dist/commands/whatsapp/setup.js +62 -52
  48. package/dist/commands/whatsapp/shared.js +75 -0
  49. package/dist/commands/whatsapp/widget.js +82 -45
  50. package/dist/commands/workers/deploy.js +80 -58
  51. package/dist/commands/workers/index.js +8 -8
  52. package/dist/commands/workers/list.js +42 -20
  53. package/dist/commands/workers/logs.js +139 -36
  54. package/dist/commands/workers/remove.js +45 -47
  55. package/dist/commands/workers/secret.js +159 -83
  56. package/dist/commands/workers/test.js +108 -60
  57. package/dist/schemas/agent.config.schema.json +159 -8
  58. package/dist/studio/api/sseClient.js +1 -1
  59. package/dist/studio/api/studioApi.js +8 -0
  60. package/dist/studio/components/App.js +12 -0
  61. package/dist/studio/components/Footer.js +1 -1
  62. package/dist/studio/index.js +77 -14
  63. package/dist/studio/runOneShot.js +62 -23
  64. package/dist/studio/runRepl.js +5 -5
  65. package/dist/studio/slash/handlers.js +11 -8
  66. package/dist/studio/state/store.js +1 -1
  67. package/dist/utils/agent-errors.js +14 -11
  68. package/dist/utils/agent-patch.js +60 -0
  69. package/dist/utils/banner.js +9 -5
  70. package/dist/utils/confirm.js +31 -0
  71. package/dist/utils/credentials.js +13 -3
  72. package/dist/utils/logger.js +13 -10
  73. package/dist/utils/output.js +78 -0
  74. package/dist/utils/phone.js +22 -0
  75. package/dist/utils/ui.js +14 -11
  76. package/package.json +2 -2
@@ -1,100 +1,192 @@
1
1
  import { Command } from 'commander';
2
- import { Message } from 'plazbot';
3
- import { getStoredCredentials } from '../../utils/credentials.js';
2
+ import fs from 'fs/promises';
4
3
  import { logger } from '../../utils/logger.js';
5
- import { addExamples } from '../../utils/help.js';
4
+ import { addExamples, addNotes } from '../../utils/help.js';
6
5
  import { theme, section, progressBar } from '../../utils/ui.js';
7
- import fs from 'fs/promises';
6
+ import { jsonOption, isJsonMode, printJson, fail, CliError, describeError } from '../../utils/output.js';
7
+ import { addYesOption, confirmAction } from '../../utils/confirm.js';
8
+ import { normalizePhone } from '../../utils/phone.js';
9
+ import { ensureApiSuccess, getMessageClient, parseVariables } from './shared.js';
10
+ /**
11
+ * Plain list of numbers: one per line, or separated by commas/semicolons.
12
+ * Ignores blank lines, `#` comments and a first header line that isn't a number.
13
+ */
14
+ export function parsePhoneList(content) {
15
+ const phones = [];
16
+ const seen = new Set();
17
+ const invalid = [];
18
+ let duplicates = 0;
19
+ let header;
20
+ let first = true;
21
+ content.replace(/^/, '').split(/\r?\n/).forEach((rawLine, idx) => {
22
+ const line = rawLine.trim();
23
+ if (!line || line.startsWith('#'))
24
+ return;
25
+ for (const piece of line.split(/[,;\t]/)) {
26
+ const entry = piece.trim().replace(/^["']|["']$/g, '').trim();
27
+ if (!entry)
28
+ continue;
29
+ try {
30
+ const phone = normalizePhone(entry);
31
+ if (seen.has(phone))
32
+ duplicates++;
33
+ else {
34
+ seen.add(phone);
35
+ phones.push(phone);
36
+ }
37
+ }
38
+ catch (error) {
39
+ if (first && /[a-z]/i.test(entry)) {
40
+ header = line; // e.g. "phone" or "phone,name": skip the whole line
41
+ first = false;
42
+ return;
43
+ }
44
+ invalid.push({ input: entry, line: idx + 1, error: describeError(error).message });
45
+ }
46
+ first = false;
47
+ }
48
+ });
49
+ return { phones, invalid, duplicates, header };
50
+ }
8
51
  export const broadcastCommand = new Command('broadcast')
9
- .description('Bulk send WhatsApp template messages')
10
- .requiredOption('-t, --template <name>', 'Template name')
11
- .requiredOption('--phones <file>', 'CSV file with phone numbers (one per line)')
12
- .option('--var <vars...>', 'Body variables (format: name=value)')
13
- .option('--delay <ms>', 'Delay between messages in ms', '1000')
14
- .option('--dev', 'Use development environment', false)
15
- .action(async (options) => {
52
+ .description('Send an approved WhatsApp template to a list of numbers')
53
+ .requiredOption('-t, --template <name>', 'Template name exactly as approved in Meta')
54
+ .requiredOption('--phones <file>', 'Text file with the numbers: one per line (commas or semicolons also work)')
55
+ .option('--var <name=value...>', 'Body variables sent to every recipient, name=value pairs')
56
+ .option('--delay <ms>', 'Pause between messages in milliseconds', '1000')
57
+ .option('--dry-run', 'Validate the list and show what would be sent, without sending', false)
58
+ .addOption(jsonOption())
59
+ .option('--dev', 'Use development environment', false);
60
+ addYesOption(broadcastCommand);
61
+ broadcastCommand.action(async (options) => {
16
62
  try {
17
- const credentials = await getStoredCredentials();
18
- const messageClient = new Message({
19
- workspaceId: credentials.workspace,
20
- apiKey: credentials.apiKey,
21
- zone: credentials.zone,
22
- ...(options.dev && { customUrl: "http://localhost:5090" })
23
- });
24
- // Leer archivo de telefonos
25
- let phones;
63
+ const template = String(options.template || '').trim();
64
+ if (!template)
65
+ throw new CliError('Template name is required.', 'missing_template', 'Pass it with -t, --template <name>.');
66
+ const delay = Number(options.delay);
67
+ if (!Number.isFinite(delay) || delay < 0) {
68
+ throw new CliError(`Invalid --delay "${options.delay}".`, 'invalid_delay', 'Use a number of milliseconds, e.g. --delay 1000.');
69
+ }
70
+ const variablesBody = parseVariables(options.var, '--var');
71
+ let content;
26
72
  try {
27
- const content = await fs.readFile(options.phones, 'utf-8');
28
- phones = content.split('\n')
29
- .map(line => line.trim())
30
- .filter(line => line && !line.startsWith('#'));
73
+ content = await fs.readFile(options.phones, 'utf-8');
31
74
  }
32
75
  catch {
33
- throw new Error(`Could not read file: ${options.phones}`);
76
+ throw new CliError(`Could not read file: ${options.phones}`, 'file_not_found', 'Check the path to your list of numbers.');
77
+ }
78
+ const list = parsePhoneList(content);
79
+ const json = isJsonMode();
80
+ if (!json) {
81
+ console.log(section(options.dryRun ? 'WhatsApp Broadcast (dry run)' : 'WhatsApp Broadcast'));
82
+ logger.label('Template', template);
83
+ logger.label('Recipients', String(list.phones.length));
84
+ if (variablesBody.length > 0)
85
+ logger.label('Variables', variablesBody.map(v => `${v.variable}=${v.value}`).join(', '));
86
+ logger.label('Delay', `${delay}ms`);
87
+ if (list.header)
88
+ logger.dim(`Skipped header line: "${list.header}"`);
89
+ if (list.duplicates > 0)
90
+ logger.dim(`Skipped ${list.duplicates} duplicate number(s).`);
91
+ if (list.invalid.length > 0) {
92
+ logger.warning(`${list.invalid.length} invalid entr${list.invalid.length === 1 ? 'y' : 'ies'} (not sent):`);
93
+ list.invalid.forEach(i => console.log(theme.error(` ✖ line ${i.line}: "${i.input}"`)));
94
+ }
95
+ console.log();
34
96
  }
35
- if (phones.length === 0) {
36
- throw new Error('The file does not contain phone numbers');
97
+ if (list.phones.length === 0) {
98
+ throw new CliError('The file has no valid phone numbers.', 'no_recipients', 'Put one number per line in international format, e.g. +51912345678.');
37
99
  }
38
- // Parsear variables
39
- const variablesBody = parseVariables(options.var);
40
- console.log(section('WhatsApp Broadcast'));
41
- logger.label('Template', options.template);
42
- logger.label('Recipients', String(phones.length));
43
- logger.label('Delay', `${options.delay}ms`);
44
- if (variablesBody.length > 0) {
45
- logger.label('Variables', variablesBody.map(v => `${v.variable}=${v.value}`).join(', '));
100
+ if (options.dryRun) {
101
+ if (json) {
102
+ printJson({
103
+ dryRun: true, template, variables: variablesBody,
104
+ recipients: list.phones,
105
+ summary: { total: list.phones.length, invalid: list.invalid, duplicates: list.duplicates },
106
+ });
107
+ }
108
+ else {
109
+ list.phones.forEach(p => console.log(` ${theme.muted('•')} +${p}`));
110
+ logger.success(`Dry run: ${list.phones.length} message(s) would be sent with template "${template}". Nothing was sent.`);
111
+ }
112
+ if (list.invalid.length > 0)
113
+ process.exit(1);
114
+ return;
46
115
  }
47
- console.log();
48
- const delay = parseInt(options.delay) || 1000;
49
- let sent = 0;
50
- let failed = 0;
51
- for (let i = 0; i < phones.length; i++) {
52
- const phone = phones[i];
116
+ if (!(await confirmAction(`Send template "${template}" to ${list.phones.length} number(s)?`, options))) {
117
+ logger.dim('Aborted.');
118
+ process.exit(1);
119
+ }
120
+ const messageClient = await getMessageClient(options.dev);
121
+ const results = [];
122
+ const tty = !json && process.stdout.isTTY;
123
+ const total = list.phones.length;
124
+ const draw = (done) => {
125
+ if (tty)
126
+ process.stdout.write(`\r\x1b[2K ${progressBar(done, total)}`);
127
+ };
128
+ draw(0);
129
+ for (let i = 0; i < total; i++) {
130
+ const phone = list.phones[i];
53
131
  try {
54
- const params = {
55
- to: phone,
56
- template: options.template,
57
- };
132
+ const params = { to: phone, template };
58
133
  if (variablesBody.length > 0)
59
134
  params.variablesBody = variablesBody;
60
- await messageClient.onConversation(params);
61
- sent++;
62
- }
63
- catch {
64
- failed++;
65
- console.log(theme.error(` ✖ Failed: ${phone}`));
135
+ const response = await messageClient.onConversation(params);
136
+ ensureApiSuccess(response, 'Not sent');
137
+ results.push({ phone, status: 'sent' });
66
138
  }
67
- // Progress
68
- console.log(` ${progressBar(i + 1, phones.length)}`);
69
- // Rate limiting
70
- if (i < phones.length - 1) {
71
- await new Promise(resolve => setTimeout(resolve, delay));
139
+ catch (error) {
140
+ const msg = describeError(error).message;
141
+ results.push({ phone, status: 'failed', error: msg });
142
+ if (!json) {
143
+ if (tty)
144
+ process.stdout.write('\r\x1b[2K');
145
+ console.log(theme.error(` ✖ +${phone}: ${msg}`));
146
+ }
72
147
  }
148
+ draw(i + 1);
149
+ if (i < total - 1 && delay > 0)
150
+ await new Promise(r => setTimeout(r, delay));
73
151
  }
74
- console.log(section('Result'));
75
- logger.label('Sent', theme.success(String(sent)));
76
- logger.label('Failed', failed > 0 ? theme.error(String(failed)) : '0');
77
- logger.label('Total', String(phones.length));
152
+ if (tty)
153
+ process.stdout.write('\n');
154
+ const sent = results.filter(r => r.status === 'sent').length;
155
+ const failed = total - sent;
156
+ if (json) {
157
+ printJson({
158
+ template, results,
159
+ summary: { total, sent, failed, invalid: list.invalid, duplicates: list.duplicates },
160
+ });
161
+ }
162
+ else {
163
+ console.log(section('Result'));
164
+ logger.label('Sent', theme.success(String(sent)));
165
+ logger.label('Failed', failed > 0 ? theme.error(String(failed)) : '0');
166
+ if (list.invalid.length > 0)
167
+ logger.label('Invalid (skipped)', theme.error(String(list.invalid.length)));
168
+ logger.label('Total', String(total));
169
+ console.log();
170
+ }
171
+ if (failed > 0 || list.invalid.length > 0)
172
+ process.exit(1);
78
173
  }
79
174
  catch (error) {
80
- const message = error?.message || 'Unknown error';
81
- logger.error(message);
82
- process.exit(1);
175
+ fail(error);
83
176
  }
84
177
  });
85
178
  addExamples(broadcastCommand, [
86
- { description: 'Broadcast a template to a phone list (default 1s delay)',
87
- command: 'plazbot whatsapp broadcast -t welcome --phones ./phones.csv' },
88
- { description: 'Broadcast with body variables',
89
- command: 'plazbot whatsapp broadcast -t promo --phones ./contacts.csv --var amount=20 code=PROMO20' },
90
- { description: 'Slow broadcast (3s delay) to avoid rate limits',
91
- command: 'plazbot whatsapp broadcast -t reminder --phones ./list.csv --delay 3000' },
179
+ { description: 'Check the list first: validates numbers, sends nothing',
180
+ command: 'plazbot whatsapp broadcast -t welcome --phones ./numbers.txt --dry-run' },
181
+ { description: 'Send a template to every number (asks for confirmation)',
182
+ command: 'plazbot whatsapp broadcast -t welcome --phones ./numbers.txt' },
183
+ { description: 'Same body variables for everyone, 3s between messages',
184
+ command: 'plazbot whatsapp broadcast -t promo --phones ./numbers.txt --var amount=20 code=PROMO20 --delay 3000' },
185
+ { description: 'Unattended run with per-number results as JSON',
186
+ command: 'plazbot whatsapp broadcast -t reminder --phones ./numbers.txt --yes --json > results.json' },
187
+ ]);
188
+ addNotes(broadcastCommand, [
189
+ 'The file is a plain list, e.g. "+51912345678" on each line (commas or semicolons also work). Blank lines, # comments and a header line are ignored; duplicates are sent once.',
190
+ 'Invalid numbers are listed and skipped. The command exits with code 1 if any number was invalid or any send failed.',
191
+ 'Variables are the same for every recipient. Templates can be sent outside the 24h window.',
92
192
  ]);
93
- function parseVariables(vars) {
94
- if (!vars)
95
- return [];
96
- return vars.map(v => {
97
- const [variable, ...rest] = v.split('=');
98
- return { variable: variable.trim(), value: rest.join('=').trim() };
99
- }).filter(v => v.variable && v.value);
100
- }
@@ -1,92 +1,194 @@
1
1
  import { Command } from 'commander';
2
- import { Agent } from 'plazbot';
3
- import { getStoredCredentials } from '../../utils/credentials.js';
4
2
  import { logger } from '../../utils/logger.js';
5
- import { addExamples } from '../../utils/help.js';
3
+ import { addExamples, addNotes } from '../../utils/help.js';
6
4
  import { createSpinner, createTable, theme, section, statusBadge } from '../../utils/ui.js';
5
+ import { jsonOption, isJsonMode, printJson, fail, CliError } from '../../utils/output.js';
6
+ import { addYesOption, confirmAction } from '../../utils/confirm.js';
7
+ import { phoneOption, normalizePhone } from '../../utils/phone.js';
8
+ import { fetchFullAgent, getAgentClient, phoneDigits, updateAgentMerged } from './shared.js';
9
+ const agentIdOf = (a) => String(a?.id || a?._id || '');
10
+ const whatsappKeys = (a) => (Array.isArray(a?.channels) ? a.channels : [])
11
+ .filter((c) => c?.channel === 'whatsapp' && c?.key)
12
+ .map((c) => String(c.key));
13
+ const hasNumber = (a, phone) => whatsappKeys(a).some(k => phoneDigits(k) === phone);
14
+ // ── channels ──────────────────────────────────────────────────────────────────
7
15
  export const channelsCommand = new Command('channels')
8
- .description('List agents with assigned WhatsApp channels')
16
+ .description('List agents that answer on a WhatsApp number')
17
+ .addOption(jsonOption())
9
18
  .option('--dev', 'Use development environment', false)
10
19
  .action(async (options) => {
11
20
  try {
12
- const credentials = await getStoredCredentials();
13
- const agent = new Agent({
14
- workspaceId: credentials.workspace,
15
- apiKey: credentials.apiKey,
16
- zone: credentials.zone,
17
- ...(options.dev && { customUrl: "http://localhost:5090" })
18
- });
19
- const spinner = createSpinner('Loading channels...');
20
- spinner.start();
21
- const agents = await agent.getAgents();
22
- spinner.stop();
21
+ const { agent } = await getAgentClient(options.dev);
22
+ const spinner = createSpinner('Loading channels...').start();
23
+ let agents;
24
+ try {
25
+ agents = (await agent.getAgents()) || [];
26
+ }
27
+ finally {
28
+ spinner.stop();
29
+ }
30
+ const rows = agents
31
+ .filter(a => whatsappKeys(a).length > 0)
32
+ .map(a => ({
33
+ agentId: agentIdOf(a),
34
+ name: String(a.name || ''),
35
+ phones: whatsappKeys(a).map(phoneDigits),
36
+ enabled: a.enable !== false,
37
+ }));
38
+ if (isJsonMode()) {
39
+ printJson(rows);
40
+ return;
41
+ }
23
42
  console.log(section('WhatsApp Channels'));
24
- const whatsappAgents = (agents || []).filter((a) => a.channels && a.channels.some((c) => c.channel === 'whatsapp'));
25
- if (whatsappAgents.length === 0) {
26
- console.log(theme.muted('\n No agents with WhatsApp configured'));
27
- console.log(theme.muted(' Use the wizard: plazbot agent create\n'));
43
+ if (rows.length === 0) {
44
+ console.log(theme.muted('\n No agents have a WhatsApp number assigned yet.'));
45
+ console.log(theme.muted(' Assign one: plazbot whatsapp assign -p +51912345678 -a <agentId>\n'));
28
46
  return;
29
47
  }
30
- const rows = whatsappAgents.map((a) => {
31
- const waChannel = a.channels.find((c) => c.channel === 'whatsapp');
32
- return [
33
- a.id || a._id,
34
- a.name,
35
- waChannel?.key || 'N/A',
36
- statusBadge(a.enable !== false),
37
- ];
38
- });
39
- console.log(createTable(['Agent ID', 'Name', 'WhatsApp Number', 'Status'], rows));
48
+ console.log(createTable(['Agent ID', 'Name', 'WhatsApp number', 'Status'], rows.map(r => [r.agentId, r.name, r.phones.map(p => `+${p}`).join(', '), statusBadge(r.enabled)])));
49
+ // Same number on several agents: warn, only one should answer.
50
+ const owners = new Map();
51
+ rows.forEach(r => r.phones.forEach(p => owners.set(p, [...(owners.get(p) || []), r.name || r.agentId])));
52
+ for (const [p, names] of owners) {
53
+ if (names.length > 1) {
54
+ logger.warning(`+${p} is assigned to ${names.length} agents (${names.join(', ')}). Fix it with: plazbot whatsapp assign -p +${p} -a <agentId>`);
55
+ }
56
+ }
40
57
  }
41
58
  catch (error) {
42
- const message = error instanceof Error ? error.message : 'Unknown error';
43
- logger.error(message);
44
- process.exit(1);
59
+ fail(error);
45
60
  }
46
61
  });
47
62
  addExamples(channelsCommand, [
48
- { description: 'List agents that have a WhatsApp number assigned',
63
+ { description: 'List agents with a WhatsApp number assigned',
49
64
  command: 'plazbot whatsapp channels' },
65
+ { description: 'Machine-readable list',
66
+ command: 'plazbot whatsapp channels --json' },
50
67
  ]);
51
68
  export const assignCommand = new Command('assign')
52
- .description('Assign an agent to a WhatsApp number')
53
- .argument('<phone>', 'WhatsApp number')
54
- .argument('<agentId>', 'Agent ID')
55
- .option('--dev', 'Use development environment', false)
56
- .action(async (phone, agentId, options) => {
69
+ .description('Make an agent answer a WhatsApp number (moves it from any other agent)')
70
+ .addOption(phoneOption('WhatsApp number in international format, e.g. +51912345678'))
71
+ .option('-a, --agent <agentId>', 'Agent that will answer the number (e.g. age_AbcDef123)')
72
+ .argument('[phone]', 'Legacy positional form of --phone')
73
+ .argument('[agentId]', 'Legacy positional form of --agent')
74
+ .addOption(jsonOption())
75
+ .option('--dev', 'Use development environment', false);
76
+ addYesOption(assignCommand);
77
+ assignCommand.action(async (posPhone, posAgent, options) => {
57
78
  try {
58
- const credentials = await getStoredCredentials();
59
- const agent = new Agent({
60
- workspaceId: credentials.workspace,
61
- apiKey: credentials.apiKey,
62
- zone: credentials.zone,
63
- ...(options.dev && { customUrl: "http://localhost:5090" })
64
- });
65
- const spinner = createSpinner('Loading agent...');
66
- spinner.start();
67
- const agentData = await agent.getAgentById({ id: agentId });
68
- spinner.stop();
69
- const channels = agentData.channels || [];
70
- // Verificar si ya tiene este canal
71
- const existing = channels.findIndex((c) => c.channel === 'whatsapp' && c.key === phone);
72
- if (existing >= 0) {
73
- logger.warning('This number is already assigned to this agent');
79
+ // `assign -p <phone> <agentId>`: the first positional is the agent.
80
+ if (options.phone && posPhone && !posAgent) {
81
+ posAgent = posPhone;
82
+ posPhone = undefined;
83
+ }
84
+ const phone = normalizePhone(options.phone ?? posPhone);
85
+ const agentId = String(options.agent ?? posAgent ?? '').trim();
86
+ if (!agentId) {
87
+ throw new CliError('Missing agent ID.', 'missing_agent', "Pass it with -a, --agent <agentId> (see 'plazbot agent list').");
88
+ }
89
+ const { agent, credentials } = await getAgentClient(options.dev);
90
+ const spinner = createSpinner('Loading agents...').start();
91
+ let target;
92
+ let others;
93
+ try {
94
+ target = await fetchFullAgent(agent, agentId);
95
+ const all = (await agent.getAgents()) || [];
96
+ others = all.filter(a => agentIdOf(a) && agentIdOf(a) !== agentId && hasNumber(a, phone));
97
+ }
98
+ finally {
99
+ spinner.stop();
100
+ }
101
+ const targetName = String(target.name || agentId);
102
+ const alreadyOnTarget = hasNumber(target, phone);
103
+ const removedFrom = others.map(a => ({ id: agentIdOf(a), name: String(a.name || '') }));
104
+ if (alreadyOnTarget && others.length === 0) {
105
+ if (isJsonMode()) {
106
+ printJson({ phone, agentId, agentName: targetName, assigned: true, changed: false, removedFrom: [] });
107
+ }
108
+ else {
109
+ logger.success(`+${phone} is already assigned to "${targetName}". Nothing to change.`);
110
+ }
74
111
  return;
75
112
  }
76
- // Agregar canal
77
- channels.push({ channel: 'whatsapp', key: phone, multianswer: false });
78
- const updateSpinner = createSpinner('Assigning...');
79
- updateSpinner.start();
80
- await agent.updateAgent(agentId, { channels });
81
- updateSpinner.succeed(`Agent "${agentData.name}" assigned to WhatsApp ${phone}`);
113
+ if (others.length > 0) {
114
+ if (!isJsonMode()) {
115
+ console.log(theme.muted(`\n +${phone} is currently assigned to:`));
116
+ removedFrom.forEach(a => console.log(` ${theme.muted('•')} ${a.name || a.id} ${theme.muted(`(${a.id})`)}`));
117
+ console.log();
118
+ }
119
+ const question = `Move +${phone} to "${targetName}" and remove it from ${others.length} other agent${others.length > 1 ? 's' : ''}?`;
120
+ if (!(await confirmAction(question, options))) {
121
+ logger.dim('Aborted.');
122
+ process.exit(1);
123
+ }
124
+ }
125
+ // 1) Assign to the target first, so the number is never left without an agent.
126
+ if (!alreadyOnTarget) {
127
+ const s = createSpinner(`Assigning +${phone} to "${targetName}"...`).start();
128
+ try {
129
+ const channels = [
130
+ ...(Array.isArray(target.channels) ? target.channels : []).map((c) => ({
131
+ channel: c.channel || '', key: c.key || '', multianswer: c.multianswer || false,
132
+ })),
133
+ { channel: 'whatsapp', key: phone, multianswer: false },
134
+ ];
135
+ await updateAgentMerged(credentials, options.dev, agentId, target, { channels });
136
+ }
137
+ catch (error) {
138
+ s.stop();
139
+ throw error;
140
+ }
141
+ s.succeed(`+${phone} assigned to "${targetName}"`);
142
+ }
143
+ // 2) Remove the number from every other agent (full document, only channels change).
144
+ const failures = [];
145
+ for (const other of removedFrom) {
146
+ const s = createSpinner(`Removing +${phone} from "${other.name || other.id}"...`).start();
147
+ try {
148
+ const full = await fetchFullAgent(agent, other.id);
149
+ const channels = (Array.isArray(full.channels) ? full.channels : [])
150
+ .filter((c) => !(c?.channel === 'whatsapp' && phoneDigits(c?.key) === phone));
151
+ await updateAgentMerged(credentials, options.dev, other.id, full, { channels });
152
+ s.succeed(`Removed +${phone} from "${other.name || other.id}"`);
153
+ }
154
+ catch (error) {
155
+ const msg = error instanceof Error ? error.message : String(error);
156
+ s.fail(`Could not remove +${phone} from "${other.name || other.id}": ${msg}`);
157
+ failures.push({ ...other, error: msg });
158
+ }
159
+ }
160
+ const moved = removedFrom.filter(a => !failures.some(f => f.id === a.id));
161
+ if (isJsonMode()) {
162
+ printJson({
163
+ phone, agentId, agentName: targetName, assigned: true, changed: true,
164
+ removedFrom: moved,
165
+ ...(failures.length > 0 && { failed: failures }),
166
+ });
167
+ }
168
+ else if (moved.length > 0) {
169
+ logger.success(`+${phone} now answered by "${targetName}" (moved from ${moved.map(a => a.name || a.id).join(', ')}).`);
170
+ }
171
+ if (failures.length > 0) {
172
+ if (!isJsonMode()) {
173
+ logger.warning(`+${phone} is still assigned to ${failures.length} other agent(s). Remove it in the panel or re-run this command.`);
174
+ }
175
+ process.exit(1);
176
+ }
82
177
  }
83
178
  catch (error) {
84
- const message = error instanceof Error ? error.message : 'Unknown error';
85
- logger.error(message);
86
- process.exit(1);
179
+ fail(error);
87
180
  }
88
181
  });
89
182
  addExamples(assignCommand, [
90
- { description: 'Assign a WhatsApp number to an agent',
91
- command: 'plazbot whatsapp assign 51912345678 agt_AbcDef123' },
183
+ { description: 'Make an agent answer a number (moves it from any other agent)',
184
+ command: 'plazbot whatsapp assign -p +51912345678 -a age_AbcDef123' },
185
+ { description: 'Skip the confirmation when the number moves between agents',
186
+ command: 'plazbot whatsapp assign -p +51912345678 -a age_AbcDef123 --yes' },
187
+ { description: 'Legacy positional form (still supported)',
188
+ command: 'plazbot whatsapp assign 51912345678 age_AbcDef123' },
189
+ ]);
190
+ addNotes(assignCommand, [
191
+ 'A number is answered by one agent: if another agent has it, it is removed from that agent (asks for confirmation).',
192
+ 'Only the channels of the affected agents change; the rest of their configuration is kept.',
193
+ 'List the current assignments with plazbot whatsapp channels.',
92
194
  ]);