-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathllms.txt
More file actions
107 lines (83 loc) · 16.5 KB
/
Copy pathllms.txt
File metadata and controls
107 lines (83 loc) · 16.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# Call Me Maybe
> A programmable home phone. Known callers ring the whole house; everyone else
> meets a lobby where they dial a 6-digit extension, or a bouncer says "Good day"
> and hangs up. Asterisk handles SIP, RTP, and DTMF; a single static Go binary
> called `doorman` decides who gets in, driving Asterisk over ARI on loopback. A
> Raspberry Pi is the obvious host, but anything that runs Asterisk will do.
The fastest way to understand the configuration surface is to ask the binary
rather than to read prose:
```bash
doorman schema # every config key, type, default, and cross-file
# reference as JSON Schema — policy.toml,
# handsets.toml, trunks.toml, contacts.toml, and the
# environment
doorman schema policy # just policy.toml
doorman schema handsets # just handsets.toml
doorman schema trunks # just trunks.toml (the optional provider inventory)
doorman schema contacts # just contacts.toml (the optional address books)
doorman schema env # just the environment variables
doorman check # validate a real config and report what it resolves to
doorman calls --json # the call log as JSON Lines: one record per call,
# numbers redacted unless --no-redact. --line and
# --direction narrow it on a box with several numbers
doorman balance --json # what is left on each prepaid trunk; exit 1 when any
# is under its threshold, so cron needs no wrapper
doorman help # every subcommand
```
**`doorman schema` describes shape; `doorman check` is the authority on
validity.** JSON Schema cannot express this system's cross-file identifier
references (a ladder step naming a handset that must exist in
`handsets.toml`), its mutually exclusive keys (`handsets` XOR `steps`), or the
roughly thirty semantic rules the validator applies. Those appear in the
schema as `x-cross-references` and `x-rules` annotations, but a config is only
known-good once `doorman check` exits 0.
**Every key must be one the schema names.** `doorman check` rejects a key it
does not recognise and suggests the nearest real one, so `voicmail` and
`afterhous` are caught rather than silently ignored — this is the loader
finally honouring the `additionalProperties: false` the schema always
declared. The running daemon only warns about the same key, because a policy
that loaded yesterday must never start taking the phone down. `doorman check`
also prints every extension's settings including the defaults it applied, so a
key that was dropped reads as a feature you configured and did not get.
## Configuration model
Three files, three cadences, plus two optional inventories. Do not merge them; the split is deliberate.
- **`.env`** — secrets and tuning. Changes rarely. Template: [examples/.env.example](https://github.com/ericdmoore/call-me-maybe/blob/main/examples/.env.example)
- **`handsets.toml`** — the hardware: what phones exist. Changes when hardware is bought. `doorman render` generates the per-handset Asterisk config from it, so the inventory and the dialplan cannot drift. Template: [examples/handsets.example.toml](https://github.com/ericdmoore/call-me-maybe/blob/main/examples/handsets.example.toml)
- **`policy.toml`** — the rules: who gets in, what rings, when. Changes weekly and is safe to hand-edit; an invalid file is rejected on reload and the last good policy stays in service. Template: [examples/policy.example.toml](https://github.com/ericdmoore/call-me-maybe/blob/main/examples/policy.example.toml)
- **`trunks.toml`** — *optional* — the providers: where numbers come from. Changes when you buy a number somewhere new. Template: [examples/trunks.example.toml](https://github.com/ericdmoore/call-me-maybe/blob/main/examples/trunks.example.toml)
- **`contacts.toml`** — *optional* — the address books: which vCard exports to read. Changes when somebody new joins the household. Template: [examples/contacts.example.toml](https://github.com/ericdmoore/call-me-maybe/blob/main/examples/contacts.example.toml)
**Not having a `trunks.toml` is the normal state, and is what every one-provider box does.** One registration fits comfortably in a hand-written `asterisk/pjsip.conf`, and with no such file `doorman render` generates only the handset config and every outbound call leaves by the dialplan's `DEFAULT_TRUNK`, exactly as it always has. Create it when a second provider turns copying PJSIP blocks into a chore: each `[[trunks]]` block becomes an endpoint, an auth, a registration and an AOR, plus one inbound dialplan context per provider with each line's DID routed into it. Every generated registration carries `line=yes` and `endpoint=<id>` — the pair that binds inbound traffic to the endpoint, whose absence makes calls hit the `anonymous` endpoint and vanish with no error anywhere. Passwords are named there (`password_env`) and substituted from `.env` at render time, exactly as handset passwords are; the loader refuses a `password_env` that is not shaped like a variable name. Deleting the file is the whole rollback.
A line names its provider with `[line] trunk = "voipms"`, an id from `trunks.toml`, validated as a cross-file reference the way handset ids are. **A call placed as a line leaves by that line's trunk**, on both outbound paths: `doorman render` writes `set_var=OUTBOUND_TRUNK=` beside `OUTBOUND_CID` on each handset endpoint, and the `*4` console sets both channel variables together. They travel as one value because a provider will not present a number its account does not own — it rejects the call or silently rewrites the caller ID, and neither is visible from this end. `doorman check` and `doorman render` refuse a line whose `outbound_cid` is a number this config declares at a *different* provider; a caller ID no `[line] number` declares is reported and allowed, because nothing can ask a provider which DIDs an account owns.
`trunks.toml` also carries `emergency_trunk`, which settles which provider a 911 call leaves by; unset, it is plain `policy.toml`'s `trunk` — the primary line, already the default for everything unqualified — and `doorman check` and every startup say which it is and whether that was chosen or inferred. **Undecided is an error**: `doorman check` exits non-zero and `doorman render` refuses to generate. 911 is routed by a generated `[cmm-emergency]` context that tries the designated trunk first and then every other in turn, so an emergency call still connects when the designated one is down — the dispatcher may then see the wrong address, which is a deliberate trade, because a connected call lets a human say their address out loud and a failed call gives them nothing. It never presents a line's caller ID, and it never asks whether a trunk is registered: it tries it, because a provider that ignores `OPTIONS` looks unreachable while working perfectly. **This is a supplementary phone and nobody should make it a household's only route to emergency services.**
A trunk may also carry `api_username`, `api_password_env` and `balance_min`, which is everything `doorman balance` needs. **A prepaid trunk that reaches zero does not error** — inbound calls simply stop arriving, and "nobody called today" is indistinguishable from a quiet Tuesday, which is the same silent-failure shape as a fetch loop that dies while still reporting healthy. The command prints a table across every trunk and exits 1 when any is below its threshold, 3 when one could not be checked at all, so a cron entry is a one-liner. **Balance is a capability rather than a provider feature**: a prepaid provider implements it, an invoiced one does not and is reported as "postpaid — no balance to report" rather than as a zero that looks alarming, and a provider doorman has no client for is reported too rather than silently skipped. **The daemon never reads those credentials**: a provider API key manages DIDs, sub-accounts and billing, which is far more privilege than the SIP sub-account password, so the check is a CLI and is best run wherever alerting already lives rather than on the Pi — `doorman balance --json` needs a copy of `trunks.toml` and the key, and no daemon at all. VoIP.ms additionally requires API access to be enabled in the portal and the calling machine's IP allow-listed there.
**Not having a `contacts.toml` is likewise the normal state**, and with no such file nothing is read and `doorman check` prints nothing about contacts at all. It exists because `[[people]]` is hand-typed and therefore always out of date, while every household already curates a contact list on their phones. Each `[[sources]]` block names a vCard export — a `path` today, a `url` with a `token_env` when fetching lands — and `kind = "block"` marks one as the spam list. doorman parses the subset real exporters emit (iCloud, Google Contacts, CardDAV, 2.1 with quoted-printable and folded lines) and counts what it does not understand rather than guessing. Every number normalises to E.164 and is classified by the vCard alone, with no lookups: **if a stranger can look the number up, it must not be automatic admission**. An `ORG` reads as a business, `TEL;TYPE=work` as probably published, an 800-family number as never personal, and a named card with a cell or home number and no `ORG` as a person — with everything ambiguous falling to published, because wrong-closed means a ten-second greeting and wrong-open means a findable number ringing the whole house at 3am. Sources union and de-duplicate by number; conflicts resolve to the restrictive answer (first source names it, published beats personal, block beats admit). The lobby walks the result as a ladder, first match wins: **a block source** (dismissed, and never hears the lobby — the difference between "not admitted" and "blocked"), then **`[[people]]`**, then **a personal contact** (both straight through, and the contact's name lands on the handset display and the call record exactly as an allow-list name does), then **a published contact** and everybody else (the lobby, dial an extension). A blocked caller is not a failed PIN attempt and never touches the rate-limit budget. **None of it is authoritative**: `[[people]]` beats the classifier outright, a number in both a block source and `[[people]]` is a contradiction `doorman check` exits non-zero on rather than ranks quietly, and deleting every source changes nothing but who hears the lobby.
A box answering several phone numbers has one policy file per number: `policy.<line>.toml` beside `policy.toml`, same schema, one independent failure domain each. The dialplan names the line — `Stasis(${DOORMAN_APP},line,biz)` — and bare `policy.toml` is the default line, which is what every call gets on an install with one number. `handsets.toml` is shared; allow-lists, extensions, PINs and rate-limit budgets are per line.
Each file's optional `[line]` section carries what makes a number itself: `label` and `number` for identity, `prompts` for a per-line prompt pack overriding `PROMPT_MEDIA_PREFIX`, and `on_no_input` — `dismiss` (the default, and what the lobby has always done), `voicemail`, or `ring-house` — for what a caller who dials nothing gets. That last key is the difference between a curt doorman and a courteous concierge running on one binary.
Outbound, the same section carries `outbound_cid` — what a call placed as this line shows the person being rung — and `outbound_handsets`, the phones that call as it without being asked. A handset no line claims presents plain `policy.toml`'s `outbound_cid`: the primary line, which is also the route 911 leaves by, so there is one answer to "which line am I on when I have not said" rather than two that can disagree. Dial `*4` from any handset to choose a different line for one call; it refuses emergency numbers, because E911 is registered per DID against a street address. Both paths go through one shared dialplan context so they cannot drift, and `_911` is deliberately outside it — the console can never reach an emergency route. The plain dial path needs no doorman at all — the caller ID and trunk are baked into the generated PJSIP config by `doorman render` — so a crashed daemon stops neither making nor receiving calls.
Observability follows the same shape: **one** call log for every line, with `line` on the record, so a whole day reads in order and `doorman calls --line biz` narrows it afterwards. A `*4` call is recorded as `direction: outbound` with the number in `dialled` and an outcome of `placed` — doorman hands the channel to the dialplan and never learns whether anybody answered. Both fields are absent for the default line and for inbound, so an install with one number writes byte-identical records and never sees a line column. The event webhook carries `line` too, which is what a Home Assistant automation routes an announcement on; it stays inbound-only, because an outbound call rings nothing in this house.
**Helping someone write a `policy.toml`? Read <https://callmemaybe.cc/llms-policy.txt> first.** It is the complete current key surface, two worked examples, the rules a JSON Schema cannot express, and — the part that matters — an explicit list of keys that appear in the planning docs but do not load. Machine-readable schema at <https://callmemaybe.cc/schema/policy.json>.
## Docs
- [README](https://github.com/ericdmoore/call-me-maybe/blob/main/README.md): architecture, diagrams, setup, suggested hardware
- [CLAUDE.md](https://github.com/ericdmoore/call-me-maybe/blob/main/CLAUDE.md): the invariants — the rules that, if broken, fail in ways that look like working software. Read this before changing behaviour.
- [docs/RUNBOOK.md](https://github.com/ericdmoore/call-me-maybe/blob/main/docs/RUNBOOK.md): provisioning, a bottom-up verification ladder, a symptom-to-cause table, and raw ARI calls for probing by hand
- [docs/architecture.md](https://github.com/ericdmoore/call-me-maybe/blob/main/docs/architecture.md): why the pieces are split the way they are
- [docs/editor.md](https://github.com/ericdmoore/call-me-maybe/blob/main/docs/editor.md): `doorman lsp` — diagnostics and completions from the same validator that guards the daemon
- [docs/TASKS.md](https://github.com/ericdmoore/call-me-maybe/blob/main/docs/TASKS.md): the backlog, with acceptance criteria
- [docs/PACKS.md](https://github.com/ericdmoore/call-me-maybe/blob/main/docs/PACKS.md): the prompt-pack format — the bouncer's personality is a swappable folder of audio
- [CONTRIBUTING.md](https://github.com/ericdmoore/call-me-maybe/blob/main/CONTRIBUTING.md): ground rules, no CLA
- [man page](https://github.com/ericdmoore/call-me-maybe/blob/main/docs/doorman.1): `man doorman` once installed
## Facts worth knowing before you suggest a change
- **Extensions are credentials.** A 6-digit PIN reachable from the PSTN is a password. Never print one, never log one, and prefer `doorman rotate` over choosing one by hand.
- **Caller IDs and PINs must never reach the logs.** This is enforced mechanically by a custom `go/analysis` pass in `tools/nologsecrets`, not just by review. `len(digits)` and `tail(number)` are allowed; the value itself is not, including through `fmt.Sprint` or a slice expression.
- **Caller IDs are normalised to E.164 before anything compares them.** The same person arrives as `5125550100`, `15125550100`, and `+15125550100` on different days. Probe any value with `doorman e164 "(512) 555-0100"`.
- **A bad `policy.toml` cannot take the phone down.** Edits are picked up live; a file that fails validation is logged and discarded.
- **Prompts are pre-rendered WAVs, not runtime TTS.** The Pi never synthesises speech, so a broken speech service can never make the phone unreachable.
- **The lobby and the bouncer are two branches of one state machine**, not separate services. Splitting them would add a network hop and no seam.
- **Tests never need a live Asterisk.** State machine changes go through the fake-ARI harness in `internal/lobby/fake_ari_test.go`.
- **Test fixtures use `555-01xx` numbers**, which are reserved for fiction.
## Repo conventions
- `make check` — gofmt, vet, lint, test, build. Must be green.
- `make cover` — tests with `-race` plus per-package coverage floors.
- `make hooks` — installs the pre-push gate (same checks as CI).
- Stdlib-first, with permissive dependencies where they earn it: TOML and YAML for configuration and templates, `gorilla/websocket` for the ARI event stream. The bar for a new one is maintenance, licence, and whether it runs on a call path — not a count. Lint tooling lives in a nested `tools/` module so it never enters the binary that ships to a Pi.
- Tests use stdlib `testing`, colocated with the code.