850 lines
32 KiB
PHP
Executable File
850 lines
32 KiB
PHP
Executable File
#!/usr/bin/env php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/src/VoipMsResponse.php';
|
|
require_once __DIR__ . '/src/VoipMsClient.php';
|
|
|
|
use VoipMs\VoipMsClient;
|
|
|
|
/**
|
|
* @return array<string, array{category: string, description: string, params?: list<string>, examples?: list<string>}>
|
|
*/
|
|
function commandCatalog(): array
|
|
{
|
|
return [
|
|
'getBalance' => [
|
|
'category' => 'General',
|
|
'description' => 'Retrieve account balance. Add advanced=true for balance and call statistics.',
|
|
'params' => ['advanced'],
|
|
'examples' => ['getBalance', 'getBalance advanced=true'],
|
|
],
|
|
'getIP' => [
|
|
'category' => 'General',
|
|
'description' => 'Show the public IP address seen by the VoIP.ms API for whitelist setup.',
|
|
'examples' => ['getIP'],
|
|
],
|
|
'getServersInfo' => [
|
|
'category' => 'General',
|
|
'description' => 'Retrieve VoIP.ms server information, optionally filtered by server_pop.',
|
|
'params' => ['server_pop'],
|
|
],
|
|
'getTransactionHistory' => [
|
|
'category' => 'General',
|
|
'description' => 'Retrieve transaction history between two dates.',
|
|
'params' => ['from', 'to'],
|
|
],
|
|
'getSubAccounts' => [
|
|
'category' => 'Accounts',
|
|
'description' => 'Retrieve all subaccounts or one account when account is provided.',
|
|
'params' => ['account', 'client'],
|
|
],
|
|
'getRegistrationStatus' => [
|
|
'category' => 'Accounts',
|
|
'description' => 'Retrieve registration status for all accounts or a specific account.',
|
|
'params' => ['account'],
|
|
'examples' => ['getRegistrationStatus account=100000_device'],
|
|
],
|
|
'setSubAccount' => [
|
|
'category' => 'Accounts',
|
|
'description' => 'Update subaccount information.',
|
|
'params' => ['id', 'description', 'auth_type', 'password', 'ip', 'device_type', 'callerid_number', 'canada_routing', 'lock_international', 'international_route'],
|
|
'examples' => ['setSubAccount id=10236 callerid_number=5551234567'],
|
|
],
|
|
'getCDR' => [
|
|
'category' => 'Call Detail Records',
|
|
'description' => 'Retrieve call detail records using the raw VoIP.ms API parameter names.',
|
|
'params' => ['date_from', 'date_to', 'timezone', 'answered', 'noanswer', 'busy', 'failed', 'calltype', 'callbilling', 'account'],
|
|
'examples' => ['getCDR date_from=2026-04-01 date_to=2026-04-20 timezone=-4 calltype=incoming callbilling=all account=all'],
|
|
],
|
|
'cdr' => [
|
|
'category' => 'Call Detail Records',
|
|
'description' => 'Friendly alias for getCDR with clearer date, timezone, type, billing, account, and disposition options.',
|
|
'params' => ['--from', '--to', '--timezone', '--type', '--billing', '--account', '--answered', '--no-answer', '--busy', '--failed'],
|
|
'examples' => [
|
|
'cdr 2026-04-01 2026-04-20 --timezone=-4',
|
|
'cdr 2026-04-01 2026-04-20 --timezone=-4 --answered --no-answer',
|
|
'cdr --from 2026-04-01 --to 2026-04-20 --timezone=-4 --type incoming --billing billed',
|
|
],
|
|
],
|
|
'getCallAccounts' => [
|
|
'category' => 'Call Detail Records',
|
|
'description' => 'List account filter values accepted by CDR and call recording commands. Use the returned value field as account.',
|
|
'params' => ['client'],
|
|
'examples' => ['getCallAccounts', 'getCallAccounts client=561115'],
|
|
],
|
|
'getAccounts' => [
|
|
'category' => 'Call Detail Records',
|
|
'description' => 'Friendly alias for getCallAccounts. This does not list VoIP.ms login users; it lists account filter values for getCDR and call recording commands.',
|
|
'params' => ['client'],
|
|
'examples' => ['getAccounts', 'getAccounts client=561115'],
|
|
],
|
|
'getCallBilling' => [
|
|
'category' => 'Call Detail Records',
|
|
'description' => 'List billing filter values accepted by getCDR callbilling.',
|
|
],
|
|
'getCallTypes' => [
|
|
'category' => 'Call Detail Records',
|
|
'description' => 'List call type filter values accepted by getCDR calltype, including incoming, outgoing, and DID numbers.',
|
|
'params' => ['client'],
|
|
],
|
|
'getRates' => [
|
|
'category' => 'Call Detail Records',
|
|
'description' => 'Retrieve rates for a package and search term.',
|
|
'params' => ['package', 'query'],
|
|
],
|
|
'getTerminationRates' => [
|
|
'category' => 'Call Detail Records',
|
|
'description' => 'Retrieve termination rates for a route and search term.',
|
|
'params' => ['route', 'query'],
|
|
],
|
|
'getCallRecordings' => [
|
|
'category' => 'Call Recordings',
|
|
'description' => 'Retrieve call recordings. account is required and comes from getAccounts/getCallAccounts; call_type may be all, incoming, or outgoing.',
|
|
'params' => ['account', 'date_from', 'date_to', 'start', 'length', 'call_type'],
|
|
'examples' => [
|
|
'getAccounts',
|
|
'getCallRecordings account=all date_from=2026-04-01 date_to=2026-04-20 call_type=all',
|
|
],
|
|
],
|
|
'getCallRecording' => [
|
|
'category' => 'Call Recordings',
|
|
'description' => 'Retrieve one call recording and MP3 data.',
|
|
'params' => ['account', 'callrecording'],
|
|
'examples' => ['getCallRecording account=100000_VoIP callrecording=<value-from-getCallRecordings>'],
|
|
],
|
|
'sendCallRecordingEmail' => [
|
|
'category' => 'Call Recordings',
|
|
'description' => 'Email one call recording.',
|
|
'params' => ['account', 'email', 'callrecording'],
|
|
'examples' => ['sendCallRecordingEmail account=100000_VoIP email=you@example.com callrecording=<value-from-getCallRecordings>'],
|
|
],
|
|
'getDIDsInfo' => [
|
|
'category' => 'DIDs',
|
|
'description' => 'Retrieve DID information for all DIDs, a reseller client, a subaccount, or a specific DID.',
|
|
'params' => ['did', 'client', 'account'],
|
|
'examples' => ['getDIDsInfo', 'getDIDsInfo did=15551234567'],
|
|
],
|
|
'setDIDRouting' => [
|
|
'category' => 'DIDs',
|
|
'description' => 'Update routing for a DID.',
|
|
'params' => ['did', 'routing', 'account', 'voicemail', 'forwarding'],
|
|
],
|
|
'setDIDInfo' => [
|
|
'category' => 'DIDs',
|
|
'description' => 'Update DID information, including incoming CNAM lookup and Caller ID prefix.',
|
|
'params' => ['did', 'routing', 'failover_busy', 'failover_unreachable', 'failover_noanswer', 'voicemail', 'pop', 'dialtime', 'cnam', 'callerid_prefix', 'record_calls', 'note', 'port_out_pin', 'billing_type'],
|
|
'examples' => [
|
|
'setDIDInfo did=5551234567 cnam=1 callerid_prefix="SALES" note="Main line"',
|
|
'setDIDInfo did=5551234567 routing=account:100001_VoIP pop=3 dialtime=60 cnam=1 billing_type=2',
|
|
],
|
|
],
|
|
'getCallerIDFiltering' => [
|
|
'category' => 'Caller ID',
|
|
'description' => 'Retrieve CallerID Filtering rules, or one rule when filtering is provided.',
|
|
'params' => ['filtering'],
|
|
'examples' => ['getCallerIDFiltering', 'getCallerIDFiltering filtering=18915'],
|
|
],
|
|
'setCallerIDFiltering' => [
|
|
'category' => 'Caller ID',
|
|
'description' => 'Create or update a CallerID Filtering rule for incoming calls.',
|
|
'params' => ['filter', 'callerid', 'did', 'routing', 'failover_unreachable', 'failover_busy', 'failover_noanswer', 'note'],
|
|
'examples' => [
|
|
'setCallerIDFiltering callerid=5551234567 did=all routing=sys:busy note="Block caller"',
|
|
'setCallerIDFiltering callerid=0 did=5552223333 routing=none: note="Anonymous caller filter"',
|
|
'setCallerIDFiltering callerid=p:3220 did=all routing=account:100001_VoIP note="Phonebook group VIP route"',
|
|
],
|
|
],
|
|
'delCallerIDFiltering' => [
|
|
'category' => 'Caller ID',
|
|
'description' => 'Delete a CallerID Filtering rule by filtering ID.',
|
|
'params' => ['filtering'],
|
|
'examples' => ['delCallerIDFiltering filtering=18915'],
|
|
],
|
|
'getForwardings' => [
|
|
'category' => 'DIDs',
|
|
'description' => 'Retrieve forwarding entries.',
|
|
'params' => ['forwarding'],
|
|
],
|
|
'setForwarding' => [
|
|
'category' => 'DIDs',
|
|
'description' => 'Create or update a forwarding entry, optionally with Caller ID override.',
|
|
'params' => ['forwarding', 'phone_number', 'callerid_override', 'description', 'dtmf_digits', 'pause'],
|
|
'examples' => ['setForwarding phone_number=5553334444 callerid_override=5551234567 description="After hours cell"'],
|
|
],
|
|
'getPhonebook' => [
|
|
'category' => 'DIDs',
|
|
'description' => 'Retrieve phonebook entries. A bare numeric argument is treated as a group ID; a bare text argument is treated as a group name.',
|
|
'params' => ['phonebook', 'name', 'group', 'group_name'],
|
|
'examples' => [
|
|
'getPhonebook',
|
|
'getPhonebook 3220',
|
|
'getPhonebook Spam',
|
|
'getPhonebook name=Jane',
|
|
'getPhonebook group=3220',
|
|
'getPhonebook group_name=Spam',
|
|
'getPhonebook phonebook=32207',
|
|
],
|
|
],
|
|
'getPhonebookGroups' => [
|
|
'category' => 'DIDs',
|
|
'description' => 'Retrieve phonebook groups. A bare numeric argument is treated as a group ID; a bare text argument is treated as a group name.',
|
|
'params' => ['group', 'name'],
|
|
'examples' => ['getPhonebookGroups', 'getPhonebookGroups 3220', 'getPhonebookGroups Work', 'getPhonebookGroups name=Work'],
|
|
],
|
|
'setPhonebook' => [
|
|
'category' => 'DIDs',
|
|
'description' => 'Create or update a phonebook entry.',
|
|
'params' => ['phonebook', 'speed_dial', 'name', 'number', 'callerid', 'note', 'group'],
|
|
'examples' => [
|
|
'setPhonebook name="Jane Smith" number=5553334444 note="Janes Mobile"',
|
|
'setPhonebook phonebook=32207 name="Jane Smith" number=5553334444 group=3220',
|
|
],
|
|
],
|
|
'setPhonebookGroup' => [
|
|
'category' => 'DIDs',
|
|
'description' => 'Create or update a phonebook group. members is a semicolon-separated list of phonebook entry IDs.',
|
|
'params' => ['group', 'name', 'members'],
|
|
'examples' => [
|
|
'setPhonebookGroup name=Work',
|
|
'setPhonebookGroup group=3220 name=Work members="32207;32208"',
|
|
],
|
|
],
|
|
'delPhonebook' => [
|
|
'category' => 'DIDs',
|
|
'description' => 'Delete a phonebook entry by phonebook ID.',
|
|
'params' => ['phonebook'],
|
|
'examples' => ['delPhonebook phonebook=32207'],
|
|
],
|
|
'delPhonebookGroup' => [
|
|
'category' => 'DIDs',
|
|
'description' => 'Delete a phonebook group by group ID.',
|
|
'params' => ['group'],
|
|
'examples' => ['delPhonebookGroup group=3220'],
|
|
],
|
|
'getSMS' => [
|
|
'category' => 'DIDs',
|
|
'description' => 'Retrieve SMS messages by date range, type, DID, and contact.',
|
|
'params' => ['date_from', 'date_to', 'type', 'did', 'contact'],
|
|
'examples' => ['getSMS date_from=2026-04-01 date_to=2026-04-20 did=15551234567'],
|
|
],
|
|
'sendSMS' => [
|
|
'category' => 'DIDs',
|
|
'description' => 'Send an SMS message. Business A2P verification may be required by VoIP.ms.',
|
|
'params' => ['did', 'dst', 'message'],
|
|
'examples' => ['sendSMS did=15551234567 dst=15557654321 message="Hello"'],
|
|
],
|
|
'getMMS' => [
|
|
'category' => 'DIDs',
|
|
'description' => 'Retrieve MMS messages by date range, type, DID, and contact.',
|
|
'params' => ['date_from', 'date_to', 'type', 'did', 'contact'],
|
|
],
|
|
'sendMMS' => [
|
|
'category' => 'DIDs',
|
|
'description' => 'Send an MMS message. Business A2P verification may be required by VoIP.ms.',
|
|
'params' => ['did', 'dst', 'message', 'media1'],
|
|
],
|
|
'getVoicemailSetups' => [
|
|
'category' => 'DIDs',
|
|
'description' => 'Retrieve voicemail setup options.',
|
|
'params' => ['voicemail_setup'],
|
|
],
|
|
'getFaxMessages' => [
|
|
'category' => 'Fax',
|
|
'description' => 'Retrieve fax messages.',
|
|
'params' => ['fax', 'folder', 'message'],
|
|
],
|
|
'getFaxMessagePDF' => [
|
|
'category' => 'Fax',
|
|
'description' => 'Retrieve a fax message as base64 PDF data.',
|
|
'params' => ['message'],
|
|
],
|
|
'sendFaxMessage' => [
|
|
'category' => 'Fax',
|
|
'description' => 'Send a fax message to a destination number.',
|
|
'params' => ['fax', 'dst', 'file'],
|
|
],
|
|
'e911Info' => [
|
|
'category' => 'e911',
|
|
'description' => 'Retrieve e911 information for a DID.',
|
|
'params' => ['did'],
|
|
],
|
|
'e911Validate' => [
|
|
'category' => 'e911',
|
|
'description' => 'Validate e911 information before provisioning.',
|
|
'params' => ['did', 'address_type', 'address', 'city', 'state', 'zip', 'country'],
|
|
],
|
|
'getLNPStatus' => [
|
|
'category' => 'Local Number Portability',
|
|
'description' => 'Retrieve status for a portability process.',
|
|
'params' => ['port'],
|
|
],
|
|
'getLNPList' => [
|
|
'category' => 'Local Number Portability',
|
|
'description' => 'Retrieve the full list of portability processes.',
|
|
],
|
|
];
|
|
}
|
|
|
|
function main(array $argv): int
|
|
{
|
|
array_shift($argv);
|
|
|
|
if ($argv === [] || in_array($argv[0], ['-h', '--help', 'help'], true)) {
|
|
$command = $argv[1] ?? null;
|
|
printHelp(is_string($command) ? $command : null);
|
|
return 0;
|
|
}
|
|
|
|
if ($argv[0] === 'list') {
|
|
printCommandList();
|
|
return 0;
|
|
}
|
|
|
|
if ($argv[0] === 'completion') {
|
|
$shell = $argv[1] ?? 'bash';
|
|
if ($shell !== 'bash') {
|
|
fwrite(STDERR, "Only bash completion is supported right now.\n");
|
|
return 64;
|
|
}
|
|
|
|
printBashCompletion();
|
|
return 0;
|
|
}
|
|
|
|
$method = array_shift($argv);
|
|
if ($method === null || $method === '') {
|
|
printHelp(null);
|
|
return 64;
|
|
}
|
|
|
|
if (($argv[0] ?? null) === 'help') {
|
|
printHelp($method);
|
|
return 0;
|
|
}
|
|
|
|
try {
|
|
if ($method === 'getAccounts') {
|
|
$method = 'getCallAccounts';
|
|
$parsed = parseArguments($argv);
|
|
} elseif ($method === 'cdr') {
|
|
$method = 'getCDR';
|
|
$parsed = parseCdrArguments($argv);
|
|
} elseif (in_array($method, ['getPhonebook', 'getPhonebookGroups'], true)) {
|
|
$parsed = parsePhonebookLookupArguments($method, $argv);
|
|
} else {
|
|
$parsed = parseArguments($argv);
|
|
}
|
|
} catch (InvalidArgumentException $exception) {
|
|
fwrite(STDERR, $exception->getMessage() . "\n");
|
|
return 64;
|
|
}
|
|
|
|
$username = $parsed['options']['username'] ?? getenv('VOIPMS_API_USERNAME') ?: '';
|
|
$password = $parsed['options']['password'] ?? getenv('VOIPMS_API_PASSWORD') ?: '';
|
|
$endpoint = $parsed['options']['endpoint'] ?? getenv('VOIPMS_API_ENDPOINT') ?: VoipMsClient::DEFAULT_ENDPOINT;
|
|
$format = $parsed['options']['format'] ?? 'pretty';
|
|
|
|
if (($parsed['options']['dry-run'] ?? '') === '1') {
|
|
printDryRun($method, $parsed['params'], $format);
|
|
return 0;
|
|
}
|
|
|
|
if ($username === '' || $password === '') {
|
|
fwrite(STDERR, "Missing credentials. Set VOIPMS_API_USERNAME and VOIPMS_API_PASSWORD, or pass --username and --password.\n");
|
|
return 78;
|
|
}
|
|
|
|
$client = new VoipMsClient($username, $password, $endpoint);
|
|
|
|
try {
|
|
$response = $client->call($method, $parsed['params']);
|
|
} catch (RuntimeException $exception) {
|
|
fwrite(STDERR, $exception->getMessage() . "\n");
|
|
return 69;
|
|
}
|
|
|
|
printResponse($response, $format);
|
|
printActionableHint($method, $response);
|
|
|
|
$decoded = $response['decoded'];
|
|
if (is_array($decoded) && isset($decoded['status']) && $decoded['status'] !== 'success') {
|
|
return 70;
|
|
}
|
|
|
|
return $response['http_code'] >= 400 ? 69 : 0;
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $args
|
|
* @return array{options: array<string, string>, params: array<string, string>}
|
|
*/
|
|
function parseArguments(array $args): array
|
|
{
|
|
$options = [];
|
|
$params = [];
|
|
|
|
for ($i = 0; $i < count($args); $i++) {
|
|
$arg = $args[$i];
|
|
|
|
if (str_starts_with($arg, '--')) {
|
|
$option = substr($arg, 2);
|
|
$value = null;
|
|
|
|
if (str_contains($option, '=')) {
|
|
[$option, $value] = explode('=', $option, 2);
|
|
}
|
|
|
|
if ($option === 'param') {
|
|
$param = $value ?? $args[++$i] ?? null;
|
|
if ($param === null || !str_contains($param, '=')) {
|
|
throw new InvalidArgumentException('--param expects key=value.');
|
|
}
|
|
|
|
[$key, $paramValue] = explode('=', $param, 2);
|
|
$params[$key] = $paramValue;
|
|
continue;
|
|
}
|
|
|
|
if ($option === 'dry-run') {
|
|
$options['dry-run'] = '1';
|
|
continue;
|
|
}
|
|
|
|
if (!in_array($option, ['username', 'password', 'endpoint', 'format'], true)) {
|
|
throw new InvalidArgumentException("Unknown option --{$option}.");
|
|
}
|
|
|
|
$options[$option] = $value ?? $args[++$i] ?? '';
|
|
if ($options[$option] === '') {
|
|
throw new InvalidArgumentException("--{$option} expects a value.");
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!str_contains($arg, '=')) {
|
|
throw new InvalidArgumentException("Unexpected argument '{$arg}'. Use key=value for API parameters.");
|
|
}
|
|
|
|
[$key, $value] = explode('=', $arg, 2);
|
|
if ($key === '') {
|
|
throw new InvalidArgumentException("Invalid empty parameter name in '{$arg}'.");
|
|
}
|
|
|
|
$params[$key] = $value;
|
|
}
|
|
|
|
if (isset($options['format']) && !in_array($options['format'], ['pretty', 'json', 'raw'], true)) {
|
|
throw new InvalidArgumentException('--format must be one of: pretty, json, raw.');
|
|
}
|
|
|
|
return ['options' => $options, 'params' => $params];
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $args
|
|
* @return array{options: array<string, string>, params: array<string, string>}
|
|
*/
|
|
function parsePhonebookLookupArguments(string $method, array $args): array
|
|
{
|
|
$parsed = parseLenientArguments($args);
|
|
$positionals = $parsed['positionals'];
|
|
unset($parsed['positionals']);
|
|
|
|
if (count($positionals) > 1) {
|
|
throw new InvalidArgumentException("{$method} accepts at most one bare argument.");
|
|
}
|
|
|
|
if (isset($parsed['params']['key'])) {
|
|
$positionals[] = $parsed['params']['key'];
|
|
unset($parsed['params']['key']);
|
|
}
|
|
|
|
if (isset($positionals[0])) {
|
|
$value = $positionals[0];
|
|
if ($method === 'getPhonebookGroups') {
|
|
$parsed['params'][ctype_digit($value) ? 'group' : 'name'] = $value;
|
|
} else {
|
|
$parsed['params'][ctype_digit($value) ? 'group' : 'group_name'] = $value;
|
|
}
|
|
}
|
|
|
|
return $parsed;
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $args
|
|
* @return array{options: array<string, string>, params: array<string, string>, positionals: list<string>}
|
|
*/
|
|
function parseLenientArguments(array $args): array
|
|
{
|
|
$options = [];
|
|
$params = [];
|
|
$positionals = [];
|
|
|
|
for ($i = 0; $i < count($args); $i++) {
|
|
$arg = $args[$i];
|
|
|
|
if (str_starts_with($arg, '--')) {
|
|
$option = substr($arg, 2);
|
|
$value = null;
|
|
|
|
if (str_contains($option, '=')) {
|
|
[$option, $value] = explode('=', $option, 2);
|
|
}
|
|
|
|
if ($option === 'param') {
|
|
$param = $value ?? $args[++$i] ?? null;
|
|
if ($param === null || !str_contains($param, '=')) {
|
|
throw new InvalidArgumentException('--param expects key=value.');
|
|
}
|
|
|
|
[$key, $paramValue] = explode('=', $param, 2);
|
|
$params[$key] = $paramValue;
|
|
continue;
|
|
}
|
|
|
|
if ($option === 'dry-run') {
|
|
$options['dry-run'] = '1';
|
|
continue;
|
|
}
|
|
|
|
if (!in_array($option, ['username', 'password', 'endpoint', 'format'], true)) {
|
|
throw new InvalidArgumentException("Unknown option --{$option}.");
|
|
}
|
|
|
|
$options[$option] = $value ?? $args[++$i] ?? '';
|
|
if ($options[$option] === '') {
|
|
throw new InvalidArgumentException("--{$option} expects a value.");
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (str_contains($arg, '=')) {
|
|
[$key, $value] = explode('=', $arg, 2);
|
|
if ($key === '') {
|
|
throw new InvalidArgumentException("Invalid empty parameter name in '{$arg}'.");
|
|
}
|
|
|
|
$params[$key] = $value;
|
|
continue;
|
|
}
|
|
|
|
$positionals[] = $arg;
|
|
}
|
|
|
|
if (isset($options['format']) && !in_array($options['format'], ['pretty', 'json', 'raw'], true)) {
|
|
throw new InvalidArgumentException('--format must be one of: pretty, json, raw.');
|
|
}
|
|
|
|
return ['options' => $options, 'params' => $params, 'positionals' => $positionals];
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $args
|
|
* @return array{options: array<string, string>, params: array<string, string>}
|
|
*/
|
|
function parseCdrArguments(array $args): array
|
|
{
|
|
$options = [];
|
|
$params = [];
|
|
$positionals = [];
|
|
|
|
for ($i = 0; $i < count($args); $i++) {
|
|
$arg = $args[$i];
|
|
|
|
if (str_starts_with($arg, '--')) {
|
|
$option = substr($arg, 2);
|
|
$value = null;
|
|
|
|
if (str_contains($option, '=')) {
|
|
[$option, $value] = explode('=', $option, 2);
|
|
}
|
|
|
|
if (in_array($option, ['answered', 'busy', 'failed'], true)) {
|
|
$params[$option] = $value ?? '1';
|
|
continue;
|
|
}
|
|
|
|
if (in_array($option, ['noanswer', 'no-answer'], true)) {
|
|
$params['noanswer'] = $value ?? '1';
|
|
continue;
|
|
}
|
|
|
|
if ($option === 'dry-run') {
|
|
$options['dry-run'] = '1';
|
|
continue;
|
|
}
|
|
|
|
if (in_array($option, ['username', 'password', 'endpoint', 'format'], true)) {
|
|
$options[$option] = $value ?? $args[++$i] ?? '';
|
|
if ($options[$option] === '') {
|
|
throw new InvalidArgumentException("--{$option} expects a value.");
|
|
}
|
|
continue;
|
|
}
|
|
|
|
$paramName = normalizeCdrParamName($option);
|
|
if ($paramName === null) {
|
|
throw new InvalidArgumentException("Unknown cdr option --{$option}.");
|
|
}
|
|
|
|
$params[$paramName] = $value ?? $args[++$i] ?? '';
|
|
if ($params[$paramName] === '') {
|
|
throw new InvalidArgumentException("--{$option} expects a value.");
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (str_contains($arg, '=')) {
|
|
[$key, $value] = explode('=', $arg, 2);
|
|
$paramName = normalizeCdrParamName($key);
|
|
|
|
if ($paramName === null) {
|
|
throw new InvalidArgumentException("Unknown cdr parameter '{$key}'.");
|
|
}
|
|
|
|
$params[$paramName] = $value;
|
|
continue;
|
|
}
|
|
|
|
$positionals[] = $arg;
|
|
}
|
|
|
|
if (count($positionals) > 2) {
|
|
throw new InvalidArgumentException('cdr accepts at most two positional arguments: date_from date_to.');
|
|
}
|
|
|
|
if (isset($positionals[0]) && !isset($params['date_from'])) {
|
|
$params['date_from'] = $positionals[0];
|
|
}
|
|
|
|
if (isset($positionals[1]) && !isset($params['date_to'])) {
|
|
$params['date_to'] = $positionals[1];
|
|
}
|
|
|
|
$envTimezone = getenv('VOIPMS_TIMEZONE');
|
|
if (!isset($params['timezone']) && is_string($envTimezone) && $envTimezone !== '') {
|
|
$params['timezone'] = $envTimezone;
|
|
}
|
|
|
|
foreach (['date_from', 'date_to', 'timezone'] as $required) {
|
|
if (($params[$required] ?? '') === '') {
|
|
throw new InvalidArgumentException("cdr requires {$required}. Example: voipms-cli.php cdr 2026-04-01 2026-04-20 --timezone=-4");
|
|
}
|
|
}
|
|
|
|
$callStatuses = ['answered', 'noanswer', 'busy', 'failed'];
|
|
$hasCallStatus = false;
|
|
foreach ($callStatuses as $status) {
|
|
if (isset($params[$status])) {
|
|
$hasCallStatus = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$hasCallStatus) {
|
|
foreach ($callStatuses as $status) {
|
|
$params[$status] = '1';
|
|
}
|
|
}
|
|
|
|
if (isset($options['format']) && !in_array($options['format'], ['pretty', 'json', 'raw'], true)) {
|
|
throw new InvalidArgumentException('--format must be one of: pretty, json, raw.');
|
|
}
|
|
|
|
return ['options' => $options, 'params' => $params];
|
|
}
|
|
|
|
function normalizeCdrParamName(string $name): ?string
|
|
{
|
|
return match ($name) {
|
|
'from', 'date-from', 'date_from' => 'date_from',
|
|
'to', 'date-to', 'date_to' => 'date_to',
|
|
'timezone', 'tz' => 'timezone',
|
|
'type', 'call-type', 'call_type', 'calltype', 'did' => 'calltype',
|
|
'billing', 'call-billing', 'call_billing', 'callbilling' => 'callbilling',
|
|
'account' => 'account',
|
|
'answered' => 'answered',
|
|
'noanswer', 'no-answer' => 'noanswer',
|
|
'busy' => 'busy',
|
|
'failed' => 'failed',
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
function printHelp(?string $command): void
|
|
{
|
|
$catalog = commandCatalog();
|
|
|
|
if ($command !== null) {
|
|
$entry = $catalog[$command] ?? null;
|
|
if ($entry === null) {
|
|
echo "{$command}\n\n";
|
|
echo "No local help is available for this method. The CLI can still call it generically:\n";
|
|
echo " voipms-cli.php {$command} key=value\n";
|
|
echo " php voipms-cli.php {$command} key=value\n";
|
|
return;
|
|
}
|
|
|
|
echo "{$command}\n\n";
|
|
echo $entry['description'] . "\n";
|
|
if (($entry['params'] ?? []) !== []) {
|
|
echo "\nCommon parameters:\n";
|
|
foreach ($entry['params'] as $param) {
|
|
echo " {$param}\n";
|
|
}
|
|
}
|
|
if (($entry['examples'] ?? []) !== []) {
|
|
echo "\nExamples:\n";
|
|
foreach ($entry['examples'] as $example) {
|
|
echo " voipms-cli.php {$example}\n";
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
echo "voipms-cli.php <command> [key=value ...] [options]\n\n";
|
|
echo "Commands:\n";
|
|
echo " help [command] Show general help or help for one command\n";
|
|
echo " <command> help Show help for one API command\n";
|
|
echo " list List locally documented API commands\n";
|
|
echo " completion bash Print bash completion code\n";
|
|
echo " cdr Friendly alias for getCDR\n";
|
|
echo " <api-method> Call any VoIP.ms REST/JSON API method\n\n";
|
|
echo "Options:\n";
|
|
echo " --username VALUE API username, defaults to VOIPMS_API_USERNAME\n";
|
|
echo " --password VALUE API password, defaults to VOIPMS_API_PASSWORD\n";
|
|
echo " --endpoint URL API endpoint, defaults to VOIPMS_API_ENDPOINT or VoIP.ms REST URL\n";
|
|
echo " --format FORMAT pretty, json, or raw. Default: pretty\n";
|
|
echo " --param key=value Pass an API parameter when shell quoting is awkward\n";
|
|
echo " --dry-run Show the API method and parameters without making a request\n\n";
|
|
echo "Examples:\n";
|
|
echo " VOIPMS_API_USERNAME='you@example.com' VOIPMS_API_PASSWORD='...' voipms-cli.php getBalance\n";
|
|
echo " voipms-cli.php cdr 2026-04-01 2026-04-20 --timezone=-4\n";
|
|
echo " voipms-cli.php getCDR date_from=2026-04-01 date_to=2026-04-20 timezone=-4\n";
|
|
echo " voipms-cli.php sendSMS did=15551234567 dst=15557654321 message='Hello'\n";
|
|
}
|
|
|
|
function printCommandList(): void
|
|
{
|
|
$byCategory = [];
|
|
foreach (commandCatalog() as $command => $entry) {
|
|
$byCategory[$entry['category']][$command] = $entry;
|
|
}
|
|
|
|
foreach ($byCategory as $category => $commands) {
|
|
echo "{$category}\n";
|
|
foreach ($commands as $command => $entry) {
|
|
echo " " . str_pad($command, 28) . $entry['description'] . "\n";
|
|
}
|
|
echo "\n";
|
|
}
|
|
}
|
|
|
|
function printBashCompletion(): void
|
|
{
|
|
$commands = array_merge(['help', 'list', 'completion'], array_keys(commandCatalog()));
|
|
sort($commands);
|
|
$commandWords = implode(' ', $commands);
|
|
|
|
echo <<<'BASH'
|
|
# Source this file in bash before using it:
|
|
# source <(./voipms-cli.php completion bash)
|
|
#
|
|
# To make it persistent for this project, add that line to your shell profile
|
|
# or write this output to a file loaded by bash-completion.
|
|
|
|
_voipms_cli()
|
|
{
|
|
local cur prev commands
|
|
COMPREPLY=()
|
|
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
|
BASH;
|
|
|
|
echo "\n commands=\"{$commandWords}\"\n";
|
|
|
|
echo <<<'BASH'
|
|
if [[ ${COMP_CWORD} -eq 1 ]]; then
|
|
COMPREPLY=( $(compgen -W "${commands}" -- "${cur}") )
|
|
return 0
|
|
fi
|
|
|
|
case "${prev}" in
|
|
--format)
|
|
COMPREPLY=( $(compgen -W "pretty json raw" -- "${cur}") )
|
|
return 0
|
|
;;
|
|
--username|--password|--endpoint|--param)
|
|
return 0
|
|
;;
|
|
esac
|
|
|
|
if [[ "${cur}" == --* ]]; then
|
|
COMPREPLY=( $(compgen -W "--username --password --endpoint --format --param --dry-run" -- "${cur}") )
|
|
return 0
|
|
fi
|
|
}
|
|
complete -o default -F _voipms_cli voipms-cli.php ./voipms-cli.php
|
|
BASH;
|
|
}
|
|
|
|
/**
|
|
* @param array{decoded: mixed, raw: string, http_code: int} $response
|
|
*/
|
|
function printResponse(array $response, string $format): void
|
|
{
|
|
if ($format === 'raw' || $response['decoded'] === null) {
|
|
echo $response['raw'];
|
|
if (!str_ends_with($response['raw'], "\n")) {
|
|
echo "\n";
|
|
}
|
|
return;
|
|
}
|
|
|
|
$flags = $format === 'pretty' ? JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES : JSON_UNESCAPED_SLASHES;
|
|
echo json_encode($response['decoded'], $flags) . "\n";
|
|
}
|
|
|
|
/**
|
|
* @param array{decoded: mixed, raw: string, http_code: int} $response
|
|
*/
|
|
function printActionableHint(string $method, array $response): void
|
|
{
|
|
$decoded = $response['decoded'];
|
|
|
|
if (!is_array($decoded)) {
|
|
return;
|
|
}
|
|
|
|
if ($method === 'getCDR' && ($decoded['status'] ?? null) === 'no_callstatus') {
|
|
fwrite(STDERR, "\nHint: getCDR requires at least one call status. The cdr alias now defaults to all statuses; raw getCDR needs answered=1, noanswer=1, busy=1, or failed=1.\n");
|
|
fwrite(STDERR, "Examples:\n");
|
|
fwrite(STDERR, " voipms-cli.php cdr 2026-04-01 2026-04-20 --timezone=-4\n");
|
|
fwrite(STDERR, " voipms-cli.php getCDR date_from=2026-04-01 date_to=2026-04-20 timezone=-4 answered=1 noanswer=1 busy=1 failed=1\n");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $params
|
|
*/
|
|
function printDryRun(string $method, array $params, string $format): void
|
|
{
|
|
$payload = [
|
|
'method' => $method,
|
|
'params' => $params,
|
|
];
|
|
|
|
if ($format === 'raw') {
|
|
echo http_build_query(array_merge(['method' => $method, 'content_type' => 'json'], $params)) . "\n";
|
|
return;
|
|
}
|
|
|
|
$flags = $format === 'pretty' ? JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES : JSON_UNESCAPED_SLASHES;
|
|
echo json_encode($payload, $flags) . "\n";
|
|
}
|
|
|
|
exit(main($argv));
|