A small helpdesk system: an office Pixel running a patched GrapheneOS Dialer answers technician calls, records both call legs as separate channels, and a Ruby backend transcribes them through Whisper and files an AI summary against the caller. Squashed to a single commit for sharing. No credentials are included; secrets live outside the repo in /etc/helpdesk/env on the server or a gitignored .claude/env.local locally. See .claude/env.local.example for the shape. Start at README.md, then docs/architecture.md.
167 lines
8.8 KiB
Markdown
167 lines
8.8 KiB
Markdown
# Device API - the phone-to-backend contract
|
|
|
|
This is the wire contract between the patched Dialer and the backend. Two codebases implement it -
|
|
`components/dialer-patch` (Java) and `components/backend/server.rb` + `lib/helpdesk/domain.rb` (Ruby) -
|
|
so when you change one side, this page and the other side change with it. How the phone decides where
|
|
and when to send these requests is a separate page, `docs/phone-connectivity.md`.
|
|
|
|
## Transport
|
|
|
|
In production the phone posts to the device door: `https://moje.al.army:8443/api/v1`. That nginx vhost
|
|
speaks TLS without a client certificate (the phone can't hold one), allowlists only
|
|
`/api/v1/calls/`, `/api/v1/device/` and `/api/v1/recordings/` (everything else is a 404), blanks the
|
|
operator-identity headers, rate-limits at 10 requests/s, and caps bodies at 64 KB - except recordings,
|
|
which get 64 MB. The backend adds its own 80 MiB cap as a backstop (413 `body_too_large`). In local dev
|
|
the phone or simulator talks plain HTTP to `:4000` with the built-in dev credentials.
|
|
|
|
## Correlation
|
|
|
|
- `call_id` - UUIDv4, minted by the phone the moment a call is detected, sent on every request about
|
|
that call. It is the one key that stitches the webhooks, the recording upload, the transcript, and
|
|
the event together.
|
|
- `recording_uuid` - UUIDv4, minted by the phone when a recording file is finalized. One recording per
|
|
call. Retrying an upload reuses the same uuid, which is what makes retries safe.
|
|
- `ts` - the device's own UTC ISO-8601 timestamp for the moment the thing happened. The backend stores
|
|
it as sent; a delayed retry still reports the original time.
|
|
|
|
Delivery is at-least-once everywhere. Retries and out-of-order arrival are normal, not errors: every
|
|
webhook is an idempotent upsert keyed on `call_id`, and a duplicate returns 200 with the event's
|
|
current state.
|
|
|
|
## Authentication
|
|
|
|
Every request carries four headers, built by `DeviceAuthSigner.java` and checked by `authed?` in
|
|
`server.rb`:
|
|
|
|
| Header | Value |
|
|
|---|---|
|
|
| `Authorization` | `Bearer <device_token>` |
|
|
| `X-Request-Ts` | unix seconds, now |
|
|
| `X-Request-Nonce` | fresh random string per request |
|
|
| `X-Signature` | lowercase-hex HMAC-SHA256(`device_secret`, `"<ts>.<nonce>.<body-bytes>"`) |
|
|
|
|
The signature covers the exact body bytes, so UTF-8 Czech text can't diverge between the two sides.
|
|
The backend rejects a timestamp more than 300 seconds off its own clock, and a nonce it has seen in
|
|
the last 600 seconds. Everything compares constant-time. Failures are a plain 401; malformed JSON is a
|
|
422 that the phone must not retry.
|
|
|
|
In production the server refuses to boot with the repo-public dev credentials
|
|
(`dev-device-token`/`dev-device-secret`); real values come from `/etc/helpdesk/env`.
|
|
|
|
## Endpoints, device side
|
|
|
|
All paths relative to `/api/v1`. All device-authed.
|
|
|
|
`POST /calls/incoming` - fired at ring, before anyone answers. The phone never waits for the response;
|
|
ringing must not block on the network.
|
|
|
|
```jsonc
|
|
{ "call_id": "uuid", "ts": "2026-07-24T09:14:03Z",
|
|
"number": "+420601234567", // caller, E.164 or null when withheld
|
|
"presentation": "allowed", // allowed | restricted | unknown | payphone
|
|
"dialed_did": "+420800111222", // which of our numbers was called; may be omitted
|
|
"device_id": "pixel10a-office-1" }
|
|
→ 200 { "event_id": 123, "state": "ringing" }
|
|
```
|
|
|
|
The backend resolves the caller to a person and the dialed number to an operator queue, then pushes
|
|
the screen-pop to that operator's console.
|
|
|
|
`POST /calls/outgoing` - the same, for a call the phone places (usually because the operator clicked
|
|
dial in the console). Body is `call_id`, `ts`, `number`, `device_id`; there is no `dialed_did` on a
|
|
call we originate. The event is marked `direction: outbound` and everything downstream works the same.
|
|
|
|
`POST /calls/answered` - `{ "call_id", "ts" }` → 200 `{ event_id, state }`.
|
|
|
|
`POST /calls/ended`:
|
|
|
|
```jsonc
|
|
{ "call_id": "uuid", "ts": "…", "duration_s": 213,
|
|
"disconnect_cause": "remote", // local | remote | missed | rejected | error | unknown
|
|
"recording_uuid": "uuid|null" } // null = no recording ever started
|
|
→ 200 { "event_id": 123, "state": "ended" }
|
|
```
|
|
|
|
If `duration_s` is missing the backend derives it from the answered/ended timestamps. An `ended`
|
|
without a prior `answered` is fine for missed and rejected calls; for anything else the event gets a
|
|
`timeline_incomplete` flag, because a webhook evidently got lost.
|
|
|
|
`PUT /recordings/{recording_uuid}` - the audio bytes as the body (the phone records .m4a), with the
|
|
`X-Call-Id` header linking it to the call. Missing header → 400, because audio without a call id could
|
|
never be attached. Normally → 200 `{ event_id, state: "recording_uploaded" }` and transcription starts.
|
|
If the webhooks lost the race and no event exists yet, the audio is quarantined and the response is
|
|
200 `{ "quarantined": true, "call_id": … }`; when `/calls/incoming` finally lands, the recording is
|
|
reattached automatically. Quarantined audio nobody claims is dropped after 24 hours.
|
|
|
|
`GET /device/commands?device_id=…&wait=25` - the reverse channel. Long-poll: the backend holds the
|
|
request up to `wait` seconds (capped at 30) and answers the moment an operator queues a command, so a
|
|
console click reaches the phone in about one round trip. Response:
|
|
|
|
```jsonc
|
|
{ "commands": [ { "id": "uuid", "call_id": "uuid|null", "verb": "answer",
|
|
"arg": null, "issued_at": "…", "status": "pending" } ] }
|
|
```
|
|
|
|
Draining is destructive - returned commands are gone from the queue, so the phone must act on them.
|
|
The phone applies each verb through the same Telecom paths as the on-screen buttons, and the result
|
|
comes back through the ordinary webhooks. There is no separate acknowledgement.
|
|
|
|
`GET /device/heartbeat?device_id=…` - `{ "ok": true }`. Marks the device as present, nothing else. A
|
|
device counts as connected if it polled commands or heartbeated within the last 300 seconds; that
|
|
feeds the console's "phone connected" badge and gates click-to-dial.
|
|
|
|
`POST /device/contacts` - the phone pushes its address book, at most every 30 minutes:
|
|
|
|
```jsonc
|
|
{ "contacts": [ { "name": "Jan Novák", "numbers": ["+420601234567", "601234567"],
|
|
"emails": ["jan@example.cz"] } ] }
|
|
→ 200 { "upserted": 12 }
|
|
```
|
|
|
|
Matching is by E.164 number. The phone owns name and email; the console owns everything else about a
|
|
person, and a contact deleted on the phone is never deleted server-side - it still has call history.
|
|
|
|
## The command verbs
|
|
|
|
`answer reject hangup hold resume mute unmute dtmf route dial`, defined once as `COMMAND_VERBS` in
|
|
`domain.rb` and mirrored by the phone's `CommandGate.java`. Operators queue call-scoped verbs with
|
|
`POST /api/v1/calls/{call_id}/command` (operator-authed, `{ "verb": …, "arg": … }`, → 202) and the one
|
|
device-scoped verb with `POST /api/v1/dial` (`{ "number": … }`). Dial only accepts numbers that
|
|
normalize to E.164 - anything else (`*21*…#` style MMI codes, short codes) is rejected - and only
|
|
targets a device that is currently connected, with a queue cap of 20 pending commands.
|
|
|
|
## The event state machine
|
|
|
|
States rank `ringing(0) < answered(1) < ended(2) < recording_uploaded(3) < transcribed(4) <
|
|
summarised(5)`, with three side states that carry no rank: `recording_missing`, `no_speech`, `failed`.
|
|
|
|
The one rule: state only moves forward. An arriving webhook may fill in its timestamps at any time,
|
|
but the state becomes the higher of current and incoming - so a retried `ended` landing after the
|
|
upload changes nothing, and out-of-order delivery is harmless by construction. `advance!` in
|
|
`domain.rb` is the whole implementation.
|
|
|
|
After the upload the backend runs the pipeline on its own: transcription (hosted Whisper), then the
|
|
summary. A failed summary leaves the event at `transcribed` - the transcript is the valuable part and
|
|
the summary can be retried later. A transcription with no speech ends at `no_speech`; exhausted
|
|
retries end at `failed` with a reason on the event.
|
|
|
|
## Reconciliation
|
|
|
|
A watchdog runs every 60 seconds (`reconcile!` in `domain.rb`) and closes whatever the webhooks left
|
|
hanging:
|
|
|
|
| Situation | After | Result |
|
|
|---|---|---|
|
|
| `ringing`, nothing else ever arrived | 15 min | closed as missed |
|
|
| `answered`, no `ended` | 6 h | closed, no duration, flagged for review |
|
|
| `ended` said a recording exists, upload never came | 30 min | `recording_missing` - still recoverable, a late upload advances it |
|
|
| quarantined upload nobody claimed | 24 h | dropped |
|
|
|
|
Every terminal-but-incomplete path still leaves a countable event with an explicit state and flags;
|
|
calls never silently vanish.
|
|
|
|
## Errors, uniformly
|
|
|
|
401 unauthorized (bad token, signature, skew, or replay) · 404 unknown path or call · 413 body too
|
|
large · 422 malformed JSON or bad verb/argument - do not retry · 500 with `{"error":"server_error"}`
|
|
and no detail, because the detail may contain caller numbers; the real message is in the server log.
|