Add the Bird Control Plane gateway, signed webhook ingestion, policy-gated writes, fail-closed production auto-activation, and RSA-OAEP bootstrap credential flow.
284 lines
13 KiB
Markdown
284 lines
13 KiB
Markdown
# Bird Module
|
|
|
|
This module provides Bird API integration for voice calls and number management.
|
|
|
|
## Control Plane integration
|
|
|
|
The Bird module is the credential and public-webhook authority for Pleno
|
|
Control Plane. Control Plane must never receive the Bird access key. The
|
|
integration is disabled by default and has three independently protected
|
|
surfaces:
|
|
|
|
- `GET /bird/health` is an end-user-permission-protected, read-only replacement
|
|
for the outbound-call connection test.
|
|
- `/bird/control-plane/v1/*` accepts only the configured
|
|
`control_plane_token` bearer token and exposes explicitly listed read
|
|
operations. It is not a generic Bird proxy.
|
|
- `POST /bird/webhooks/notifications` accepts only Bird notifications signed
|
|
against the exact configured public HTTPS URL. It checks the replay window
|
|
and durably deduplicates both Bird request ID and signature before returning
|
|
success.
|
|
- `POST /bird/flows/evaluate` accepts a timestamp-bound HMAC in
|
|
`x-pleno-flow-timestamp` and `x-pleno-flow-signature`. It returns only
|
|
deterministic `tag`, `assign`, `snooze`, or `close` decisions from a valid
|
|
versioned policy.
|
|
|
|
Required configuration is:
|
|
|
|
- `workspaceId`: canonical workspace identifier. While migrating, an empty
|
|
value falls back to legacy `workplaceId`.
|
|
- `allowed_channel_ids_json`: canonical JSON array of explicitly approved
|
|
channel IDs. Its empty default falls back to legacy `channelId`; malformed
|
|
or non-empty invalid configuration fails closed.
|
|
- `control_plane_enabled=false` and secret `control_plane_token`.
|
|
- secret `webhook_signing_key`, exact `webhook_public_url`, and
|
|
`webhook_replay_window_seconds=300`.
|
|
- `flow_enabled=false`, secret `flow_shared_secret`, and a valid
|
|
`flow_policy_json` document.
|
|
- `operations_actions_enabled=false` for confirmed typed voice actions.
|
|
- `outbound_messages_enabled=false`, `participantId`, and an immutable
|
|
`template_policy_json` allowlist for confirmed conversation replies.
|
|
|
|
The gateway intentionally does not expose conversation creation, number
|
|
deletion, physical gate actions, arbitrary recipients, attachments, or a
|
|
generic proxy. Outbound references are durably reserved before a provider
|
|
request; an ambiguous outcome must be inspected through
|
|
`/bird/control-plane/v1/messages/by-reference` and is never blindly retried.
|
|
Credentials and write switches can only be changed by the guarded CLI startup
|
|
path or its explicit break-glass command, never by a web request.
|
|
|
|
### Schema deployment and preflight
|
|
|
|
The durable webhook and outbound-action ledgers use checked-in schema version
|
|
`1`. Schema changes are never run from a web request or worker. Production
|
|
startup applies and verifies the schema automatically; these commands remain
|
|
available for manual preflight and break-glass recovery:
|
|
|
|
```bash
|
|
php scripts/bird-control-plane-schema.php check
|
|
php scripts/bird-control-plane-schema.php apply --yes
|
|
php scripts/bird-control-plane-schema.php check
|
|
```
|
|
|
|
`apply` is CLI-only and requires the explicit `--yes` guard. The status endpoint
|
|
publishes read-only `schema: {ready, version, expectedVersion, missing}` state.
|
|
Ledger-dependent reads, webhook ingestion, messages, and operational actions
|
|
fail closed with HTTP `503` and code `bird_schema_not_ready` until the preflight
|
|
is ready. Production auto-activation applies and verifies the schema before
|
|
enabling any Bird write switch; the manual command remains a diagnostic and
|
|
break-glass path.
|
|
|
|
### Fail-closed production auto-activation
|
|
|
|
The Coolify production container runs activation before PHP-FPM or nginx. It
|
|
first transactionally disables bootstrap readiness and all three write
|
|
switches. It then canonicalizes the existing legacy workspace/channel
|
|
configuration, applies and checks schema version `1`, validates configured
|
|
channels and conversations, resolves the access-key participant, pins the
|
|
public webhook URL to
|
|
`https://api.truckwash.io:4433/bird/webhooks/notifications`, and reconciles and
|
|
verifies webhooks while writes remain dark. Only then does one transaction
|
|
enable bootstrap readiness, the Control Plane, outbound messaging, and
|
|
operational-action switches, followed by final readiness checks.
|
|
It preserves existing valid credentials; otherwise it generates distinct
|
|
48-byte random Control Plane and webhook secrets inside the container. The
|
|
webhook key remains backend-only.
|
|
|
|
The Control Plane token is sealed with the committed RSA-3072 public key using
|
|
RSA-OAEP-SHA256 with MGF1-SHA256. OpenSSL receives plaintext only over child
|
|
stdin, never argv. Only the ciphertext, `RSA-OAEP-256` algorithm identifier,
|
|
SPKI SHA-256 fingerprint
|
|
`6dc63c6ffe33b8de0b1396d7f529f56aea0a685ef98168016161cf721ddc8c21`,
|
|
monotonic token version, and UTC update timestamp are bootstrap-visible.
|
|
Existing ciphertext is retained only when its internal token hash and all
|
|
metadata remain valid, so normal deployments do not rotate a working token.
|
|
|
|
Webhook subscription reconciliation runs before service readiness. The
|
|
reconciler first requires Bird's `available-webhooks` response to advertise the
|
|
`conversations` service, both `conversation.created` and
|
|
`conversation.updated`, and `channelId` filtering for each. It prefers the
|
|
documented organization/workspace list after deriving a single UUID-like
|
|
organization ID from workspace-consistent channel/conversation metadata.
|
|
|
|
When no organization ID is discoverable, the read-only preflight may probe the
|
|
existing workspace-scoped subscription list endpoint. That fallback is
|
|
accepted only when it returns an explicit `results`, `items`, or `data`
|
|
collection with bounded pagination. Any unsupported response, ambiguous page,
|
|
or provider error becomes `organization_id_required` before a POST or PATCH.
|
|
The reconciler creates one exact subscription for each event and allowlisted
|
|
channel, or patches only an existing subscription with the exact Pleno URL,
|
|
event, and sole channel filter. It never deletes or mutates unrelated
|
|
subscriptions.
|
|
|
|
Any schema, provider, encryption, subscription, or final readiness failure
|
|
transactionally restores bootstrap readiness and all three write switches to
|
|
false, then exits container startup before PHP-FPM/nginx starts. Flow and
|
|
template automation remain disabled unless their existing versioned policies
|
|
are explicit, non-empty, and structurally valid; Flow also requires an existing
|
|
strong shared secret.
|
|
|
|
### Local Control Plane credential bootstrap
|
|
|
|
The unauthenticated `GET /bird/control-plane/v1/bootstrap` response is
|
|
`Cache-Control: no-store` and contains only the fixed validated ciphertext
|
|
envelope. Unavailable state always returns the same `404 Not found` response.
|
|
The endpoint never returns plaintext, hashes, provider credentials, or the
|
|
backend-only webhook key.
|
|
|
|
On the Control Plane host, run:
|
|
|
|
```bash
|
|
scripts/bird-control-plane-bootstrap-local.sh
|
|
```
|
|
|
|
The script is pinned to `https://api.truckwash.io:4433` and accepts no URL
|
|
override. It validates the exact algorithm, public-key fingerprint, fixed
|
|
ciphertext shape, version, and timestamp, decrypts using the local `0600`
|
|
private key, and writes only a temporary `0600` candidate. It immediately proves
|
|
the bearer against the authenticated status endpoint and atomically retains it
|
|
as `/home/jeppe/.openclaw/credentials/bird.gateway-token` only after success.
|
|
|
|
## Endpoints
|
|
|
|
### Voice calls
|
|
- `POST /bird/voice/calls`
|
|
- Permission: `modules_bird_voice_calls_create`
|
|
- Required: `workspaceId`, `channelId`
|
|
- Body: passthrough to Bird call create API (except `workspaceId` and `channelId`).
|
|
|
|
- `GET /bird/voice/calls`
|
|
- Permission: `modules_bird_voice_calls_list`
|
|
- Required query: `workspaceId`, `channelId`
|
|
- Query params are passed through to Bird.
|
|
|
|
- `GET /bird/voice/calls/{id}`
|
|
- Permission: `modules_bird_voice_calls_get`
|
|
- Required query: `workspaceId`, `channelId`
|
|
|
|
- `POST /bird/voice/calls/{id}/hangup`
|
|
- Permission: `modules_bird_voice_calls_hangup`
|
|
- Required: `workspaceId`, `channelId`
|
|
|
|
- `POST /bird/voice/calls/test-outbound`
|
|
- Permission: `modules_bird_voice_calls_test_outbound`
|
|
- Required: `workspaceId`, `channelId`
|
|
- Places an outbound call to the fixed test number `+45 42 33 11 28` (`+4542331128`), polls call state, and sends hangup when state is `accepted` or `ongoing`.
|
|
- Optional tuning params:
|
|
- `pollIntervalSeconds` (default `2`)
|
|
- `maxPollSeconds` (default `30`)
|
|
- `hangupCause`
|
|
|
|
- `POST /bird/voice/calls/webhook/inbound`
|
|
- Permission: `modules_bird_voice_call_webhooks_trigger`
|
|
- Stateful inbound IVR webhook for phone-controlled department gates.
|
|
- On the initial request, the webhook accepts the live inbound call through Bird before returning the first gather command.
|
|
- Supports both request formats:
|
|
- Native-flow mode: top-level `{ callId, channelId, workspaceId }` for the initial fetch, then `{ callId, channelId, workspaceId, keys }` for selections.
|
|
- Compatibility mode: `{ payload, request, waitConditions }`, which returns a raw Bird `callCommand` gather envelope.
|
|
- Uses `department_gates` rows with `config.type=PHONE_CALL` as the source of truth for which departments and entrance/exit gates are offered.
|
|
- Department menu order follows `departments.order_priority`.
|
|
- Supports multi-digit department selections such as `10#`.
|
|
- Always prompts for both department selection and gate selection, even when only one valid option exists.
|
|
- Uses compact gate numbering:
|
|
- both gates available: `1=entrance`, `2=exit`
|
|
- entrance only: `1=entrance`
|
|
- exit only: `1=exit`
|
|
|
|
### Bird Flow Builder setup
|
|
- Preferred native-flow setup:
|
|
- `Voice` trigger
|
|
- `Answer call`
|
|
- `Async HTTP request` (initial IVR state fetch)
|
|
- `Gather digits from a call`
|
|
- `Async HTTP request` (submit selected keys)
|
|
- If the second HTTP response returns `completed=false`, run another `Gather digits from a call` with the returned prompt and gather settings.
|
|
- If the second HTTP response returns `completed=true`, end the flow or optionally say the returned `message`.
|
|
- The caller must be connected in Bird before the HTTP webhook step runs. If the flow omits `Answer call`, the inbound call can keep ringing until Bird times it out.
|
|
- Initial HTTP step:
|
|
- URL: `https://api.truckwash.io:4433/bird/voice/calls/webhook/inbound`
|
|
- Content type: `application/json`
|
|
- Body:
|
|
|
|
```json
|
|
{
|
|
"callId": "{{callId}}",
|
|
"channelId": "{{channelId}}",
|
|
"workspaceId": "{{workspaceId}}"
|
|
}
|
|
```
|
|
|
|
- Example native-flow response:
|
|
|
|
```json
|
|
{
|
|
"requestId": "request-123",
|
|
"callId": "4015cf84-8028-46a1-a0d9-9213e5bf4f09",
|
|
"status": "gather",
|
|
"completed": false,
|
|
"stage": "department_select",
|
|
"prompt": "Choose department. Press 1 for Roskilde. Press 2 for Demo.",
|
|
"gather": {
|
|
"input": "dtmf",
|
|
"maxNumKeys": 1,
|
|
"endKey": "#",
|
|
"timeout": 30,
|
|
"retries": 3,
|
|
"say": {
|
|
"locale": "en-US",
|
|
"voice": "female",
|
|
"text": "Choose department. Press 1 for Roskilde. Press 2 for Demo."
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- Use the initial HTTP step output to configure `Gather digits from a call`:
|
|
- Prompt/text from `prompt` or `gather.say.text`
|
|
- `maxNumKeys`, `endKey`, `timeout`, and `retries` from `gather`
|
|
- TTS `locale` and `voice` from `gather.say`
|
|
- Selection HTTP step:
|
|
- POST the same `callId`, `channelId`, and `workspaceId` plus the gathered digits as `keys`.
|
|
- Example body:
|
|
|
|
```json
|
|
{
|
|
"callId": "{{callId}}",
|
|
"channelId": "{{channelId}}",
|
|
"workspaceId": "{{workspaceId}}",
|
|
"keys": "{{gatheredKeys}}"
|
|
}
|
|
```
|
|
|
|
- Bird's current Voice API examples use BCP-47 locales such as `en-US` and TTS voices such as `female`/`male`. Avoid undocumented values like `alice`.
|
|
- Compatibility mode:
|
|
- The webhook still supports the older `{ payload, request, waitConditions }` contract and returns a raw `202 Accepted` Bird `callCommand` gather envelope.
|
|
- That mode is not suitable for Bird Flow Builder `Async HTTP request`, because Bird treats the response as data rather than executing the returned call command.
|
|
|
|
### Numbers
|
|
- `GET /bird/numbers`
|
|
- Permission: `modules_bird_numbers_list`
|
|
- Optional query: `workspaceId` (if not configured)
|
|
- Query params are passed through to Bird.
|
|
|
|
- `GET /bird/numbers/{id}`
|
|
- Permission: `modules_bird_numbers_get`
|
|
- Optional query: `workspaceId` (if not configured)
|
|
|
|
- `DELETE /bird/numbers/{id}`
|
|
- Permission: `modules_bird_numbers_delete`
|
|
- Optional query/body: `workspaceId` (if not configured)
|
|
- Releases/deletes a number if supported by Bird account policy.
|
|
|
|
## Logging
|
|
|
|
Bird requests are logged through `logs_o` with module `bird`, including:
|
|
- Request start (`BIRD_HTTP_REQUEST`)
|
|
- Response status (`BIRD_HTTP_RESPONSE`)
|
|
- API or transport errors (`BIRD_HTTP_ERROR`, `BIRD_HTTP_CURL_ERROR`)
|
|
- Test outbound flow events (`BIRD_TEST_OUTBOUND_CALL_*`)
|
|
|
|
## Notes
|
|
|
|
- Bird credentials and base URL are configured in Bird module config.
|
|
- Route handlers enforce workspace/channel requirements before outbound API calls.
|