Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Lib/GetReport.php
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ private function prepareCdrData($selectedRecords):array
'uniqid' => $provider->uniqid,
'username' => $provider->username,
'description' => $provider->description,
'host' => $provider->host,
];
}
$trunkResolver = new TrunkResolver($providerRows);
Expand Down
49 changes: 27 additions & 22 deletions Lib/TrunkResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@

final class TrunkResolver
{
/** @var array<string,array{name:string,id:string}> */
/** @var array<string,array{name:string,id:string,host:string}> */
private array $byId = [];
/** @var array<string,array<int,array{name:string,id:string}>> */
private array $byUsername = [];
/** @var array<string,int> */
private array $hostProviderCount = [];
/** @var array<string,array<string,array<int,array{name:string,id:string,host:string}>>> */
private array $byHostAndUsername = [];

public function __construct(iterable $providers)
{
Expand All @@ -18,11 +20,16 @@ public function __construct(iterable $providers)
if ($id === '' || $name === '') {
continue;
}
$candidate = ['name' => $name, 'id' => $id];
$host = self::normalizeHost((string)($provider['host'] ?? ''));
$candidate = ['name' => $name, 'id' => $id, 'host' => $host];
$this->byId[$id] = $candidate;
if ($host === '') {
continue;
}
$this->hostProviderCount[$host] = ($this->hostProviderCount[$host] ?? 0) + 1;
$username = self::normalizeNumber((string)($provider['username'] ?? ''));
if ($username !== '') {
$this->byUsername[$username][] = $candidate;
$this->byHostAndUsername[$host][$username][] = $candidate;
}
}
}
Expand All @@ -32,24 +39,17 @@ public function resolve(array $record, string $callType): array
{
$technical = (string)($record['line'] ?? '');
if (isset($this->byId[$technical])) {
return $this->resolved($this->byId[$technical], 'line_id');
}

if ($callType === 'incoming' || $callType === '2') {
$did = self::normalizeNumber((string)($record['did'] ?? ''));
$candidates = $did === '' ? [] : ($this->byUsername[$did] ?? []);
if (count($candidates) === 1) {
return $this->resolved($candidates[0], 'did_username');
}
if (count($candidates) > 1) {
return [
'name' => $technical,
'id' => $technical,
'status' => 'ambiguous',
'source' => 'did_username',
'candidates' => array_column($candidates, 'id'),
];
$lineProvider = $this->byId[$technical];
$host = $lineProvider['host'];
$isIncoming = in_array($callType, ['incoming', '2', '3'], true);
if ($isIncoming && $host !== '' && ($this->hostProviderCount[$host] ?? 0) > 1) {
$did = self::normalizeNumber((string)($record['did'] ?? ''));
$candidates = $did === '' ? [] : ($this->byHostAndUsername[$host][$did] ?? []);
if (count($candidates) === 1) {
return $this->resolved($candidates[0], 'did_username');
}
}
return $this->resolved($lineProvider, 'line_id');
}

return [
Expand All @@ -76,4 +76,9 @@ private static function normalizeNumber(string $number): string
{
return preg_replace('/\D+/', '', $number) ?: '';
}

private static function normalizeHost(string $host): string
{
return strtolower(trim($host));
}
}
68 changes: 68 additions & 0 deletions docs/superpowers/plans/2026-08-13-shared-host-trunk-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Shared-host Trunk Resolution Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Resolve incoming and missed calls to the correct SIP account by DID when the technical line belongs to a host shared by several providers.

**Architecture:** Keep the existing single provider query in `GetReport`, include `host` in each provider row, and build immutable hash indexes in `TrunkResolver`. Resolution remains line-first, with an O(1) host-and-DID refinement only for inbound call types and shared non-empty hosts.

**Tech Stack:** PHP 7.4-compatible production code, standalone PHP regression tests.

## Global Constraints

- Do not add per-CDR or per-call database queries.
- Treat call types `2` and `3` as inbound for shared-host refinement.
- Never group empty hosts.
- Retain the line provider when DID refinement is unavailable or ambiguous.
- Normalize hosts with trim plus lowercase and numbers by retaining digits only.

---

### Task 1: Shared-host resolver and report integration

**Files:**
- Modify: `tests/TrunkResolverTest.php`
- Modify: `Lib/TrunkResolver.php`
- Modify: `Lib/GetReport.php`

**Interfaces:**
- Consumes: provider arrays containing `uniqid`, `description`, `host`, and `username`; CDR arrays containing `line` and `did`.
- Produces: the existing `TrunkResolver::resolve(array $record, string $callType): array` contract, with `source=did_username` for successful shared-host refinement and `source=line_id` for fallback.

- [x] **Step 1: Add failing shared-host regression tests**

Extend provider fixtures with `host`. Assert that an incoming call whose line is Provider A and whose DID matches Provider B resolves to Provider B only when both share the same non-empty host. Add the same assertion for call type `3`. Assert fallback to Provider A for different hosts, empty hosts, outgoing calls, missing DID, and duplicate host-and-username candidates.

- [x] **Step 2: Run the focused test and verify RED**

Run: `php tests/TrunkResolverTest.php`

Expected: failure where a shared-host incoming call returns Provider A instead of Provider B.

- [x] **Step 3: Build constructor hash indexes**

In `TrunkResolver`, retain provider host in `byId`, count providers by normalized non-empty host, and build candidates indexed by normalized host then normalized username. Add a PHP 7.4-compatible host normalization helper using `strtolower(trim($host))`.

- [x] **Step 4: Implement line-first shared-host refinement**

In `resolve`, first look up `line`. If found, refine only when call type is `incoming`, `2`, or `3`, the selected provider has a non-empty host with count greater than one, and exactly one candidate matches that host plus normalized DID. Otherwise return the selected line provider. If line is unresolved, retain the existing unresolved technical result without a global DID lookup.

- [x] **Step 5: Pass provider host from the existing query result**

Add `'host' => $provider->host` to the provider row built by `GetReport::prepareCdrData`. Do not add another model lookup.

- [x] **Step 6: Run focused and full tests**

Run: `php tests/TrunkResolverTest.php`

Expected: `TrunkResolverTest: OK`.

Run every standalone PHP test under `tests/` using the repository's existing test loop.

Expected: exit code 0 and no failed test.

- [x] **Step 7: Review the diff and commit**

Run: `git diff --check && git diff -- Lib/TrunkResolver.php Lib/GetReport.php tests/TrunkResolverTest.php`

Commit only the three implementation files and this plan with message `fix: resolve providers sharing a SIP host`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Shared-host trunk resolution

## Problem

Several SIP provider accounts can use the same host. Asterisk records the
technical `line` of one account for calls received through that shared host,
while the call `did` identifies the actual account. Resolving only by `line`
therefore assigns some incoming calls to the wrong provider.

## Resolution rule

Provider metadata is loaded once with the existing `Sip::find("type='friend'")`
query. `GetReport` passes `uniqid`, `description`, `host`, and `username` to
`TrunkResolver`.

`TrunkResolver` builds these in-memory indexes in its constructor:

- provider by `uniqid`;
- number of providers per normalized non-empty host;
- providers by normalized host and normalized username.

Resolution starts with `line -> uniqid`. If no provider matches `line`, the
technical value remains unresolved; the resolver must not search globally by
DID.

For incoming and missed calls (`typeCall` `2` and `3`), when the provider found
by `line` has a non-empty host shared by multiple provider records, the resolver
uses `did` as a username within that host. One matching account overrides the
technical-line provider. No match or multiple matches retain the provider found
by `line`.

Internal and outgoing calls always retain line-based resolution. Empty hosts
are never treated as a shared-host group.

Hosts are normalized by trimming whitespace and converting to lowercase.
Usernames and DIDs are normalized by retaining digits only.

## Performance

There are no database queries during per-CDR resolution. Provider metadata is
queried once and all resolver operations use hash lookups. Construction is
linear in the number of providers and each resolution is constant-time apart
from the small candidate list for an exact host-and-username key.

## Compatibility and fallback

Existing line-based resolution remains the fallback in every ambiguous or
incomplete-data case. Providers with missing identifiers or descriptions keep
the existing exclusion behavior. Provider IDs returned after a successful
shared-host refinement belong to the DID-matched account.

## Tests

Regression tests will cover:

- a unique DID match overriding a different technical line on a shared host;
- the same behavior for missed calls (`typeCall=3`);
- no override when hosts differ;
- no grouping or override for empty hosts;
- no DID refinement for outgoing calls;
- fallback to the line provider for missing and ambiguous DID matches;
- preservation of existing normalization behavior.
62 changes: 45 additions & 17 deletions tests/TrunkResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,30 +15,58 @@ function trunkAssert($expected, $actual, string $message): void
}

$resolver = new TrunkResolver([
['uniqid' => 'SIP-A', 'username' => '+7 (495) 111-22-33', 'description' => 'Provider A'],
['uniqid' => 'SIP-B', 'username' => '74952223344', 'description' => 'Provider B'],
[
'uniqid' => 'SIP-A',
'username' => '+7 (495) 111-22-33',
'description' => 'Provider A',
'host' => ' Shared.Example.com ',
],
[
'uniqid' => 'SIP-B',
'username' => '74952223344',
'description' => 'Provider B',
'host' => 'shared.example.com',
],
]);

$line = $resolver->resolve(['line' => 'SIP-A', 'did' => '74952223344'], 'incoming');
trunkAssert('Provider A', $line['name'], 'stable line id has highest priority');
trunkAssert('line_id', $line['source'], 'line id evidence source');
trunkAssert('Provider B', $line['name'], 'DID refines provider on shared host');
trunkAssert('SIP-B', $line['id'], 'refined provider id');
trunkAssert('did_username', $line['source'], 'shared host DID evidence source');

$did = $resolver->resolve(['line' => 'unknown-peer', 'did' => '7 495 222-33-44'], 'incoming');
trunkAssert('Provider B', $did['name'], 'incoming unique DID resolves provider');
trunkAssert('did_username', $did['source'], 'DID evidence source');
$storedType = $resolver->resolve(['line' => 'unknown-peer', 'did' => '74952223344'], '2');
trunkAssert('Provider B', $storedType['name'], 'stored incoming call type resolves DID');
$missed = $resolver->resolve(['line' => 'SIP-A', 'did' => '7 495 222-33-44'], '3');
trunkAssert('Provider B', $missed['name'], 'missed call refines provider on shared host');

$outgoing = $resolver->resolve(['line' => 'unknown-peer', 'did' => '74952223344'], 'outgoing');
trunkAssert('unresolved', $outgoing['status'], 'outgoing call does not use DID');
$outgoing = $resolver->resolve(['line' => 'SIP-A', 'did' => '74952223344'], 'outgoing');
trunkAssert('Provider A', $outgoing['name'], 'outgoing call retains line provider');
trunkAssert('line_id', $outgoing['source'], 'outgoing line evidence source');

$unknown = $resolver->resolve(['line' => 'unknown-peer', 'did' => '74952223344'], 'incoming');
trunkAssert('unresolved', $unknown['status'], 'unknown line does not trigger global DID lookup');

$differentHosts = new TrunkResolver([
['uniqid' => 'SIP-A', 'username' => '100', 'description' => 'Provider A', 'host' => 'a.example.com'],
['uniqid' => 'SIP-B', 'username' => '200', 'description' => 'Provider B', 'host' => 'b.example.com'],
]);
$differentHost = $differentHosts->resolve(['line' => 'SIP-A', 'did' => '200'], '2');
trunkAssert('Provider A', $differentHost['name'], 'DID on another host does not override line');

$emptyHosts = new TrunkResolver([
['uniqid' => 'SIP-A', 'username' => '100', 'description' => 'Provider A', 'host' => ''],
['uniqid' => 'SIP-B', 'username' => '200', 'description' => 'Provider B', 'host' => ''],
]);
$emptyHost = $emptyHosts->resolve(['line' => 'SIP-A', 'did' => '200'], '2');
trunkAssert('Provider A', $emptyHost['name'], 'empty hosts are not grouped');

$ambiguous = new TrunkResolver([
['uniqid' => 'SIP-A', 'username' => '100500', 'description' => 'Provider A'],
['uniqid' => 'SIP-B', 'username' => '100500', 'description' => 'Provider B'],
['uniqid' => 'SIP-A', 'username' => '100500', 'description' => 'Provider A', 'host' => 'shared.example.com'],
['uniqid' => 'SIP-B', 'username' => '100500', 'description' => 'Provider B', 'host' => 'shared.example.com'],
]);
$result = $ambiguous->resolve(['line' => 'shared-peer', 'did' => '100500'], 'incoming');
trunkAssert('ambiguous', $result['status'], 'duplicate usernames are ambiguous');
trunkAssert('shared-peer', $result['name'], 'ambiguous result preserves technical value');
trunkAssert(2, count($result['candidates']), 'ambiguous candidates exposed');
$result = $ambiguous->resolve(['line' => 'SIP-A', 'did' => '100500'], 'incoming');
trunkAssert('Provider A', $result['name'], 'ambiguous shared-host DID retains line provider');
trunkAssert('line_id', $result['source'], 'ambiguous match falls back to line evidence');

$missingDid = $resolver->resolve(['line' => 'SIP-A', 'did' => ''], 'incoming');
trunkAssert('Provider A', $missingDid['name'], 'missing DID retains line provider');

echo "TrunkResolverTest: OK\n";
Loading