# FreeSWITCH integration

FreeSWITCH 1.10.12 (Alpine `main` package) runs with a minimal static config
(`infra/freeswitch/conf`). Everything that changes at runtime is served by the API through
`mod_xml_curl` at `POST /internal/freeswitch/xml` (HTTP basic `freeswitch:<INTERNAL_API_SECRET>`,
source-CIDR allow-list `INTERNAL_ALLOWED_CIDRS`).

| Request | Served from |
|---|---|
| `configuration` / `sofia.conf` | `buildProfiles()` — profiles `agents` (5060 + WSS 7443) and `carriers` (5080) + one gateway per carrier |
| `configuration` / `acl.conf` | list `esl` (`FREESWITCH_ESL_ALLOWED_CIDRS`) and `carriers` (carrier source IPs) |
| `directory` | the agent's HA1 (`a1-hash`), never a plaintext password; no caching |
| `dialplan` | per-call routing decided by the API (`DialplanRouter`) |

## Passwords
Agents have one long-lived password (desk phones) and any number of short-lived browser
passwords (`sip_tokens`, default TTL 1 h). FreeSWITCH accepts one secret per lookup, so the
directory endpoint compares the client's digest response against every valid HA1 and returns the
one that matches (`directoryA1Hash`). Only HA1 hashes are stored.

## Applying changes without restarts
The controller reconciles on every ESL (re)connect: loads `mod_sofia` if needed, starts missing
profiles, `reloadacl`. Gateway changes use `sofia profile carriers killgw <gw>` + `startgw <gw>`.

## Events used
`sofia::register`, `sofia::unregister`, `sofia::expire` → `registrations` table and the
`agent.registered` / `agent.unregistered` events.

## Call records and the timeline
Every routed call gets a `calls` row at routing time (dialplan request or `POST /v1/calls`). Each
channel carries `vp_call_id` and `vp_leg` (`agent` | `remote`); the controller turns
`CHANNEL_CREATE/PROGRESS/ANSWER/BRIDGE/HOLD/UNHOLD/HANGUP_COMPLETE`, `DTMF` and `RECORD_*` into
`call_events` (the timeline) and the normalized `call.*` events. The call ends when the last leg
hangs up (`finalizeCall` is idempotent because both legs often hang up together).

## Recording (hard-won rules, verified in M3)
- Profiles must set `rtp-timer-name=soft`. Without it, apps that produce audio (playback, record,
  speak, sleep) block until the far end sends media, bridge pre-execute hooks never fire, and two
  silent ends deadlock.
- Record the **agent** leg (stereo). Hold music is broadcast onto the other leg; a recording there
  is flooded with unthrottled frames.
- Start recordings and the consent announcement **inside FreeSWITCH**
  (`bridge_pre_execute_{aleg,bleg}_app` = `record_session` / `speak`; inbound consent via
  `answer` + `speak` in the dialplan). Any ESL command racing a hangup
  (`uuid_broadcast`, `uuid_record`) can leave half-destroyed sessions that count against
  `max-sessions` until restart. The E2E suite asserts no sessions are left behind.
- Set `RECORD_MIN_SEC=0`; the default discards short files (3 s with append).
- Never put `|` (leg separator) or unquoted spaces in dial strings / originate arguments: TTS
  engine and voice go in `tts_engine`/`tts_voice`, and `originate` arguments are single-quoted.
- `tone_stream://...` repeats forever unless `;loops=1`.
