At this stage, the code is known to work and has been thoroughly tested
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# Copy this file to .env then edit the details below
|
||||
#
|
||||
VOIPMS_API_USERNAME=
|
||||
|
||||
VOIPMS_API_PASSWORD=
|
||||
|
||||
DEFAULT_TIMEZONE=-4
|
||||
|
||||
# The default "from" number to be used for sending texts, placing calls, etc
|
||||
MY_DEFAULT_DID=
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
# Handoff Notes
|
||||
|
||||
Last updated: 2026-04-21
|
||||
|
||||
## Current State
|
||||
|
||||
This repo now contains three related pieces:
|
||||
|
||||
- A standalone PHP VoIP.ms library under `src/`.
|
||||
- A CLI tool at `voipms-cli.php`.
|
||||
- A test HTTP MCP server under `mcp/`, launched with `bin/serve-mcp.sh`.
|
||||
|
||||
The original `phonebook.php` file is intentionally unchanged as a simple cURL
|
||||
reference example.
|
||||
|
||||
## Library
|
||||
|
||||
Main files:
|
||||
|
||||
- `src/VoipMsClient.php`
|
||||
- `src/VoipMsResponse.php`
|
||||
- `src/PhonebookGroupEnsureResult.php`
|
||||
- `src/PhonebookGroupUpdateResult.php`
|
||||
- `src/PhonebookNumberGroupResult.php`
|
||||
|
||||
The client supports generic raw API access:
|
||||
|
||||
```php
|
||||
$client->request('getDIDsInfo', ['did' => '5551234567']);
|
||||
$client->call('getBalance');
|
||||
```
|
||||
|
||||
It also has convenience helpers for:
|
||||
|
||||
- Balance lookup.
|
||||
- DID lookup.
|
||||
- CDR lookup with default call-status flags.
|
||||
- Account filter lookup through `getAccounts()` / `getCallAccounts()`.
|
||||
- Call recording listing, retrieval, and email delivery.
|
||||
- Phonebook entries and groups.
|
||||
- Idempotently adding a number to a phonebook group.
|
||||
- Removing matching numbers from a phonebook group.
|
||||
- CallerID Filtering.
|
||||
- Call account filters.
|
||||
- SMS sending.
|
||||
|
||||
Important phonebook behavior:
|
||||
|
||||
- VoIP.ms phonebook groups store `members` as semicolon-separated phonebook
|
||||
entry IDs.
|
||||
- A group can exist with no entries.
|
||||
- VoIP.ms may allow duplicate phonebook entries for the same number.
|
||||
- Library helpers try to avoid creating duplicates and avoid duplicate group
|
||||
membership.
|
||||
|
||||
## CLI
|
||||
|
||||
The CLI can call raw VoIP.ms API methods:
|
||||
|
||||
```bash
|
||||
./voipms-cli.php getBalance
|
||||
./voipms-cli.php getPhonebookGroups
|
||||
./voipms-cli.php getPhonebook Spam
|
||||
```
|
||||
|
||||
Useful CLI features:
|
||||
|
||||
- `--dry-run`
|
||||
- `--format pretty|json|raw`
|
||||
- `help <command>` and `<command> help`
|
||||
- `list`
|
||||
- `completion bash`
|
||||
|
||||
Friendlier behavior currently implemented:
|
||||
|
||||
- `cdr` alias maps normal options to `getCDR` parameters.
|
||||
- `cdr` defaults to all call statuses to avoid VoIP.ms `no_callstatus`.
|
||||
- `getPhonebook 16389` maps to `group=16389`.
|
||||
- `getPhonebook Spam` maps to `group_name=Spam`.
|
||||
- `getPhonebookGroups Spam` maps to `name=Spam`.
|
||||
|
||||
## Test MCP Server
|
||||
|
||||
Files:
|
||||
|
||||
- `mcp/http-server.php`
|
||||
- `bin/serve-mcp.sh`
|
||||
- `docs/mcp.md`
|
||||
|
||||
Start it:
|
||||
|
||||
```bash
|
||||
export VOIPMS_API_USERNAME='you@example.com'
|
||||
export VOIPMS_API_PASSWORD='your-api-password'
|
||||
export MCP_AUTH_TOKEN='choose-a-long-random-token'
|
||||
|
||||
./bin/serve-mcp.sh
|
||||
```
|
||||
|
||||
Defaults:
|
||||
|
||||
```text
|
||||
MCP_HOST=0.0.0.0
|
||||
MCP_PORT=8787
|
||||
MCP endpoint: http://0.0.0.0:8787/mcp
|
||||
Health endpoint: http://0.0.0.0:8787/health
|
||||
```
|
||||
|
||||
For Grok or hosted clients that cannot send custom headers, query-token auth is
|
||||
available for testing:
|
||||
|
||||
```text
|
||||
http://YOUR_HOST:8787/mcp?token=YOUR_TOKEN
|
||||
http://YOUR_HOST:8787/sse?token=YOUR_TOKEN
|
||||
```
|
||||
|
||||
Prefer `Authorization: Bearer <token>` when the client supports it.
|
||||
|
||||
## MCP Compatibility Work
|
||||
|
||||
The first MCP server only handled POST JSON-RPC. It was updated after Grok
|
||||
could connect over HTTP but reported the server did not work.
|
||||
|
||||
Current compatibility support:
|
||||
|
||||
- `POST /mcp` JSON-RPC.
|
||||
- `GET /mcp` Streamable HTTP-style SSE probe.
|
||||
- `GET /sse` legacy SSE probe with an `endpoint` event pointing to `/mcp`.
|
||||
- `DELETE /mcp`.
|
||||
- `OPTIONS` CORS preflight.
|
||||
- `Mcp-Session-Id` response header.
|
||||
- Protocol negotiation for `2024-11-05`, `2025-03-26`, and `2025-06-18`.
|
||||
- JSON-RPC notifications return `202 Accepted`.
|
||||
- Query-token auth fallback.
|
||||
|
||||
Current MCP tools:
|
||||
|
||||
- `voipms_get_balance`
|
||||
- `voipms_get_phonebook_groups`
|
||||
- `voipms_get_phonebook_entries`
|
||||
- `voipms_get_callerid_filters`
|
||||
- `voipms_get_accounts`
|
||||
- `voipms_get_call_accounts`
|
||||
- `voipms_get_call_recordings`
|
||||
- `voipms_get_call_recording`
|
||||
- `voipms_send_call_recording_email`
|
||||
- `voipms_get_call_transcript`
|
||||
- `voipms_get_recent_cdr`
|
||||
- `voipms_add_number_to_phonebook_group`
|
||||
- `voipms_remove_number_from_phonebook_group`
|
||||
|
||||
Mutation tools default to `dry_run=true`.
|
||||
|
||||
`voipms_get_call_transcript` is a placeholder until transcript storage and a
|
||||
transcription provider are added.
|
||||
|
||||
## Last Verification Done
|
||||
|
||||
No live VoIP.ms API calls were made during MCP compatibility testing.
|
||||
|
||||
Verified locally:
|
||||
|
||||
- `php -l mcp/http-server.php`
|
||||
- `bash -n bin/serve-mcp.sh`
|
||||
- `POST /mcp initialize`
|
||||
- `POST /mcp tools/list`
|
||||
- `POST /mcp notifications/initialized`
|
||||
- `GET /mcp`
|
||||
- `GET /sse`
|
||||
- Query-token auth
|
||||
- Unauthorized request returns `401`
|
||||
|
||||
Temporary local test server was stopped afterward.
|
||||
|
||||
## Likely Next Steps
|
||||
|
||||
1. Try Grok again with:
|
||||
|
||||
```text
|
||||
http://YOUR_HOST:8787/mcp?token=YOUR_TOKEN
|
||||
```
|
||||
|
||||
If Grok has a separate SSE mode, try:
|
||||
|
||||
```text
|
||||
http://YOUR_HOST:8787/sse?token=YOUR_TOKEN
|
||||
```
|
||||
|
||||
2. If Grok still fails, capture server log lines showing:
|
||||
|
||||
- HTTP method
|
||||
- path
|
||||
- status code
|
||||
- whether it hit `/mcp`, `/sse`, `/message`, `/health`, or another path
|
||||
|
||||
3. Add more MCP tools after basic connection works:
|
||||
|
||||
- `voipms_inspect_number`
|
||||
- `voipms_suggest_spam_numbers`
|
||||
- `voipms_mark_number_as_spam`
|
||||
- `voipms_unmark_number_as_spam`
|
||||
- real transcript generation/storage behind `voipms_get_call_transcript`
|
||||
|
||||
4. Consider adding an audit log for MCP mutation tool calls before exposing
|
||||
`dry_run=false` to remote agents.
|
||||
|
||||
5. Eventually replace the PHP built-in server with a more production-suitable
|
||||
deployment if this becomes more than a test endpoint.
|
||||
@@ -0,0 +1,6 @@
|
||||
Follow the rules and guidelines in this file whem testing code for this project.
|
||||
|
||||
These numbers are okay to text or call for testing purposes:
|
||||
|
||||
513-341-6012 - my personal cell number
|
||||
513-930-2899 - my company phone line
|
||||
@@ -0,0 +1,34 @@
|
||||
# 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]}"
|
||||
commands="cdr completion e911Info e911Validate getBalance getCDR getCallAccounts getCallBilling getCallRecording getCallRecordings getCallTypes getDIDsInfo getFaxMessagePDF getFaxMessages getForwardings getIP getLNPList getLNPStatus getMMS getPhonebook getRates getRegistrationStatus getSMS getServersInfo getSubAccounts getTerminationRates getTransactionHistory getVoicemailSetups help list sendCallRecordingEmail sendFaxMessage sendMMS sendSMS setDIDInfo setDIDRouting setForwarding setPhonebook setSubAccount"
|
||||
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
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${MCP_HOST:-0.0.0.0}"
|
||||
PORT="${MCP_PORT:-8787}"
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
if [[ "${MCP_ALLOW_UNAUTHENTICATED:-}" != "1" && -z "${MCP_AUTH_TOKEN:-}" ]]; then
|
||||
MCP_AUTH_TOKEN="$(php -r 'echo bin2hex(random_bytes(24));')"
|
||||
export MCP_AUTH_TOKEN
|
||||
echo "Generated MCP_AUTH_TOKEN for this session:"
|
||||
echo "${MCP_AUTH_TOKEN}"
|
||||
echo
|
||||
fi
|
||||
|
||||
if [[ -z "${VOIPMS_API_USERNAME:-}" || -z "${VOIPMS_API_PASSWORD:-}" ]]; then
|
||||
echo "Warning: VOIPMS_API_USERNAME or VOIPMS_API_PASSWORD is not set."
|
||||
echo "Read-only protocol methods will work, but VoIP.ms tools will fail until credentials are present."
|
||||
echo
|
||||
fi
|
||||
|
||||
echo "Starting VoIP.ms test MCP server"
|
||||
echo "URL: http://${HOST}:${PORT}/mcp"
|
||||
echo "Health: http://${HOST}:${PORT}/health"
|
||||
echo "Auth: Authorization: Bearer ${MCP_AUTH_TOKEN:-<disabled>}"
|
||||
echo
|
||||
|
||||
exec php -S "${HOST}:${PORT}" -t "${ROOT}/mcp" "${ROOT}/mcp/http-server.php"
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "local/voipms-cli",
|
||||
"description": "Small PHP library and CLI for the VoIP.ms REST/JSON API.",
|
||||
"type": "library",
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"VoipMs\\": "src/"
|
||||
}
|
||||
},
|
||||
"bin": [
|
||||
"voipms-cli.php"
|
||||
]
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
# Library Usage
|
||||
|
||||
The CLI uses the same client class that application code can use directly.
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
require __DIR__ . '/../src/VoipMsClient.php';
|
||||
|
||||
use VoipMs\VoipMsClient;
|
||||
|
||||
$client = new VoipMsClient(
|
||||
getenv('VOIPMS_API_USERNAME'),
|
||||
getenv('VOIPMS_API_PASSWORD'),
|
||||
);
|
||||
|
||||
$response = $client->getPhonebookGroups();
|
||||
|
||||
if (!$response->successful()) {
|
||||
throw new RuntimeException($response->message() ?? 'VoIP.ms request failed.');
|
||||
}
|
||||
|
||||
foreach ($response->data()['phonebook_groups'] ?? [] as $group) {
|
||||
echo $group['phonebook_group'] . ' ' . $group['name'] . PHP_EOL;
|
||||
}
|
||||
```
|
||||
|
||||
If a command does not have a convenience method yet, use the generic request method:
|
||||
|
||||
```php
|
||||
$response = $client->request('getDIDsInfo', ['did' => '5551234567']);
|
||||
```
|
||||
|
||||
For backwards compatibility with the CLI, `call()` still returns the older array shape:
|
||||
|
||||
```php
|
||||
$result = $client->call('getBalance');
|
||||
echo $result['raw'];
|
||||
```
|
||||
|
||||
## Convenience Methods
|
||||
|
||||
General:
|
||||
|
||||
```php
|
||||
$client->getBalance();
|
||||
$client->getBalance(advanced: true);
|
||||
$client->getDidsInfo();
|
||||
$client->getDidsInfo('5551234567');
|
||||
```
|
||||
|
||||
Phonebook:
|
||||
|
||||
```php
|
||||
$client->getPhonebook();
|
||||
$client->getPhonebookByGroup('16389');
|
||||
$client->getPhonebookByGroupName('Spam');
|
||||
$client->getPhonebookGroups();
|
||||
$client->findPhonebookGroupByName('Spam');
|
||||
$client->findOrCreatePhonebookGroup('Spam');
|
||||
$client->findPhonebookEntriesByNumber('5553334444');
|
||||
$client->setPhonebook([
|
||||
'name' => 'Jane Smith',
|
||||
'number' => '5553334444',
|
||||
'group' => '16389',
|
||||
]);
|
||||
$client->deletePhonebook('32207');
|
||||
$client->setPhonebookGroup('Spam');
|
||||
$client->deletePhonebookGroup('16389');
|
||||
```
|
||||
|
||||
To add a number to a named group without creating duplicate entries:
|
||||
|
||||
```php
|
||||
$result = $client->addNumberToPhonebookGroup(
|
||||
number: '5553334444',
|
||||
groupName: 'Spam',
|
||||
name: 'Spam Caller',
|
||||
note: 'Marked by automation',
|
||||
);
|
||||
|
||||
if ($result->changed()) {
|
||||
echo 'Added entry IDs: ' . implode(', ', $result->addedEntryIds()) . PHP_EOL;
|
||||
}
|
||||
```
|
||||
|
||||
The helper creates the group if needed, reuses existing phonebook entries for
|
||||
the same number when possible, and only creates a new phonebook entry when the
|
||||
number does not already exist.
|
||||
|
||||
Removing a number from a group is a group membership update. VoIP.ms stores
|
||||
group members as phonebook entry IDs, and it may allow duplicate phonebook
|
||||
entries for the same number. The helper below removes every entry in the group
|
||||
whose `number` matches, but keeps the phonebook entries themselves:
|
||||
|
||||
```php
|
||||
$result = $client->removePhonebookNumberFromGroup('16389', '5553334444');
|
||||
|
||||
echo 'Removed IDs: ' . implode(', ', $result->removedEntryIds()) . PHP_EOL;
|
||||
|
||||
if (!$result->response()->successful()) {
|
||||
throw new RuntimeException($result->response()->message() ?? 'Group update failed.');
|
||||
}
|
||||
```
|
||||
|
||||
If you already know the phonebook entry IDs to remove:
|
||||
|
||||
```php
|
||||
$client->removePhonebookEntriesFromGroup('16389', ['32207', '32208']);
|
||||
```
|
||||
|
||||
Only pass `deleteMatchedEntries: true` when you want to delete those phonebook
|
||||
entries from the account entirely after removing them from the group:
|
||||
|
||||
```php
|
||||
$client->removePhonebookNumberFromGroup('16389', '5553334444', deleteMatchedEntries: true);
|
||||
```
|
||||
|
||||
Caller ID filtering:
|
||||
|
||||
```php
|
||||
$client->getCallerIdFiltering();
|
||||
$client->setCallerIdFiltering([
|
||||
'callerid' => 'p:16389',
|
||||
'did' => 'all',
|
||||
'routing' => 'sys:busy',
|
||||
'note' => 'Spam group',
|
||||
]);
|
||||
$client->deleteCallerIdFiltering('18915');
|
||||
```
|
||||
|
||||
Call records and SMS:
|
||||
|
||||
```php
|
||||
$client->getAccounts();
|
||||
$client->getCallAccounts();
|
||||
$client->getCdr('2026-04-01', '2026-04-20', '-4', [
|
||||
'calltype' => 'incoming',
|
||||
'callbilling' => 'all',
|
||||
]);
|
||||
$client->getCallRecordings('all', '2026-04-01', '2026-04-20');
|
||||
$client->getCallRecording('100000_VoIP', '<value-from-getCallRecordings>');
|
||||
$client->sendCallRecordingEmail('100000_VoIP', 'you@example.com', '<value-from-getCallRecordings>');
|
||||
$client->sendSms('5551234567', '5553334444', 'Hello');
|
||||
```
|
||||
|
||||
`getAccounts()` is a friendly alias for VoIP.ms `getCallAccounts`. It does not
|
||||
list VoIP.ms portal login users. It returns account filter values such as `all`
|
||||
or subaccount identifiers like `100000_VoIP`; use the returned `value` field as
|
||||
the `account` argument for `getCdr()`, `getCallRecordings()`,
|
||||
`getCallRecording()`, and `sendCallRecordingEmail()`.
|
||||
|
||||
`getCdr()` defaults to all call statuses (`answered`, `noanswer`, `busy`,
|
||||
and `failed`) because the VoIP.ms API rejects requests where none of those
|
||||
flags are set. Pass one or more status filters to narrow the result:
|
||||
|
||||
```php
|
||||
$client->getCdr('2026-04-01', '2026-04-20', '-4', [
|
||||
'answered' => '1',
|
||||
]);
|
||||
```
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
# Test MCP Server
|
||||
|
||||
This project includes a dependency-free HTTP JSON-RPC MCP endpoint for testing
|
||||
remote-agent access to the VoIP.ms library.
|
||||
|
||||
It is intended for development and testing. It binds to `0.0.0.0` by default,
|
||||
so use a firewall, VPN, or private network when exposing it beyond this machine.
|
||||
|
||||
## Start The Server
|
||||
|
||||
```bash
|
||||
export VOIPMS_API_USERNAME='you@example.com'
|
||||
export VOIPMS_API_PASSWORD='your-api-password'
|
||||
export MCP_AUTH_TOKEN='choose-a-long-random-token'
|
||||
|
||||
./bin/serve-mcp.sh
|
||||
```
|
||||
|
||||
Defaults:
|
||||
|
||||
```text
|
||||
MCP_HOST=0.0.0.0
|
||||
MCP_PORT=8787
|
||||
MCP endpoint: http://0.0.0.0:8787/mcp
|
||||
Health endpoint: http://0.0.0.0:8787/health
|
||||
```
|
||||
|
||||
If `MCP_AUTH_TOKEN` is not set, the launcher generates one for that session.
|
||||
For local unauthenticated testing only:
|
||||
|
||||
```bash
|
||||
MCP_ALLOW_UNAUTHENTICATED=1 ./bin/serve-mcp.sh
|
||||
```
|
||||
|
||||
The preferred authentication form is:
|
||||
|
||||
```text
|
||||
Authorization: Bearer <MCP_AUTH_TOKEN>
|
||||
```
|
||||
|
||||
For clients that cannot send custom headers during testing, the same token can
|
||||
be passed as a query parameter:
|
||||
|
||||
```text
|
||||
http://your-host:8787/mcp?token=<MCP_AUTH_TOKEN>
|
||||
```
|
||||
|
||||
Prefer the `Authorization` header when the client supports it.
|
||||
|
||||
## Smoke Test
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8787/health
|
||||
```
|
||||
|
||||
Initialize:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8787/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $MCP_AUTH_TOKEN" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"test"}}}'
|
||||
```
|
||||
|
||||
Streamable HTTP GET probe:
|
||||
|
||||
```bash
|
||||
curl -sN http://127.0.0.1:8787/mcp \
|
||||
-H "Accept: text/event-stream" \
|
||||
-H "Authorization: Bearer $MCP_AUTH_TOKEN"
|
||||
```
|
||||
|
||||
Legacy SSE probe:
|
||||
|
||||
```bash
|
||||
curl -sN http://127.0.0.1:8787/sse \
|
||||
-H "Accept: text/event-stream" \
|
||||
-H "Authorization: Bearer $MCP_AUTH_TOKEN"
|
||||
```
|
||||
|
||||
List tools:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8787/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $MCP_AUTH_TOKEN" \
|
||||
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
|
||||
```
|
||||
|
||||
Call a safe dry-run mutation:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8787/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $MCP_AUTH_TOKEN" \
|
||||
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"voipms_add_number_to_phonebook_group","arguments":{"number":"5553334444","group_name":"Spam","dry_run":true}}}'
|
||||
```
|
||||
|
||||
## Exposed Tools
|
||||
|
||||
- `voipms_get_balance`
|
||||
- `voipms_get_phonebook_groups`
|
||||
- `voipms_get_phonebook_entries`
|
||||
- `voipms_get_callerid_filters`
|
||||
- `voipms_get_accounts`
|
||||
- `voipms_get_call_accounts`
|
||||
- `voipms_get_call_recordings`
|
||||
- `voipms_get_call_recording`
|
||||
- `voipms_send_call_recording_email`
|
||||
- `voipms_get_call_transcript`
|
||||
- `voipms_get_recent_cdr`
|
||||
- `voipms_add_number_to_phonebook_group`
|
||||
- `voipms_remove_number_from_phonebook_group`
|
||||
|
||||
Mutation tools default to `dry_run=true` and must be called with
|
||||
`dry_run=false` to apply changes.
|
||||
|
||||
`voipms_get_call_transcript` is currently a placeholder. It is listed so remote
|
||||
agents can discover that transcripts are planned, but transcription
|
||||
storage/provider support has not been implemented yet.
|
||||
|
||||
`voipms_get_accounts` is a friendly alias for `voipms_get_call_accounts`. These
|
||||
tools return account filter values for CDR and call recording tools. They do
|
||||
not list VoIP.ms portal login users. Use the returned `value` field as the
|
||||
`account` argument.
|
||||
@@ -0,0 +1,658 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../src/VoipMsClient.php';
|
||||
|
||||
use VoipMs\VoipMsClient;
|
||||
use VoipMs\VoipMsResponse;
|
||||
|
||||
const MCP_PROTOCOL_VERSION = '2025-06-18';
|
||||
const MCP_SESSION_ID = 'voipms-test-session';
|
||||
|
||||
if (PHP_SAPI === 'cli-server') {
|
||||
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||
if (!in_array($path, ['/', '/mcp', '/sse', '/message', '/health'], true)) {
|
||||
http_response_code(404);
|
||||
echo "Not found\n";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') {
|
||||
sendCorsHeaders();
|
||||
http_response_code(204);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/') === '/health') {
|
||||
sendJson(['status' => 'ok', 'service' => 'voipms-mcp']);
|
||||
return;
|
||||
}
|
||||
|
||||
$requestPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||
$requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
|
||||
if ($requestMethod === 'GET' && ($requestPath === '/mcp' || $requestPath === '/sse')) {
|
||||
if (!isAuthorized()) {
|
||||
sendJsonError(null, -32001, 'Unauthorized. Set MCP_AUTH_TOKEN and send Authorization: Bearer <token>.', 401);
|
||||
return;
|
||||
}
|
||||
|
||||
sendSseHandshake($requestPath === '/sse');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($requestMethod === 'DELETE' && $requestPath === '/mcp') {
|
||||
http_response_code(204);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($requestMethod !== 'POST') {
|
||||
sendJsonError(null, -32600, 'MCP endpoint expects POST JSON-RPC requests.', 405);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAuthorized()) {
|
||||
sendJsonError(null, -32001, 'Unauthorized. Set MCP_AUTH_TOKEN and send Authorization: Bearer <token>.', 401);
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = json_decode(file_get_contents('php://input') ?: '', true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
sendJsonError(null, -32700, 'Invalid JSON request.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isBatchRequest($payload)) {
|
||||
$responses = [];
|
||||
foreach ($payload as $request) {
|
||||
$response = handleJsonRpcRequest(is_array($request) ? $request : []);
|
||||
if ($response !== null) {
|
||||
$responses[] = $response;
|
||||
}
|
||||
}
|
||||
sendJson($responses);
|
||||
return;
|
||||
}
|
||||
|
||||
$response = handleJsonRpcRequest(is_array($payload) ? $payload : []);
|
||||
if ($response === null) {
|
||||
sendCorsHeaders();
|
||||
header('Mcp-Session-Id: ' . MCP_SESSION_ID);
|
||||
http_response_code(202);
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson($response);
|
||||
} catch (Throwable $exception) {
|
||||
sendJson(jsonRpcError($payload['id'] ?? null, -32603, $exception->getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $request
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
function handleJsonRpcRequest(array $request): ?array
|
||||
{
|
||||
$id = $request['id'] ?? null;
|
||||
$method = is_string($request['method'] ?? null) ? $request['method'] : '';
|
||||
$params = is_array($request['params'] ?? null) ? $request['params'] : [];
|
||||
|
||||
if ($method === '') {
|
||||
return jsonRpcError($id, -32600, 'Missing JSON-RPC method.');
|
||||
}
|
||||
|
||||
if (str_starts_with($method, 'notifications/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match ($method) {
|
||||
'initialize' => initializeResult($id, $params),
|
||||
'ping' => jsonRpcResult($id, new stdClass()),
|
||||
'tools/list' => jsonRpcResult($id, ['tools' => toolDefinitions()]),
|
||||
'tools/call' => handleToolCall($id, $params),
|
||||
default => jsonRpcError($id, -32601, "Unsupported method: {$method}"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function handleToolCall(mixed $id, array $params): array
|
||||
{
|
||||
$name = is_string($params['name'] ?? null) ? $params['name'] : '';
|
||||
$arguments = is_array($params['arguments'] ?? null) ? $params['arguments'] : [];
|
||||
|
||||
if ($name === '') {
|
||||
return jsonRpcError($id, -32602, 'tools/call requires a tool name.');
|
||||
}
|
||||
|
||||
try {
|
||||
$result = callTool($name, $arguments);
|
||||
|
||||
return jsonRpcResult($id, [
|
||||
'content' => [
|
||||
[
|
||||
'type' => 'text',
|
||||
'text' => json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
|
||||
],
|
||||
],
|
||||
'isError' => false,
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
return jsonRpcResult($id, [
|
||||
'content' => [
|
||||
[
|
||||
'type' => 'text',
|
||||
'text' => $exception->getMessage(),
|
||||
],
|
||||
],
|
||||
'isError' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function initializeResult(mixed $id, array $params): array
|
||||
{
|
||||
$requestedVersion = is_string($params['protocolVersion'] ?? null) ? $params['protocolVersion'] : MCP_PROTOCOL_VERSION;
|
||||
$supportedVersions = ['2024-11-05', '2025-03-26', '2025-06-18'];
|
||||
$protocolVersion = in_array($requestedVersion, $supportedVersions, true) ? $requestedVersion : MCP_PROTOCOL_VERSION;
|
||||
|
||||
header('Mcp-Session-Id: ' . MCP_SESSION_ID);
|
||||
|
||||
return jsonRpcResult($id, [
|
||||
'protocolVersion' => $protocolVersion,
|
||||
'capabilities' => [
|
||||
'tools' => [
|
||||
'listChanged' => false,
|
||||
],
|
||||
],
|
||||
'serverInfo' => [
|
||||
'name' => 'voipms-mcp',
|
||||
'version' => '0.1.0',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function callTool(string $name, array $arguments): array
|
||||
{
|
||||
$client = voipmsClient();
|
||||
|
||||
return match ($name) {
|
||||
'voipms_get_balance' => responsePayload($client->getBalance(boolArg($arguments, 'advanced', false))),
|
||||
'voipms_get_phonebook_groups' => responsePayload($client->getPhonebookGroups(
|
||||
optionalStringArg($arguments, 'group_id'),
|
||||
optionalStringArg($arguments, 'name'),
|
||||
)),
|
||||
'voipms_get_phonebook_entries' => responsePayload($client->getPhonebook(
|
||||
optionalStringArg($arguments, 'entry_id'),
|
||||
optionalStringArg($arguments, 'name'),
|
||||
optionalStringArg($arguments, 'group_id'),
|
||||
optionalStringArg($arguments, 'group_name'),
|
||||
)),
|
||||
'voipms_get_callerid_filters' => responsePayload($client->getCallerIdFiltering(optionalStringArg($arguments, 'filtering_id'))),
|
||||
'voipms_get_accounts' => responsePayload($client->getAccounts(optionalStringArg($arguments, 'client'))),
|
||||
'voipms_get_call_accounts' => responsePayload($client->getCallAccounts(optionalStringArg($arguments, 'client'))),
|
||||
'voipms_get_call_recordings' => responsePayload($client->getCallRecordings(
|
||||
requiredStringArg($arguments, 'account'),
|
||||
requiredStringArg($arguments, 'date_from'),
|
||||
requiredStringArg($arguments, 'date_to'),
|
||||
stringArg($arguments, 'call_type', 'all'),
|
||||
optionalStringArg($arguments, 'start'),
|
||||
optionalStringArg($arguments, 'length'),
|
||||
)),
|
||||
'voipms_get_call_recording' => responsePayload($client->getCallRecording(
|
||||
requiredStringArg($arguments, 'account'),
|
||||
requiredStringArg($arguments, 'callrecording'),
|
||||
)),
|
||||
'voipms_send_call_recording_email' => responsePayload($client->sendCallRecordingEmail(
|
||||
requiredStringArg($arguments, 'account'),
|
||||
requiredStringArg($arguments, 'email'),
|
||||
requiredStringArg($arguments, 'callrecording'),
|
||||
)),
|
||||
'voipms_get_call_transcript' => [
|
||||
'status' => 'not_configured',
|
||||
'message' => 'Transcript storage/transcription provider has not been implemented yet. Use voipms_get_call_recordings and voipms_get_call_recording for recording access.',
|
||||
'callrecording' => optionalStringArg($arguments, 'callrecording'),
|
||||
],
|
||||
'voipms_get_recent_cdr' => responsePayload($client->getCdr(
|
||||
requiredStringArg($arguments, 'date_from'),
|
||||
requiredStringArg($arguments, 'date_to'),
|
||||
stringArg($arguments, 'timezone', getenv('VOIPMS_TIMEZONE') ?: '-4'),
|
||||
cdrFilters($arguments),
|
||||
)),
|
||||
'voipms_add_number_to_phonebook_group' => addNumberToPhonebookGroupTool($client, $arguments),
|
||||
'voipms_remove_number_from_phonebook_group' => removeNumberFromPhonebookGroupTool($client, $arguments),
|
||||
default => throw new InvalidArgumentException("Unknown tool: {$name}"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
function toolDefinitions(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'name' => 'voipms_get_balance',
|
||||
'description' => 'Get the VoIP.ms account balance.',
|
||||
'inputSchema' => objectSchema([
|
||||
'advanced' => ['type' => 'boolean', 'description' => 'Include advanced balance and call statistics.'],
|
||||
]),
|
||||
],
|
||||
[
|
||||
'name' => 'voipms_get_phonebook_groups',
|
||||
'description' => 'List VoIP.ms phonebook groups, optionally filtered by group ID or name.',
|
||||
'inputSchema' => objectSchema([
|
||||
'group_id' => ['type' => 'string'],
|
||||
'name' => ['type' => 'string'],
|
||||
]),
|
||||
],
|
||||
[
|
||||
'name' => 'voipms_get_phonebook_entries',
|
||||
'description' => 'List VoIP.ms phonebook entries, optionally filtered by entry ID, name, group ID, or group name.',
|
||||
'inputSchema' => objectSchema([
|
||||
'entry_id' => ['type' => 'string'],
|
||||
'name' => ['type' => 'string'],
|
||||
'group_id' => ['type' => 'string'],
|
||||
'group_name' => ['type' => 'string'],
|
||||
]),
|
||||
],
|
||||
[
|
||||
'name' => 'voipms_get_callerid_filters',
|
||||
'description' => 'List VoIP.ms CallerID Filtering rules, optionally filtered by filtering ID.',
|
||||
'inputSchema' => objectSchema([
|
||||
'filtering_id' => ['type' => 'string'],
|
||||
]),
|
||||
],
|
||||
[
|
||||
'name' => 'voipms_get_accounts',
|
||||
'description' => 'Friendly alias for voipms_get_call_accounts. Lists account filter values for CDR and call recording tools; it does not list VoIP.ms login users.',
|
||||
'inputSchema' => objectSchema([
|
||||
'client' => ['type' => 'string', 'description' => 'Optional reseller client ID.'],
|
||||
]),
|
||||
],
|
||||
[
|
||||
'name' => 'voipms_get_call_accounts',
|
||||
'description' => 'List account filter values accepted by CDR and call recording tools. Use the returned value field as the account argument.',
|
||||
'inputSchema' => objectSchema([
|
||||
'client' => ['type' => 'string', 'description' => 'Optional reseller client ID.'],
|
||||
]),
|
||||
],
|
||||
[
|
||||
'name' => 'voipms_get_call_recordings',
|
||||
'description' => 'List VoIP.ms call recordings for an account and date range. Use voipms_get_accounts to discover account values first.',
|
||||
'inputSchema' => objectSchema([
|
||||
'account' => ['type' => 'string', 'description' => 'Account filter value, often all or a subaccount value from voipms_get_accounts.'],
|
||||
'date_from' => ['type' => 'string', 'description' => 'Start date, YYYY-MM-DD.'],
|
||||
'date_to' => ['type' => 'string', 'description' => 'End date, YYYY-MM-DD.'],
|
||||
'call_type' => ['type' => 'string', 'description' => 'all, incoming, or outgoing. Default all.'],
|
||||
'start' => ['type' => 'string', 'description' => 'Pagination start offset.'],
|
||||
'length' => ['type' => 'string', 'description' => 'Pagination length.'],
|
||||
], ['account', 'date_from', 'date_to']),
|
||||
],
|
||||
[
|
||||
'name' => 'voipms_get_call_recording',
|
||||
'description' => 'Retrieve one VoIP.ms call recording. The response may contain base64 audio data from VoIP.ms.',
|
||||
'inputSchema' => objectSchema([
|
||||
'account' => ['type' => 'string'],
|
||||
'callrecording' => ['type' => 'string', 'description' => 'Recording identifier returned by voipms_get_call_recordings.'],
|
||||
], ['account', 'callrecording']),
|
||||
],
|
||||
[
|
||||
'name' => 'voipms_send_call_recording_email',
|
||||
'description' => 'Ask VoIP.ms to email one call recording.',
|
||||
'inputSchema' => objectSchema([
|
||||
'account' => ['type' => 'string'],
|
||||
'email' => ['type' => 'string'],
|
||||
'callrecording' => ['type' => 'string', 'description' => 'Recording identifier returned by voipms_get_call_recordings.'],
|
||||
], ['account', 'email', 'callrecording']),
|
||||
],
|
||||
[
|
||||
'name' => 'voipms_get_call_transcript',
|
||||
'description' => 'Placeholder transcript lookup. Transcription storage/provider is not configured yet.',
|
||||
'inputSchema' => objectSchema([
|
||||
'callrecording' => ['type' => 'string'],
|
||||
]),
|
||||
],
|
||||
[
|
||||
'name' => 'voipms_get_recent_cdr',
|
||||
'description' => 'Get call detail records for a date range. Defaults to all call statuses.',
|
||||
'inputSchema' => objectSchema([
|
||||
'date_from' => ['type' => 'string', 'description' => 'Start date, YYYY-MM-DD.'],
|
||||
'date_to' => ['type' => 'string', 'description' => 'End date, YYYY-MM-DD.'],
|
||||
'timezone' => ['type' => 'string', 'description' => 'VoIP.ms timezone offset, for example -4.'],
|
||||
'calltype' => ['type' => 'string', 'description' => 'Call type filter, such as incoming, outgoing, or a DID value from getCallTypes.'],
|
||||
'callbilling' => ['type' => 'string', 'description' => 'Billing filter value.'],
|
||||
'account' => ['type' => 'string', 'description' => 'Account filter value from getCallAccounts.'],
|
||||
'answered' => ['type' => 'boolean'],
|
||||
'noanswer' => ['type' => 'boolean'],
|
||||
'busy' => ['type' => 'boolean'],
|
||||
'failed' => ['type' => 'boolean'],
|
||||
], ['date_from', 'date_to']),
|
||||
],
|
||||
[
|
||||
'name' => 'voipms_add_number_to_phonebook_group',
|
||||
'description' => 'Idempotently add a number to a named phonebook group. Defaults to dry_run=true for safety.',
|
||||
'inputSchema' => objectSchema([
|
||||
'number' => ['type' => 'string'],
|
||||
'group_name' => ['type' => 'string'],
|
||||
'name' => ['type' => 'string'],
|
||||
'note' => ['type' => 'string'],
|
||||
'callerid' => ['type' => 'string'],
|
||||
'dry_run' => ['type' => 'boolean', 'description' => 'When true, return the planned action without changing VoIP.ms. Default true.'],
|
||||
], ['number', 'group_name']),
|
||||
],
|
||||
[
|
||||
'name' => 'voipms_remove_number_from_phonebook_group',
|
||||
'description' => 'Remove all matching phonebook entries for a number from a group. Defaults to dry_run=true for safety.',
|
||||
'inputSchema' => objectSchema([
|
||||
'group_id' => ['type' => 'string'],
|
||||
'number' => ['type' => 'string'],
|
||||
'delete_matched_entries' => ['type' => 'boolean', 'description' => 'Also delete matched phonebook entries from the account.'],
|
||||
'dry_run' => ['type' => 'boolean', 'description' => 'When true, return the planned action without changing VoIP.ms. Default true.'],
|
||||
], ['group_id', 'number']),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $properties
|
||||
* @param list<string> $required
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function objectSchema(array $properties, array $required = []): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => $properties,
|
||||
'required' => $required,
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function addNumberToPhonebookGroupTool(VoipMsClient $client, array $arguments): array
|
||||
{
|
||||
$dryRun = boolArg($arguments, 'dry_run', true);
|
||||
$number = requiredStringArg($arguments, 'number');
|
||||
$groupName = requiredStringArg($arguments, 'group_name');
|
||||
|
||||
if ($dryRun) {
|
||||
return [
|
||||
'dry_run' => true,
|
||||
'planned_action' => 'Add number to phonebook group if it is not already a member.',
|
||||
'number' => $number,
|
||||
'group_name' => $groupName,
|
||||
'name' => optionalStringArg($arguments, 'name'),
|
||||
'note' => optionalStringArg($arguments, 'note'),
|
||||
'callerid' => optionalStringArg($arguments, 'callerid'),
|
||||
];
|
||||
}
|
||||
|
||||
$result = $client->addNumberToPhonebookGroup(
|
||||
$number,
|
||||
$groupName,
|
||||
optionalStringArg($arguments, 'name'),
|
||||
optionalStringArg($arguments, 'note'),
|
||||
optionalStringArg($arguments, 'callerid'),
|
||||
);
|
||||
|
||||
return [
|
||||
'dry_run' => false,
|
||||
'changed' => $result->changed(),
|
||||
'group' => $result->group(),
|
||||
'added_entry_ids' => $result->addedEntryIds(),
|
||||
'existing_entry_ids' => $result->existingEntryIds(),
|
||||
'member_ids' => $result->memberIds(),
|
||||
'matching_entries' => $result->matchingEntries(),
|
||||
'phonebook_response' => $result->phonebookResponse()?->data(),
|
||||
'group_response' => $result->groupResponse()?->data(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function removeNumberFromPhonebookGroupTool(VoipMsClient $client, array $arguments): array
|
||||
{
|
||||
$dryRun = boolArg($arguments, 'dry_run', true);
|
||||
$groupId = requiredStringArg($arguments, 'group_id');
|
||||
$number = requiredStringArg($arguments, 'number');
|
||||
$deleteMatchedEntries = boolArg($arguments, 'delete_matched_entries', false);
|
||||
|
||||
if ($dryRun) {
|
||||
return [
|
||||
'dry_run' => true,
|
||||
'planned_action' => 'Remove every matching phonebook entry ID for this number from the group membership list.',
|
||||
'group_id' => $groupId,
|
||||
'number' => $number,
|
||||
'delete_matched_entries' => $deleteMatchedEntries,
|
||||
];
|
||||
}
|
||||
|
||||
$result = $client->removePhonebookNumberFromGroup($groupId, $number, $deleteMatchedEntries);
|
||||
|
||||
return [
|
||||
'dry_run' => false,
|
||||
'group_id' => $result->groupId(),
|
||||
'group_name' => $result->groupName(),
|
||||
'removed_entry_ids' => $result->removedEntryIds(),
|
||||
'remaining_entry_ids' => $result->remainingEntryIds(),
|
||||
'matched_entries' => $result->matchedEntries(),
|
||||
'response' => $result->response()->data(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function cdrFilters(array $arguments): array
|
||||
{
|
||||
$filters = [];
|
||||
foreach (['calltype', 'callbilling', 'account'] as $field) {
|
||||
$value = optionalStringArg($arguments, $field);
|
||||
if ($value !== null) {
|
||||
$filters[$field] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['answered', 'noanswer', 'busy', 'failed'] as $field) {
|
||||
if (array_key_exists($field, $arguments)) {
|
||||
$filters[$field] = boolArg($arguments, $field, false) ? '1' : '0';
|
||||
}
|
||||
}
|
||||
|
||||
return $filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function responsePayload(VoipMsResponse $response): array
|
||||
{
|
||||
return [
|
||||
'http_code' => $response->httpCode(),
|
||||
'status' => $response->status(),
|
||||
'message' => $response->message(),
|
||||
'data' => $response->data(),
|
||||
];
|
||||
}
|
||||
|
||||
function voipmsClient(): VoipMsClient
|
||||
{
|
||||
$username = getenv('VOIPMS_API_USERNAME') ?: '';
|
||||
$password = getenv('VOIPMS_API_PASSWORD') ?: '';
|
||||
$endpoint = getenv('VOIPMS_API_ENDPOINT') ?: VoipMsClient::DEFAULT_ENDPOINT;
|
||||
|
||||
if ($username === '' || $password === '') {
|
||||
throw new RuntimeException('Missing VOIPMS_API_USERNAME or VOIPMS_API_PASSWORD in the MCP server environment.');
|
||||
}
|
||||
|
||||
return new VoipMsClient($username, $password, $endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
*/
|
||||
function requiredStringArg(array $arguments, string $key): string
|
||||
{
|
||||
$value = optionalStringArg($arguments, $key);
|
||||
if ($value === null) {
|
||||
throw new InvalidArgumentException("Missing required argument: {$key}");
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
*/
|
||||
function stringArg(array $arguments, string $key, string $default): string
|
||||
{
|
||||
return optionalStringArg($arguments, $key) ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
*/
|
||||
function optionalStringArg(array $arguments, string $key): ?string
|
||||
{
|
||||
if (!array_key_exists($key, $arguments) || $arguments[$key] === null || $arguments[$key] === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_scalar($arguments[$key])) {
|
||||
return (string) $arguments[$key];
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException("Argument {$key} must be a string-compatible value.");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
*/
|
||||
function boolArg(array $arguments, string $key, bool $default): bool
|
||||
{
|
||||
if (!array_key_exists($key, $arguments)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return filter_var($arguments[$key], FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? $default;
|
||||
}
|
||||
|
||||
function isAuthorized(): bool
|
||||
{
|
||||
if ((getenv('MCP_ALLOW_UNAUTHENTICATED') ?: '') === '1') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$expected = getenv('MCP_AUTH_TOKEN') ?: '';
|
||||
if ($expected === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$authorization = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||
if (!is_string($authorization) || !str_starts_with($authorization, 'Bearer ')) {
|
||||
$queryToken = $_GET['token'] ?? '';
|
||||
|
||||
return is_string($queryToken) && hash_equals($expected, $queryToken);
|
||||
}
|
||||
|
||||
return hash_equals($expected, substr($authorization, 7));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $payload
|
||||
*/
|
||||
function isBatchRequest(mixed $payload): bool
|
||||
{
|
||||
return is_array($payload) && array_is_list($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function jsonRpcResult(mixed $id, mixed $result): array
|
||||
{
|
||||
return [
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => $id,
|
||||
'result' => $result,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function jsonRpcError(mixed $id, int $code, string $message): array
|
||||
{
|
||||
return [
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => $id,
|
||||
'error' => [
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function sendJsonError(mixed $id, int $code, string $message, int $httpCode = 400): void
|
||||
{
|
||||
http_response_code($httpCode);
|
||||
sendJson(jsonRpcError($id, $code, $message));
|
||||
}
|
||||
|
||||
function sendJson(mixed $payload): void
|
||||
{
|
||||
sendCorsHeaders();
|
||||
header('Mcp-Session-Id: ' . MCP_SESSION_ID);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
|
||||
}
|
||||
|
||||
function sendCorsHeaders(): void
|
||||
{
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: POST, OPTIONS, GET, DELETE');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization, Accept, Mcp-Session-Id, Last-Event-ID');
|
||||
header('Access-Control-Expose-Headers: Mcp-Session-Id');
|
||||
}
|
||||
|
||||
function sendSseHandshake(bool $legacyEndpointEvent): void
|
||||
{
|
||||
sendCorsHeaders();
|
||||
header('Mcp-Session-Id: ' . MCP_SESSION_ID);
|
||||
header('Content-Type: text/event-stream');
|
||||
header('Cache-Control: no-cache');
|
||||
header('Connection: keep-alive');
|
||||
header('X-Accel-Buffering: no');
|
||||
|
||||
if ($legacyEndpointEvent) {
|
||||
echo "event: endpoint\n";
|
||||
echo "data: /mcp\n\n";
|
||||
} else {
|
||||
echo ": voipms-mcp stream open\n\n";
|
||||
}
|
||||
|
||||
echo "event: ping\n";
|
||||
echo 'data: ' . json_encode(['time' => gmdate('c')], JSON_UNESCAPED_SLASHES) . "\n\n";
|
||||
@ob_flush();
|
||||
flush();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace VoipMs;
|
||||
|
||||
final class PhonebookGroupEnsureResult
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $group
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $group,
|
||||
private readonly bool $created,
|
||||
private readonly ?VoipMsResponse $response = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function group(): array
|
||||
{
|
||||
return $this->group;
|
||||
}
|
||||
|
||||
public function groupId(): string
|
||||
{
|
||||
return stringField($this->group, 'phonebook_group');
|
||||
}
|
||||
|
||||
public function groupName(): string
|
||||
{
|
||||
return stringField($this->group, 'name');
|
||||
}
|
||||
|
||||
public function created(): bool
|
||||
{
|
||||
return $this->created;
|
||||
}
|
||||
|
||||
public function response(): ?VoipMsResponse
|
||||
{
|
||||
return $this->response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace VoipMs;
|
||||
|
||||
final class PhonebookGroupUpdateResult
|
||||
{
|
||||
/**
|
||||
* @param list<string> $removedEntryIds
|
||||
* @param list<string> $remainingEntryIds
|
||||
* @param list<array<string, mixed>> $matchedEntries
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $groupId,
|
||||
private readonly string $groupName,
|
||||
private readonly array $removedEntryIds,
|
||||
private readonly array $remainingEntryIds,
|
||||
private readonly array $matchedEntries,
|
||||
private readonly VoipMsResponse $response,
|
||||
) {
|
||||
}
|
||||
|
||||
public function groupId(): string
|
||||
{
|
||||
return $this->groupId;
|
||||
}
|
||||
|
||||
public function groupName(): string
|
||||
{
|
||||
return $this->groupName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function removedEntryIds(): array
|
||||
{
|
||||
return $this->removedEntryIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function remainingEntryIds(): array
|
||||
{
|
||||
return $this->remainingEntryIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function matchedEntries(): array
|
||||
{
|
||||
return $this->matchedEntries;
|
||||
}
|
||||
|
||||
public function response(): VoipMsResponse
|
||||
{
|
||||
return $this->response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace VoipMs;
|
||||
|
||||
final class PhonebookNumberGroupResult
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $group
|
||||
* @param list<array<string, mixed>> $matchingEntries
|
||||
* @param list<string> $addedEntryIds
|
||||
* @param list<string> $existingEntryIds
|
||||
* @param list<string> $memberIds
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $group,
|
||||
private readonly array $matchingEntries,
|
||||
private readonly array $addedEntryIds,
|
||||
private readonly array $existingEntryIds,
|
||||
private readonly array $memberIds,
|
||||
private readonly ?VoipMsResponse $phonebookResponse,
|
||||
private readonly ?VoipMsResponse $groupResponse,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function group(): array
|
||||
{
|
||||
return $this->group;
|
||||
}
|
||||
|
||||
public function groupId(): string
|
||||
{
|
||||
return stringField($this->group, 'phonebook_group');
|
||||
}
|
||||
|
||||
public function groupName(): string
|
||||
{
|
||||
return stringField($this->group, 'name');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function matchingEntries(): array
|
||||
{
|
||||
return $this->matchingEntries;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function addedEntryIds(): array
|
||||
{
|
||||
return $this->addedEntryIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function existingEntryIds(): array
|
||||
{
|
||||
return $this->existingEntryIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function memberIds(): array
|
||||
{
|
||||
return $this->memberIds;
|
||||
}
|
||||
|
||||
public function changed(): bool
|
||||
{
|
||||
return $this->addedEntryIds !== [];
|
||||
}
|
||||
|
||||
public function phonebookResponse(): ?VoipMsResponse
|
||||
{
|
||||
return $this->phonebookResponse;
|
||||
}
|
||||
|
||||
public function groupResponse(): ?VoipMsResponse
|
||||
{
|
||||
return $this->groupResponse;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace VoipMs;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
require_once __DIR__ . '/VoipMsResponse.php';
|
||||
require_once __DIR__ . '/PhonebookGroupUpdateResult.php';
|
||||
require_once __DIR__ . '/PhonebookGroupEnsureResult.php';
|
||||
require_once __DIR__ . '/PhonebookNumberGroupResult.php';
|
||||
|
||||
final class VoipMsClient
|
||||
{
|
||||
public const DEFAULT_ENDPOINT = 'https://voip.ms/api/v1/rest.php';
|
||||
|
||||
public function __construct(
|
||||
private readonly string $username,
|
||||
private readonly string $password,
|
||||
private readonly string $endpoint = self::DEFAULT_ENDPOINT,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $params
|
||||
* @return array{decoded: mixed, raw: string, http_code: int}
|
||||
*/
|
||||
public function call(string $method, array $params = []): array
|
||||
{
|
||||
$response = $this->request($method, $params);
|
||||
|
||||
return [
|
||||
'decoded' => $response->data(),
|
||||
'raw' => $response->raw(),
|
||||
'http_code' => $response->httpCode(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $params
|
||||
*/
|
||||
public function request(string $method, array $params = []): VoipMsResponse
|
||||
{
|
||||
$query = array_merge(
|
||||
[
|
||||
'api_username' => $this->username,
|
||||
'api_password' => $this->password,
|
||||
'method' => $method,
|
||||
'content_type' => 'json',
|
||||
],
|
||||
$params,
|
||||
);
|
||||
|
||||
$url = $this->endpoint . '?' . http_build_query($query);
|
||||
$ch = curl_init($url);
|
||||
|
||||
if ($ch === false) {
|
||||
throw new RuntimeException('Unable to initialize cURL.');
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
CURLOPT_USERAGENT => 'voipms-cli/0.1',
|
||||
]);
|
||||
|
||||
$raw = curl_exec($ch);
|
||||
$curlError = curl_error($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($raw === false) {
|
||||
throw new RuntimeException('API request failed: ' . $curlError);
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$decoded = null;
|
||||
}
|
||||
|
||||
return new VoipMsResponse(is_array($decoded) ? $decoded : null, $raw, $httpCode);
|
||||
}
|
||||
|
||||
public function getBalance(bool $advanced = false): VoipMsResponse
|
||||
{
|
||||
$params = $advanced ? ['advanced' => '1'] : [];
|
||||
|
||||
return $this->request('getBalance', $params);
|
||||
}
|
||||
|
||||
public function getDidsInfo(?string $did = null): VoipMsResponse
|
||||
{
|
||||
return $this->request('getDIDsInfo', optionalParams(['did' => $did]));
|
||||
}
|
||||
|
||||
public function getPhonebook(?string $entryId = null, ?string $name = null, ?string $groupId = null, ?string $groupName = null): VoipMsResponse
|
||||
{
|
||||
return $this->request('getPhonebook', optionalParams([
|
||||
'phonebook' => $entryId,
|
||||
'name' => $name,
|
||||
'group' => $groupId,
|
||||
'group_name' => $groupName,
|
||||
]));
|
||||
}
|
||||
|
||||
public function getPhonebookByGroup(string $groupId): VoipMsResponse
|
||||
{
|
||||
return $this->getPhonebook(groupId: $groupId);
|
||||
}
|
||||
|
||||
public function getPhonebookByGroupName(string $groupName): VoipMsResponse
|
||||
{
|
||||
return $this->getPhonebook(groupName: $groupName);
|
||||
}
|
||||
|
||||
public function getPhonebookGroups(?string $groupId = null, ?string $name = null): VoipMsResponse
|
||||
{
|
||||
return $this->request('getPhonebookGroups', optionalParams([
|
||||
'group' => $groupId,
|
||||
'name' => $name,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $fields
|
||||
*/
|
||||
public function setPhonebook(array $fields): VoipMsResponse
|
||||
{
|
||||
return $this->request('setPhonebook', $fields);
|
||||
}
|
||||
|
||||
public function deletePhonebook(string $entryId): VoipMsResponse
|
||||
{
|
||||
return $this->request('delPhonebook', ['phonebook' => $entryId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $memberIds
|
||||
*/
|
||||
public function setPhonebookGroup(string $name, ?string $groupId = null, array $memberIds = []): VoipMsResponse
|
||||
{
|
||||
return $this->request('setPhonebookGroup', optionalParams([
|
||||
'group' => $groupId,
|
||||
'name' => $name,
|
||||
'members' => $memberIds === [] ? null : implode(';', $memberIds),
|
||||
]));
|
||||
}
|
||||
|
||||
public function deletePhonebookGroup(string $groupId): VoipMsResponse
|
||||
{
|
||||
return $this->request('delPhonebookGroup', ['group' => $groupId]);
|
||||
}
|
||||
|
||||
public function findPhonebookGroupByName(string $name): ?array
|
||||
{
|
||||
$response = $this->getPhonebookGroups(name: $name);
|
||||
|
||||
foreach ($this->extractPhonebookGroups($response) as $group) {
|
||||
if (strcasecmp(stringField($group, 'name'), $name) === 0) {
|
||||
return $group;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function findOrCreatePhonebookGroup(string $name): PhonebookGroupEnsureResult
|
||||
{
|
||||
$existing = $this->findPhonebookGroupByName($name);
|
||||
|
||||
if ($existing !== null) {
|
||||
return new PhonebookGroupEnsureResult($existing, false);
|
||||
}
|
||||
|
||||
$response = $this->setPhonebookGroup($name);
|
||||
$created = $this->findPhonebookGroupByName($name);
|
||||
|
||||
if ($created === null) {
|
||||
throw new RuntimeException("Phonebook group {$name} was created but could not be loaded.");
|
||||
}
|
||||
|
||||
return new PhonebookGroupEnsureResult($created, true, $response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function findPhonebookEntriesByNumber(string $number): array
|
||||
{
|
||||
$response = $this->getPhonebook();
|
||||
$normalizedNumber = normalizePhoneNumber($number);
|
||||
|
||||
return array_values(array_filter(
|
||||
$this->extractPhonebookEntries($response),
|
||||
static fn (array $entry): bool => normalizePhoneNumber(stringField($entry, 'number')) === $normalizedNumber,
|
||||
));
|
||||
}
|
||||
|
||||
public function addNumberToPhonebookGroup(
|
||||
string $number,
|
||||
string $groupName,
|
||||
?string $name = null,
|
||||
?string $note = null,
|
||||
?string $callerId = null,
|
||||
): PhonebookNumberGroupResult {
|
||||
$groupResult = $this->findOrCreatePhonebookGroup($groupName);
|
||||
$group = $groupResult->group();
|
||||
$groupId = $groupResult->groupId();
|
||||
$memberIds = parseMemberIds(stringField($group, 'members'));
|
||||
$memberSet = array_fill_keys($memberIds, true);
|
||||
$matchingEntries = $this->findPhonebookEntriesByNumber($number);
|
||||
$matchingIds = uniqueStrings(array_values(array_filter(array_map(
|
||||
static fn (array $entry): string => stringField($entry, 'phonebook'),
|
||||
$matchingEntries,
|
||||
))));
|
||||
$existingMemberIds = array_values(array_filter(
|
||||
$matchingIds,
|
||||
static fn (string $entryId): bool => isset($memberSet[$entryId]),
|
||||
));
|
||||
$phonebookResponse = null;
|
||||
$groupResponse = null;
|
||||
$addedEntryIds = [];
|
||||
|
||||
if ($existingMemberIds === []) {
|
||||
if ($matchingIds === []) {
|
||||
$phonebookResponse = $this->setPhonebook(optionalParams([
|
||||
'name' => $name ?? $number,
|
||||
'number' => normalizePhoneNumber($number),
|
||||
'callerid' => $callerId,
|
||||
'note' => $note,
|
||||
'group' => $groupId,
|
||||
]));
|
||||
|
||||
$matchingEntries = $this->findPhonebookEntriesByNumber($number);
|
||||
$matchingIds = uniqueStrings(array_values(array_filter(array_map(
|
||||
static fn (array $entry): string => stringField($entry, 'phonebook'),
|
||||
$matchingEntries,
|
||||
))));
|
||||
}
|
||||
|
||||
$addedEntryIds = array_values(array_filter(
|
||||
$matchingIds,
|
||||
static fn (string $entryId): bool => !isset($memberSet[$entryId]),
|
||||
));
|
||||
|
||||
if ($addedEntryIds !== []) {
|
||||
$memberIds = uniqueStrings(array_merge($memberIds, $addedEntryIds));
|
||||
$groupResponse = $this->setPhonebookGroup(stringField($group, 'name'), $groupId, $memberIds);
|
||||
}
|
||||
}
|
||||
|
||||
return new PhonebookNumberGroupResult(
|
||||
$group,
|
||||
$matchingEntries,
|
||||
$addedEntryIds,
|
||||
$existingMemberIds,
|
||||
$memberIds,
|
||||
$phonebookResponse,
|
||||
$groupResponse,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every matching phonebook entry ID from a phonebook group.
|
||||
*
|
||||
* This only changes group membership. It does not delete the phonebook entries.
|
||||
*
|
||||
* @param list<string> $entryIds
|
||||
*/
|
||||
public function removePhonebookEntriesFromGroup(string $groupId, array $entryIds): PhonebookGroupUpdateResult
|
||||
{
|
||||
$group = $this->fetchPhonebookGroup($groupId);
|
||||
$groupName = stringField($group, 'name');
|
||||
$existingMemberIds = parseMemberIds(stringField($group, 'members'));
|
||||
$removeSet = array_fill_keys($entryIds, true);
|
||||
$remainingMemberIds = array_values(array_filter(
|
||||
$existingMemberIds,
|
||||
static fn (string $entryId): bool => !isset($removeSet[$entryId]),
|
||||
));
|
||||
$removedEntryIds = array_values(array_filter(
|
||||
$existingMemberIds,
|
||||
static fn (string $entryId): bool => isset($removeSet[$entryId]),
|
||||
));
|
||||
|
||||
$response = $this->setPhonebookGroup($groupName, $groupId, $remainingMemberIds);
|
||||
|
||||
return new PhonebookGroupUpdateResult(
|
||||
$groupId,
|
||||
$groupName,
|
||||
uniqueStrings($removedEntryIds),
|
||||
$remainingMemberIds,
|
||||
[],
|
||||
$response,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all entries whose number matches from the group membership list.
|
||||
*
|
||||
* This handles duplicate phonebook entries for the same number by removing
|
||||
* every matching entry ID from the group. Use deleteMatchedEntries=true only
|
||||
* when those entries should be removed from the account phonebook entirely.
|
||||
*/
|
||||
public function removePhonebookNumberFromGroup(string $groupId, string $number, bool $deleteMatchedEntries = false): PhonebookGroupUpdateResult
|
||||
{
|
||||
$group = $this->fetchPhonebookGroup($groupId);
|
||||
$groupName = stringField($group, 'name');
|
||||
$existingMemberIds = parseMemberIds(stringField($group, 'members'));
|
||||
$entriesResponse = $this->getPhonebookByGroup($groupId);
|
||||
$entries = $this->extractPhonebookEntries($entriesResponse);
|
||||
$normalizedNumber = normalizePhoneNumber($number);
|
||||
|
||||
$matchedEntries = array_values(array_filter(
|
||||
$entries,
|
||||
static fn (array $entry): bool => normalizePhoneNumber(stringField($entry, 'number')) === $normalizedNumber,
|
||||
));
|
||||
|
||||
$removeIds = uniqueStrings(array_values(array_filter(array_map(
|
||||
static fn (array $entry): string => stringField($entry, 'phonebook'),
|
||||
$matchedEntries,
|
||||
))));
|
||||
$removeSet = array_fill_keys($removeIds, true);
|
||||
$remainingMemberIds = array_values(array_filter(
|
||||
$existingMemberIds,
|
||||
static fn (string $entryId): bool => !isset($removeSet[$entryId]),
|
||||
));
|
||||
|
||||
$response = $this->setPhonebookGroup($groupName, $groupId, $remainingMemberIds);
|
||||
|
||||
if ($deleteMatchedEntries) {
|
||||
foreach ($removeIds as $entryId) {
|
||||
$this->deletePhonebook($entryId);
|
||||
}
|
||||
}
|
||||
|
||||
return new PhonebookGroupUpdateResult(
|
||||
$groupId,
|
||||
$groupName,
|
||||
$removeIds,
|
||||
$remainingMemberIds,
|
||||
$matchedEntries,
|
||||
$response,
|
||||
);
|
||||
}
|
||||
|
||||
public function getCallerIdFiltering(?string $filteringId = null): VoipMsResponse
|
||||
{
|
||||
return $this->request('getCallerIDFiltering', optionalParams(['filtering' => $filteringId]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $fields
|
||||
*/
|
||||
public function setCallerIdFiltering(array $fields): VoipMsResponse
|
||||
{
|
||||
return $this->request('setCallerIDFiltering', $fields);
|
||||
}
|
||||
|
||||
public function deleteCallerIdFiltering(string $filteringId): VoipMsResponse
|
||||
{
|
||||
return $this->request('delCallerIDFiltering', ['filtering' => $filteringId]);
|
||||
}
|
||||
|
||||
public function getCallAccounts(?string $client = null): VoipMsResponse
|
||||
{
|
||||
return $this->request('getCallAccounts', optionalParams(['client' => $client]));
|
||||
}
|
||||
|
||||
public function getAccounts(?string $client = null): VoipMsResponse
|
||||
{
|
||||
return $this->getCallAccounts($client);
|
||||
}
|
||||
|
||||
public function getCallRecordings(
|
||||
string $account,
|
||||
string $dateFrom,
|
||||
string $dateTo,
|
||||
string $callType = 'all',
|
||||
?string $start = null,
|
||||
?string $length = null,
|
||||
): VoipMsResponse {
|
||||
return $this->request('getCallRecordings', optionalParams([
|
||||
'account' => $account,
|
||||
'date_from' => $dateFrom,
|
||||
'date_to' => $dateTo,
|
||||
'call_type' => $callType,
|
||||
'start' => $start,
|
||||
'length' => $length,
|
||||
]));
|
||||
}
|
||||
|
||||
public function getCallRecording(string $account, string $callRecording): VoipMsResponse
|
||||
{
|
||||
return $this->request('getCallRecording', [
|
||||
'account' => $account,
|
||||
'callrecording' => $callRecording,
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendCallRecordingEmail(string $account, string $email, string $callRecording): VoipMsResponse
|
||||
{
|
||||
return $this->request('sendCallRecordingEmail', [
|
||||
'account' => $account,
|
||||
'email' => $email,
|
||||
'callrecording' => $callRecording,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $filters
|
||||
*/
|
||||
public function getCdr(string $dateFrom, string $dateTo, string $timezone, array $filters = []): VoipMsResponse
|
||||
{
|
||||
if (!arrayHasAnyKey($filters, ['answered', 'noanswer', 'busy', 'failed'])) {
|
||||
$filters = array_merge([
|
||||
'answered' => '1',
|
||||
'noanswer' => '1',
|
||||
'busy' => '1',
|
||||
'failed' => '1',
|
||||
], $filters);
|
||||
}
|
||||
|
||||
return $this->request('getCDR', array_merge([
|
||||
'date_from' => $dateFrom,
|
||||
'date_to' => $dateTo,
|
||||
'timezone' => $timezone,
|
||||
], $filters));
|
||||
}
|
||||
|
||||
public function sendSms(string $did, string $destination, string $message): VoipMsResponse
|
||||
{
|
||||
return $this->request('sendSMS', [
|
||||
'did' => $did,
|
||||
'dst' => $destination,
|
||||
'message' => $message,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function fetchPhonebookGroup(string $groupId): array
|
||||
{
|
||||
$response = $this->getPhonebookGroups($groupId);
|
||||
$groups = $this->extractPhonebookGroups($response);
|
||||
|
||||
if ($groups === []) {
|
||||
throw new RuntimeException("Phonebook group {$groupId} was not found.");
|
||||
}
|
||||
|
||||
return $groups[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function extractPhonebookEntries(VoipMsResponse $response): array
|
||||
{
|
||||
$data = $response->data() ?? [];
|
||||
$entries = $data['phonebook'] ?? $data['phonebooks'] ?? [];
|
||||
|
||||
if (!is_array($entries)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter($entries, static fn (mixed $entry): bool => is_array($entry)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function extractPhonebookGroups(VoipMsResponse $response): array
|
||||
{
|
||||
$data = $response->data() ?? [];
|
||||
$groups = $data['phonebook_groups'] ?? [];
|
||||
|
||||
if (!is_array($groups)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter($groups, static fn (mixed $group): bool => is_array($group)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|null> $params
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function optionalParams(array $params): array
|
||||
{
|
||||
return array_filter($params, static fn (?string $value): bool => $value !== null && $value !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
function parseMemberIds(string $members): array
|
||||
{
|
||||
if ($members === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
array_map('trim', explode(';', $members)),
|
||||
static fn (string $member): bool => $member !== '',
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $values
|
||||
* @return list<string>
|
||||
*/
|
||||
function uniqueStrings(array $values): array
|
||||
{
|
||||
return array_values(array_unique($values));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
function stringField(array $row, string $field): string
|
||||
{
|
||||
$value = $row[$field] ?? '';
|
||||
|
||||
return is_scalar($value) ? (string) $value : '';
|
||||
}
|
||||
|
||||
function normalizePhoneNumber(string $number): string
|
||||
{
|
||||
return preg_replace('/\D+/', '', $number) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $values
|
||||
* @param list<string> $keys
|
||||
*/
|
||||
function arrayHasAnyKey(array $values, array $keys): bool
|
||||
{
|
||||
foreach ($keys as $key) {
|
||||
if (array_key_exists($key, $values)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace VoipMs;
|
||||
|
||||
final class VoipMsResponse
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed>|null $data
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ?array $data,
|
||||
private readonly string $raw,
|
||||
private readonly int $httpCode,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function data(): ?array
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
public function raw(): string
|
||||
{
|
||||
return $this->raw;
|
||||
}
|
||||
|
||||
public function httpCode(): int
|
||||
{
|
||||
return $this->httpCode;
|
||||
}
|
||||
|
||||
public function status(): ?string
|
||||
{
|
||||
$status = $this->data['status'] ?? null;
|
||||
|
||||
return is_string($status) ? $status : null;
|
||||
}
|
||||
|
||||
public function successful(): bool
|
||||
{
|
||||
return $this->httpCode < 400 && $this->status() === 'success';
|
||||
}
|
||||
|
||||
public function message(): ?string
|
||||
{
|
||||
$message = $this->data['message'] ?? null;
|
||||
|
||||
return is_string($message) ? $message : null;
|
||||
}
|
||||
}
|
||||
Executable
+849
@@ -0,0 +1,849 @@
|
||||
#!/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));
|
||||
Reference in New Issue
Block a user