Files
php-voip.ms/voipms-cli.php
T

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));