From 11d2d4c844e535debb5c55af10369d48b34a153b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 13 Sep 2026 21:50:42 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(accounts):=20Cap=20model=20=E2=80=94?= =?UTF-8?q?=20shared=20iMessage=20bot,=20user=20phone=20on=20account?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire LINQ_FROM_E164=+12138653024 as the one sitewide Treena iMessage bot and expose it as dashboard bot_number. users.phone stays the user's own number. Do not invent a shared Telnyx from-number: kickoff still requires users.from_did (not unique) until Cap names that E.164. Co-authored-by: natecl --- .../verify-call-agent/features/accounts.md | 21 +++---- .../features/did-provision.md | 2 +- .../features/linq-inbound.md | 2 +- .../features/linq-to-call.md | 8 +-- .../features/verify-accounts.md | 2 +- .env.example | 24 +++++--- README.md | 4 +- call_agent/accounts.py | 10 +++- call_agent/cli.py | 4 +- call_agent/config.py | 2 + call_agent/dashboard.py | 2 + call_agent/kickoff.py | 5 +- call_agent/linq.py | 23 ++++++-- call_agent/server.py | 3 + call_agent/verify_accounts.py | 5 +- docs/FEATURE_MAP.md | 26 +++++---- docs/WAVE1_HOLD_FEATURE_MAP_PLAN.md | 2 + docs/accounts.md | 30 +++++++--- docs/linq.md | 8 +-- tests/test_accounts.py | 56 +++++++++++++++++++ tests/test_feature_map.py | 4 ++ tests/test_messaging.py | 12 ++++ 22 files changed, 192 insertions(+), 63 deletions(-) diff --git a/.cursor/skills/verify-call-agent/features/accounts.md b/.cursor/skills/verify-call-agent/features/accounts.md index 9a65a26..3054440 100644 --- a/.cursor/skills/verify-call-agent/features/accounts.md +++ b/.cursor/skills/verify-call-agent/features/accounts.md @@ -1,18 +1,19 @@ # Accounts (`users` row) -The `users` table is the runtime account record. Kickoff looks it up by inbound phone. Stripe stays checkout and billing. call-agent-web POSTs a signed body to Fly `POST /accounts/sync`; Fly is the sole writer. Those Fly routes (`POST /accounts/sync` and `GET /accounts/dashboard`) are intended, not accidental. Dashboard UI stays on Pages (web). CLI `account upsert --from-did` seeds a row, including the assigned Treena number, until that website PR lands. Name is collected on website `/start` and that copy is in settings after signup. Kickoff reads `users.first_name`. Never collected over iMessage. Display name is `first_name` + `prefs.full_name`. There is no `users.name` column. Signed `GET /accounts/dashboard` packages that row plus linq artifacts so the website does not open Neon or R2. Prove with `call-agent verify-accounts`. This skill never sets `NEON_DATABASE_URL` or `ACCOUNT_SYNC_SECRET`. +The `users` table is the runtime account record. Cap model: one shared Treena iMessage bot (`LINQ_FROM_E164`); each account stores the user's own phone. Kickoff looks it up by inbound phone. Stripe stays checkout and billing. call-agent-web POSTs a signed body to Fly `POST /accounts/sync`; Fly is the sole writer. Those Fly routes (`POST /accounts/sync` and `GET /accounts/dashboard`) are intended, not accidental. Dashboard UI stays on Pages (web). CLI `account upsert --from-did` seeds a row, including the Telnyx caller ID, until that website PR lands. Name is collected on website `/start` and that copy is in settings after signup. Kickoff reads `users.first_name`. Never collected over iMessage. Display name is `first_name` + `prefs.full_name`. There is no `users.name` column. Signed `GET /accounts/dashboard` packages that row, the sitewide `bot_number`, plus linq artifacts so the website does not open Neon or R2. Prove with `call-agent verify-accounts`. This skill never sets `NEON_DATABASE_URL` or `ACCOUNT_SYNC_SECRET`. ## Sub-features -- `users-row` one `users` row per phone: `first_name`, phone, assigned Treena `from_did`, minutes, retain, prefs, Stripe ids. No `users.name` column. -- `from-did` nullable E.164 Telnyx caller ID. Rejected when equal to `phone`. Additive `ALTER TABLE` on existing SQLite and Neon `users` tables. +- `users-row` one `users` row per phone: `first_name`, the user's own phone, Telnyx `from_did` (not unique), minutes, retain, prefs, Stripe ids. No `users.name` column. +- `from-did` nullable E.164 Telnyx caller ID. Not unique. Rejected when equal to `phone`. Additive `ALTER TABLE` on existing SQLite and Neon `users` tables. Not the shared iMessage bot. +- `shared-bot` `LINQ_FROM_E164` is the one Text Treena number (sandbox `+12138653024` until ~Sep 18). Dashboard `bot_number` is that key. - `omit-preserve` omitted `first_name`, `prefs`, `minutes_used`, and `from_did` keep the existing values. Explicit `minutes_used` `0` writes 0. -- `account-upsert` CLI seed without HMAC. `--from-did` sets the Treena number. Omitted `--from-did` and `--name` keep existing values. +- `account-upsert` CLI seed without HMAC. `--phone` is the user's number. `--from-did` sets the Telnyx from-number. Omitted `--from-did` and `--name` keep existing values. - `account-get` read-back of that phone, including `from_did`. - `account-list` every row (`USER PHONE NAME FROM_DID MINUTES RETAIN`). - `accounts-sync` HMAC `POST /accounts/sync` upserts; Fly is the only writer. Omitted name/prefs/`minutes_used`/`from_did` keep seeded values. -- `accounts-dashboard` HMAC `GET /accounts/dashboard?phone=` packages `users` + linq thread index + retained `call_report`/transcript. Empty lists when unknown or wiped. -- `kickoff-lookup` inbound phone → `users.first_name`, `users.user_id`, and `users.from_did` for the call. +- `accounts-dashboard` HMAC `GET /accounts/dashboard?phone=` packages `users` + `bot_number` + linq thread index + retained `call_report`/transcript. Empty lists when unknown or wiped. +- `kickoff-lookup` inbound phone → `users.first_name`, `users.user_id`, and `users.from_did` for the Telnyx call. ## How to get to it (user POV) @@ -20,7 +21,7 @@ The `users` table is the runtime account record. Kickoff looks it up by inbound - Run `call-agent account get --phone +14155552671`. - Run `call-agent account list`. - Website checkout/settings POST to Fly `/accounts/sync` (intended Fly route). Until the writer is live, CLI seed is the L1 path. Name is collected on website `/start` and copied into settings after signup. The website is not expected to send `from_did` this climb. Stripe events without a name must omit `first_name`. -- Website dashboard UI stays on Pages. Fly `GET /accounts/dashboard?phone=` with the same HMAC family is the packaged read. Web must not open Neon/R2. +- Website dashboard UI stays on Pages. Fly `GET /accounts/dashboard?phone=` with the same HMAC family is the packaged read, including `bot_number`. Web must not open Neon/R2. ## Driving it with call-agent @@ -39,9 +40,9 @@ Preconditions: - SQLite `users` is the same file as `dids`. On Fly, set `NEON_DATABASE_URL` or the row dies on redeploy. - `account upsert` does not need `ACCOUNT_SYNC_SECRET`. The HTTP routes do. -- Kickoff `BookRequest.user_id` is `users.user_id`. The outbound Telnyx caller ID is `users.from_did`. Confirm/dial never calls `ledger.provision`. -- `from_did` must not equal `phone`. Personal iMessage number is not the Treena from-number. +- Kickoff `BookRequest.user_id` is `users.user_id`. The outbound Telnyx caller ID is `users.from_did`. Confirm/dial never calls `ledger.provision`. The iMessage bot is `LINQ_FROM_E164`, not `from_did`. +- `from_did` must not equal `phone`. Personal number is not the Telnyx from-number. Two accounts may share one `from_did`. - Omitted `--from-did` / `--name` (and omitted `from_did` / `first_name` / `prefs` / `minutes_used` on `/accounts/sync`) keep existing values so Stripe sync cannot wipe an ops-seeded DID, name, or minutes count. Explicit `minutes_used` `0` is a write. -- Dashboard HMAC signs the E.164 `phone` query value, not the URL-encoded query string. Unsigned GET is 401. Unknown phone returns empty lists. +- Dashboard HMAC signs the E.164 `phone` query value, not the URL-encoded query string. Unsigned GET is 401. Unknown phone returns empty lists. `bot_number` still comes from `LINQ_FROM_E164`. - Do not collect a name over iMessage. Name is collected on website `/start` and is in settings after signup. Do not set `LINQ_KICKOFF_CALLER` or `LINQ_KICKOFF_USER_ID`. - This skill never POSTs `/accounts/sync` live, never GETs `/accounts/dashboard` live, and never deploys Fly. diff --git a/.cursor/skills/verify-call-agent/features/did-provision.md b/.cursor/skills/verify-call-agent/features/did-provision.md index b474c56..8b1b3b7 100644 --- a/.cursor/skills/verify-call-agent/features/did-provision.md +++ b/.cursor/skills/verify-call-agent/features/did-provision.md @@ -32,4 +32,4 @@ Preconditions: - A leftover ledger from a previous run makes the first provision return a number that is not `+15555550100`. Use a fresh `CALL_AGENT_DB_PATH`. - Stub numbers are assigned in order of first provision, not by user name. - Live provisioning orders a real number and costs money. This skill never runs it. -- Confirm/dial never calls `Ledger.provision`. `did provision` is an ops verb, not a live-call prerequisite. +- Confirm/dial never calls `Ledger.provision`. `did provision` is an ops verb, not a live-call prerequisite. Cap account model does not assign a unique Treena iMessage DID per user. Live from-DID is `users.from_did` or CLI `--from-did`. diff --git a/.cursor/skills/verify-call-agent/features/linq-inbound.md b/.cursor/skills/verify-call-agent/features/linq-inbound.md index ed7580f..5ba38c4 100644 --- a/.cursor/skills/verify-call-agent/features/linq-inbound.md +++ b/.cursor/skills/verify-call-agent/features/linq-inbound.md @@ -35,7 +35,7 @@ Preconditions: ## Gotchas -- SMS is off. A 501 `missing_app_base_url` means the Worker has no FastAPI origin. FastAPI `POST /linq/webhook` verifies HMAC, then replies via MessagingPort. Live reply needs `LINQ_API_TOKEN` and `LINQ_FROM_E164` on Fly. +- SMS is off. A 501 `missing_app_base_url` means the Worker has no FastAPI origin. FastAPI `POST /linq/webhook` verifies HMAC, then replies via MessagingPort. Live reply needs `LINQ_API_TOKEN` and `LINQ_FROM_E164` (shared bot) on Fly. - Prod FastAPI is the always-on Fly host `https://hidden-beacon-080.fly.dev` (no path) with `LINQ_WEBHOOK_SIGNING_SECRET`. Nathan's laptop is not that host. - There is no `call-agent-web` map in this repository. The Worker name is `call-agent-webhooks`. - Pointing `PUBLIC_BASE_URL` at the Worker sends Telnyx media to `/media-stream` and gets 501. diff --git a/.cursor/skills/verify-call-agent/features/linq-to-call.md b/.cursor/skills/verify-call-agent/features/linq-to-call.md index 3e3b86e..0d1ed3e 100644 --- a/.cursor/skills/verify-call-agent/features/linq-to-call.md +++ b/.cursor/skills/verify-call-agent/features/linq-to-call.md @@ -12,7 +12,7 @@ Closed alpha testers text Treena on iMessage. A natural book ask on that chat co - `parse-fail` A phone follow-up that is not a number, or a confirm follow-up that is not an affirmative, texts a plain intake line. No `Call failed:` / `Call not placed:` prefix. No dial. - `account-lookup` Inbound phone must match a `users` row. The call uses that row's first name, `user_id`, and `from_did`. - `account-gate` No row, a row with no first name, or a row with no `from_did` texts a plain line. Never `Call failed: missing caller name`. Never `ledger.provision`. -- `from-did` Confirm/dial uses `users.from_did` as `DialSpec.from_did`. `run_call` takes a required `from_did` kwarg. `KickoffSession.from_did` stays the Linq iMessage line. +- `from-did` Confirm/dial uses `users.from_did` as `DialSpec.from_did`. `run_call` takes a required `from_did` kwarg. `KickoffSession.from_did` stays the shared Linq iMessage bot (`LINQ_FROM_E164`). - `no-number-order` Confirm/dial never calls `ledger.provision`, `search_and_order_number`, or `POST /v2/number_orders`. - `echo-else` Any other inbound text stays on `Received: {text}`. - `async-b` HMAC verify, parse, event-id dedupe, then 202 `{ok, kicked_off, event_id}` on the approve follow-up. `BackgroundTasks` runs `run_kickoff` in this process. @@ -22,7 +22,7 @@ Closed alpha testers text Treena on iMessage. A natural book ask on that chat co ## How to get to it (user POV) -- Have an account (website checkout, or `call-agent account upsert --phone --name Nathan --from-did `) with a first name and assigned Treena number. +- Have an account (website checkout, or `call-agent account upsert --phone --name Nathan --from-did `) with a first name and Telnyx from-number. The number people text is the shared bot `LINQ_FROM_E164`, not a per-user DID. - Text Treena `can you make a reservation with this restaurant at 7:30 for a party of two?`. - If she asks for time (or party and time), reply with those before the restaurant number. - Reply with the restaurant phone number when she asks. @@ -46,8 +46,8 @@ Preconditions: - `PUBLIC_BASE_URL` and `PUBLIC_WEBHOOK_URL` on Fly must both be `https://hidden-beacon-080.fly.dev`. The Worker does not terminate media. - One Fly machine: `BOOK` is process-local. Do not scale this app until that is no longer true. - Confirm/dial never provisions a DID. A missing `users.from_did` texts `NO_FROM_DID_TEXT` and does not buy a number. -- Live kickoff needs a `users` row on Fly's Neon for the tester's iMessage phone, with `from_did` set. Seed with `account upsert --from-did` until call-agent-web POSTs `/accounts/sync`. -- `KickoffSession.from_did` is the Linq iMessage line. Telnyx caller ID is `KickoffParsed.from_did` / `run_call(from_did=...)`. +- Live kickoff needs a `users` row on Fly's Neon for the tester's iMessage phone, with Telnyx `from_did` set. Seed with `account upsert --from-did` until call-agent-web POSTs `/accounts/sync`. +- `KickoffSession.from_did` is the shared Linq iMessage bot (`LINQ_FROM_E164`). Telnyx caller ID is `KickoffParsed.from_did` / `run_call(from_did=...)`. - Mid-call iMessage is not shipped. Do not treat a missing pause/resume as a failed L1 kickoff. - Pending collect and confirm live at `linq/pending/.json` under artifacts. It is not the live-call pause/resume seam. - Do not collect a name in iMessage. Name is collected on website `/start` and is in settings after signup. Do not set `LINQ_KICKOFF_CALLER` or `LINQ_KICKOFF_USER_ID`. diff --git a/.cursor/skills/verify-call-agent/features/verify-accounts.md b/.cursor/skills/verify-call-agent/features/verify-accounts.md index 54dfdbc..16c6ebe 100644 --- a/.cursor/skills/verify-call-agent/features/verify-accounts.md +++ b/.cursor/skills/verify-call-agent/features/verify-accounts.md @@ -9,7 +9,7 @@ - `cli-roundtrip` upserts `first_name`, `from_did`, prefs, and minutes, then get/list match. - `omit-preserve` omitted `first_name`, `prefs`, `minutes_used`, and `from_did` keep the existing values. - `sync-hmac` unsigned POST is 401; signed POST upserts Stripe ids without wiping name or from-DID. -- `dashboard-get` unsigned GET is 401; signed GET packages `users` plus computed `user.name`. Unknown phone is empty lists. Dashboard UI stays on Pages (web). +- `dashboard-get` unsigned GET is 401; signed GET packages `users` plus computed `user.name` and sitewide `bot_number` from `LINQ_FROM_E164`. Unknown phone is empty lists. Dashboard UI stays on Pages (web). - `no-name-column` schema has `first_name` and `prefs`, not `users.name`. ## How to get to it (user POV) diff --git a/.env.example b/.env.example index bbd417c..1b9a56b 100644 --- a/.env.example +++ b/.env.example @@ -40,22 +40,28 @@ PUBLIC_WEBHOOK_URL= CALL_AGENT_PORT=8000 # Linq Partner API V3. Stub mode needs none of these. Token is a box secret. -# LINQ_FROM_E164 is the sending line. LINQ_WEBHOOK_URL is the Workers HTTPS -# target. LINQ_WEBHOOK_SIGNING_SECRET is the subscription signing secret. -# APP_BASE_URL is a Worker var/secret (FastAPI origin, no path), not a -# Python Settings field. Prod origin is https://hidden-beacon-080.fly.dev. -# Set it on the Worker, not in this process: +# LINQ_FROM_E164 is the one shared Treena iMessage bot for everyone (Cap +# account model). Not a unique Treena DID per user. Linq sandbox until +# ~Sep 18; ops will confirm if the E.164 changes. LINQ_WEBHOOK_URL is +# the Workers HTTPS target. LINQ_WEBHOOK_SIGNING_SECRET is the +# subscription signing secret. APP_BASE_URL is a Worker var/secret +# (FastAPI origin, no path), not a Python Settings field. Prod origin is +# https://hidden-beacon-080.fly.dev. Set it on the Worker, not in this +# process: # cd workers/webhooks # npx wrangler secret put APP_BASE_URL # npx wrangler deploy LINQ_API_TOKEN= -LINQ_FROM_E164= +LINQ_FROM_E164=+12138653024 LINQ_WEBHOOK_SIGNING_SECRET= LINQ_WEBHOOK_URL= -# L1 iMessage kickoff. The outbound Telnyx caller ID is users.from_did -# for the inbound iMessage phone. Seed it with: -# call-agent account upsert --phone --name --from-did +# L1 iMessage kickoff. Each account stores the user's own phone +# (`users.phone`, the inbound iMessage sender). The shared bot people +# text is LINQ_FROM_E164, not a per-user Treena DID. The outbound Telnyx +# caller ID is still users.from_did (not unique; not the iMessage bot). +# Seed a tester with: +# call-agent account upsert --phone --name --from-did # Caller name comes from the users row, never from iMessage and never # from LINQ_KICKOFF_CALLER. No env var sets from_did. diff --git a/README.md b/README.md index 95da328..c14b279 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # call-agent -A phone agent that calls a business on your behalf, asks one quick question, and writes down the answer. You paste an E.164 number, pick `hours`, `stock`, or `aisle`, and give your first name. The agent dials from a Telnyx number that belongs to you, opens with "I'm calling on behalf of Nathan", gets the answer, hangs up, and leaves a result, a transcript, and the call audio on disk. +A phone agent that calls a business on your behalf, asks one quick question, and writes down the answer. You paste an E.164 number, pick `hours`, `stock`, or `aisle`, and give your first name. The agent dials from a Telnyx number (`users.from_did`), opens with "I'm calling on behalf of Nathan", gets the answer, hangs up, and leaves a result, a transcript, and the call audio on disk. Closed-alpha testers text one shared Treena iMessage bot (`LINQ_FROM_E164`); each account stores the user's own phone. The stack is Python 3.12, Pydantic v2, Typer, FastAPI (two routes), Telnyx Call Control with media streaming, an app-owned GPT-Live bridge that follows [Telnyx's outbound GPT-Live recipe](https://telnyx.com/resources/outbound-ai-calls-python-openai-live), and a DID ledger that defaults to SQLite. Set `NEON_DATABASE_URL` to use Neon. Set the four `R2_*` vars to put finished artifacts on R2. The design and its rationale are in [docs/design.md](docs/design.md). How V1 prompts GPT-Live, and the OpenAI page that is the source of truth for it, are in [docs/gpt-live-prompting.md](docs/gpt-live-prompting.md). What this tree implements today is listed in [docs/FEATURE_MAP.md](docs/FEATURE_MAP.md). The standing verification map is [`.cursor/skills/verify-call-agent/features/`](.cursor/skills/verify-call-agent/features/README.md). Both maps update when a lever ships. @@ -24,7 +24,7 @@ fly secrets set ACCOUNT_SYNC_SECRET=... fly deploy ``` -`LINQ_WEBHOOK_SIGNING_SECRET` is required for inbound HMAC. `ACCOUNT_SYNC_SECRET` is required for `POST /accounts/sync` (call-agent-web checkout/settings). Live inbound reply also needs `LINQ_API_TOKEN` and `LINQ_FROM_E164` on this process. HMAC verify without a token still ACKs ignored events; `message.received` send uses StubLinq when the token is unset. Seed L1 testers with `call-agent account upsert --phone +1... --name Nathan --from-did +1...` until the website PR POSTs sync. `from_did` is the assigned Treena number, not the tester's iMessage phone. The sync contract is [docs/accounts.md](docs/accounts.md). This tree does not `fly deploy`. +`LINQ_WEBHOOK_SIGNING_SECRET` is required for inbound HMAC. `ACCOUNT_SYNC_SECRET` is required for `POST /accounts/sync` (call-agent-web checkout/settings). Live inbound reply also needs `LINQ_API_TOKEN` and `LINQ_FROM_E164` on this process. `LINQ_FROM_E164` is the one shared Treena iMessage bot (Cap/ops sandbox `+12138653024` until ~Sep 18). HMAC verify without a token still ACKs ignored events; `message.received` send uses StubLinq when the token is unset. Seed L1 testers with `call-agent account upsert --phone +1... --name Nathan --from-did +1...` until the website PR POSTs sync. `--phone` is the user's own number. `from_did` is the Telnyx caller ID, not the shared bot. The sync contract is [docs/accounts.md](docs/accounts.md). This tree does not `fly deploy`. Then point the Worker at the Fly origin (no path) and deploy the Worker: diff --git a/call_agent/accounts.py b/call_agent/accounts.py index cdb76f6..f272dea 100644 --- a/call_agent/accounts.py +++ b/call_agent/accounts.py @@ -21,6 +21,12 @@ ACCOUNT_SYNC_SIGNATURE_HEADER = "x-account-sync-signature" +# Cap account model: `phone` is the user's own number (unique kickoff +# key). The Treena iMessage bot is shared sitewide (`LINQ_FROM_E164`), +# not a unique DID per user. `from_did` is the Telnyx caller ID: nullable, +# not unique. Kickoff still requires it until Cap names a shared Telnyx +# from-number. Do not store the shared bot on this column. + USERS_SCHEMA = """ CREATE TABLE IF NOT EXISTS users ( user_id TEXT PRIMARY KEY, @@ -63,8 +69,8 @@ def _reject_from_did_equal_to_phone(phone: E164, from_did: E164 | None) -> None: if from_did is not None and str(from_did) == str(phone): raise ValueError( - "from_did must be the assigned Treena number, " - "not the user's own phone" + "from_did is the Telnyx caller ID, not the user's own phone " + "and not the shared iMessage bot" ) diff --git a/call_agent/cli.py b/call_agent/cli.py index 33b61e2..a9604a1 100644 --- a/call_agent/cli.py +++ b/call_agent/cli.py @@ -479,8 +479,8 @@ def _resolve_from_did( return STUB_FROM_DID raise ValueError( f"no from-DID for user {user_id}: pass --from-did or assign one with " - "`call-agent account upsert --phone --from-did " - "`. Live calls never order a number." + "`call-agent account upsert --phone --from-did " + "`. Live calls never order a number." ) diff --git a/call_agent/config.py b/call_agent/config.py index d2790a1..030a7a3 100644 --- a/call_agent/config.py +++ b/call_agent/config.py @@ -21,6 +21,8 @@ class Settings(BaseSettings): telnyx_public_key: str | None = None telnyx_stream_codec: str = "L16" linq_api_token: str | None = None + # Shared Treena iMessage bot (Cap). One number for everyone. + # Sandbox +12138653024 until ~Sep 18. Not a per-user DID. linq_from_e164: str | None = None linq_webhook_signing_secret: str | None = None linq_webhook_url: str | None = None diff --git a/call_agent/dashboard.py b/call_agent/dashboard.py index c370bf2..a0977b7 100644 --- a/call_agent/dashboard.py +++ b/call_agent/dashboard.py @@ -34,6 +34,7 @@ def build_account_dashboard( record: UserRecord | None, artifacts_dir: Path, phone: E164, + bot_number: str | None = None, ) -> dict[str, Any]: threads, calls = scan_linq_artifacts( artifacts_dir, @@ -45,6 +46,7 @@ def build_account_dashboard( "user": user_dashboard_payload(record) if record is not None else None, "threads": threads, "calls": calls, + "bot_number": bot_number, } diff --git a/call_agent/kickoff.py b/call_agent/kickoff.py index 10551cb..426b3a4 100644 --- a/call_agent/kickoff.py +++ b/call_agent/kickoff.py @@ -155,8 +155,9 @@ class KickoffParsed: """Confirmed book/ask ready to dial. - `from_did` is the Telnyx caller ID from the users row. Not - `KickoffSession.from_did`, which is the Linq iMessage line. + `from_did` is the Telnyx caller ID from `users.from_did`. Not + `KickoffSession.from_did`, which is the shared Linq iMessage bot + (`LINQ_FROM_E164`). """ request: BookRequest | QuickAskRequest diff --git a/call_agent/linq.py b/call_agent/linq.py index 7e3b40c..fe6c851 100644 --- a/call_agent/linq.py +++ b/call_agent/linq.py @@ -328,19 +328,32 @@ def validate_linq_live_token(api_token: str | None) -> None: raise LinqConfigError("live Linq requires LINQ_API_TOKEN") -def linq_from(settings: Settings) -> E164: - raw = settings.linq_from_e164 +def shared_bot_e164(settings: Settings) -> E164 | None: + """Sitewide Treena iMessage bot. One number for everyone. + + Driven by `LINQ_FROM_E164`. Not a unique Treena DID per user and not + the Telnyx caller ID (`users.from_did`). + """ + raw = (settings.linq_from_e164 or "").strip() if not raw: - raise LinqConfigError("live Linq send requires LINQ_FROM_E164") + return None return E164(raw) +def linq_from(settings: Settings) -> E164: + bot = shared_bot_e164(settings) + if bot is None: + raise LinqConfigError("live Linq send requires LINQ_FROM_E164") + return bot + + STUB_FROM_DID = E164("+15555550100") def reply_from_did(settings: Settings) -> E164: - if settings.linq_from_e164: - return E164(settings.linq_from_e164) + bot = shared_bot_e164(settings) + if bot is not None: + return bot if settings.linq_api_token: raise LinqConfigError("live Linq send requires LINQ_FROM_E164") return STUB_FROM_DID diff --git a/call_agent/server.py b/call_agent/server.py index 6999317..89deee1 100644 --- a/call_agent/server.py +++ b/call_agent/server.py @@ -58,6 +58,7 @@ handle_inbound, messaging_port, reply_from_did, + shared_bot_e164, verify_linq_signature, write_inbound_artifact, ) @@ -134,10 +135,12 @@ async def accounts_dashboard(request: Request) -> dict[str, object]: ) phone = E164(phone_raw) record = open_accounts(settings).get_by_phone(phone) + bot = shared_bot_e164(settings) return build_account_dashboard( record=record, artifacts_dir=settings.call_agent_artifacts_dir, phone=phone, + bot_number=str(bot) if bot is not None else None, ) except AccountSyncAuthError as error: raise HTTPException(status_code=401, detail="unauthorized") from error diff --git a/call_agent/verify_accounts.py b/call_agent/verify_accounts.py index d8ec839..43f6efd 100644 --- a/call_agent/verify_accounts.py +++ b/call_agent/verify_accounts.py @@ -94,6 +94,7 @@ def _workspace_env(root: Path, secret: str) -> Iterator[None]: "CALL_AGENT_DB_PATH": str(root / "ledger.sqlite3"), "CALL_AGENT_ARTIFACTS_DIR": str(artifacts), "ACCOUNT_SYNC_SECRET": secret, + "LINQ_FROM_E164": "+12138653024", "QUICK_ASK_LIVE": "0", } previous = {key: os.environ.get(key) for key in values} @@ -241,17 +242,19 @@ def _dashboard_get() -> AccountsCheck: and name.get("first_name") == "Nathan" and name.get("full_name") == "Nathan Tester" and payload.get("threads") == [] + and payload.get("bot_number") == "+12138653024" and unknown.status_code == 200 and unknown_payload.get("user") is None and unknown_payload.get("threads") == [] and unknown_payload.get("calls") == [] + and unknown_payload.get("bot_number") == "+12138653024" ) return AccountsCheck( name="dashboard_get", passed=passed, detail=( f"unsigned={denied.status_code} signed={signed.status_code} " - f"name={name.get('first_name')}" + f"name={name.get('first_name')} bot_number={payload.get('bot_number')}" ), ) diff --git a/docs/FEATURE_MAP.md b/docs/FEATURE_MAP.md index da13cbf..14f17b7 100644 --- a/docs/FEATURE_MAP.md +++ b/docs/FEATURE_MAP.md @@ -20,7 +20,8 @@ Cap locks for surfaces this repository owns. Do not invent product. Do not `fly | L1 iMessage | shipped | Stub shipped. `--live` gated. Cap recorded `live-prove: PASS (#34)`. qa owns live-prove sign-off (TRE-19). This tree does not redial. | `docs/FEATURE_MAP.md` linq-to-call. `.cursor/skills/verify-call-agent/features/linq-to-call.md`. TRE-19 | | live voice | gated | Stub shipped. Telnyx/OpenAI live adapters refuse without keys. This repository has not exercised Telnyx live. | `call_agent/telnyx.py` `LiveTelnyxHttp`. `call_agent/gpt_live.py` `LiveGptLive`. `README.md` | | hangup | shipped | Done-state + far-party. Legal terminal `record_task_result` or `end_call` owns hangup. Informal goodbye is not a hangup trigger. | `call_agent/domain.py` `_enter_farewell`. `tests/test_runtime.py` `test_done_state_hangup_skips_end_call_path_is_tool` | -| `users` | shipped | Stub shipped. Website writer sot-ready (`docs/accounts.md`). Omit-preserve + signed `GET /accounts/dashboard` shipped in tree. Fly `POST /accounts/sync` and `GET /accounts/dashboard` are intended Fly routes, not accidental. Dashboard UI stays on Pages (web). Name is collected on website `/start` and that copy is in settings after signup. Kickoff never collects a name over iMessage. | `call_agent/accounts.py`. `call_agent/server.py` `accounts_sync` `accounts_dashboard`. `call-agent verify-accounts`. `docs/accounts.md` | +| `users` | shipped | Stub shipped. Website writer sot-ready (`docs/accounts.md`). Cap model: `users.phone` is the user's own number; one shared iMessage bot (`LINQ_FROM_E164`). Omit-preserve + signed `GET /accounts/dashboard` (includes `bot_number`) shipped in tree. Fly `POST /accounts/sync` and `GET /accounts/dashboard` are intended Fly routes, not accidental. Dashboard UI stays on Pages (web). Name is collected on website `/start` and that copy is in settings after signup. Kickoff never collects a name over iMessage. | `call_agent/accounts.py`. `call_agent/server.py` `accounts_sync` `accounts_dashboard`. `call-agent verify-accounts`. `docs/accounts.md` | +| shared Treena iMessage bot | shipped | One sitewide bot. `LINQ_FROM_E164` is the one env key. Cap/ops sandbox `+12138653024` until ~Sep 18. Signed dashboard includes `bot_number` from that key. | `call_agent/linq.py` `shared_bot_e164`. `call_agent/dashboard.py`. `.env.example`. `docs/accounts.md`. `tests/test_accounts.py` `test_accounts_dashboard_includes_shared_bot_number` | | friendly iMessage errors | shipped | Linq/iMessage never shows raw R2, env, or HTTP. | `call_agent/user_text.py`. `tests/test_user_text.py` | | landing unfreeze | pending web | TRE-22 unfreeze ordered. Mostly call-agent-web. This repo keeps the closed-alpha iMessage line and does not claim a public landing. | TRE-22. `docs/linq.md` | @@ -37,18 +38,19 @@ The ledger is SQLite unless `NEON_DATABASE_URL` is set. Then it is Neon. ## accounts -The `users` table is the runtime account record Fly reads. Same ledger DB as `dids` (SQLite unless `NEON_DATABASE_URL` is set). Stripe is billing only. The website POSTs signed checkout/settings payloads to Fly; Fly is the sole writer. Kickoff looks up by inbound phone. The outbound Telnyx caller ID is `users.from_did`. `BookRequest.user_id` is `users.user_id`. CLI `account upsert --from-did` seeds the assigned Treena number. Confirm/dial never calls `ledger.provision`. Name is collected on website `/start` and that copy is in settings after signup. Kickoff reads `users.first_name`. Never collected over iMessage. Display name is `first_name` plus `prefs.full_name`. There is no `users.name` column. Fly `POST /accounts/sync` and `GET /accounts/dashboard` are intended routes on `hidden-beacon-080`, not accidental. Dashboard UI stays on Pages (web). +The `users` table is the runtime account record Fly reads. Same ledger DB as `dids` (SQLite unless `NEON_DATABASE_URL` is set). Cap model: one shared Treena iMessage bot (`LINQ_FROM_E164`); each account stores the user's own phone. Stripe is billing only. The website POSTs signed checkout/settings payloads to Fly; Fly is the sole writer. Kickoff looks up by inbound phone. The outbound Telnyx caller ID is `users.from_did` (not unique; not the iMessage bot). `BookRequest.user_id` is `users.user_id`. CLI `account upsert --from-did` seeds the Telnyx from-number. Confirm/dial never calls `ledger.provision`. Name is collected on website `/start` and that copy is in settings after signup. Kickoff reads `users.first_name`. Never collected over iMessage. Display name is `first_name` plus `prefs.full_name`. There is no `users.name` column. Fly `POST /accounts/sync` and `GET /accounts/dashboard` are intended routes on `hidden-beacon-080`, not accidental. Dashboard UI stays on Pages (web). | name | status | does | proof | | --- | --- | --- | --- | -| `users` | shipped | Stores `first_name`, linked phone, assigned Treena `from_did`, minutes, retain/`prefs`, and Stripe ids on table `users`. Display name is `first_name` + `prefs.full_name`. No `users.name` column. Phone is unique. Kickoff reads this row. Name originates on website `/start` and is in settings after signup. | `call_agent/accounts.py` `USERS_SCHEMA` `UserRecord`. `tests/test_accounts.py` `test_upsert_get_by_phone_and_list` `test_users_schema_has_no_name_column`. `docs/accounts.md`. `call-agent verify-accounts` | -| `users.from_did` | shipped | Nullable E.164 assigned Treena number. Not unique. Rejected when equal to `phone`. Additive `ALTER TABLE` on existing SQLite and Neon `users` tables. | `call_agent/accounts.py` `USERS_FROM_DID_MIGRATION` `_reject_from_did_equal_to_phone`. `tests/test_accounts.py` `test_existing_users_table_gains_from_did_column` `test_from_did_equal_to_phone_fails_loud` | +| `users` | shipped | Stores `first_name`, the user's own `phone`, Telnyx `from_did` (not unique), minutes, retain/`prefs`, and Stripe ids on table `users`. Display name is `first_name` + `prefs.full_name`. No `users.name` column. Phone is unique. Kickoff reads this row. Name originates on website `/start` and is in settings after signup. | `call_agent/accounts.py` `USERS_SCHEMA` `UserRecord`. `tests/test_accounts.py` `test_upsert_get_by_phone_and_list` `test_users_schema_has_no_name_column`. `docs/accounts.md`. `call-agent verify-accounts` | +| `users.from_did` | shipped | Nullable E.164 Telnyx caller ID. Not unique. Rejected when equal to `phone`. Additive `ALTER TABLE` on existing SQLite and Neon `users` tables. Kickoff still requires it before dial. Cap decision: not a unique Treena iMessage DID; if Telnyx should also be one shared number, Cap/Nathan name the E.164. | `call_agent/accounts.py` `USERS_FROM_DID_MIGRATION` `_reject_from_did_equal_to_phone`. `tests/test_accounts.py` `test_existing_users_table_gains_from_did_column` `test_from_did_equal_to_phone_fails_loud` `test_two_accounts_can_share_the_same_from_did` | +| `shared Treena iMessage bot` | shipped | One sitewide bot. `LINQ_FROM_E164` is the one env/config key (sandbox `+12138653024` until ~Sep 18). Signed `GET /accounts/dashboard` includes `bot_number` from that key (`null` when unset). Not stored per user. | `call_agent/linq.py` `shared_bot_e164`. `call_agent/dashboard.py` `build_account_dashboard`. `.env.example`. `tests/test_accounts.py` `test_accounts_dashboard_includes_shared_bot_number`. `tests/test_messaging.py` `test_shared_bot_e164_is_linq_from_and_not_per_user` | | `open_accounts` | shipped | SQLite `Accounts` when `NEON_DATABASE_URL` is unset, `NeonAccounts` when set. Same ledger config as `open_ledger`. | `call_agent/accounts.py` `open_accounts`. `tests/test_accounts.py` `test_open_accounts_unset_returns_sqlite` `test_neon_accounts_roundtrip_through_sqlite_bridge` | -| `account upsert` | shipped | Seeds or updates a `users` row (`--phone`, optional `--name`, `--from-did`, `--minutes`, `--retain`, `--user`, `--plan`). Prints the JSON record. No HMAC. Omitted `--from-did`, `--name`, prefs, and `minutes_used` keep existing values. | `call_agent/cli.py` `account_upsert`. `tests/test_accounts.py` `test_cli_account_upsert_get_list` `test_upsert_merges_from_did_when_omitted` `test_cli_account_upsert_omitted_name_preserves`. `.cursor/skills/verify-call-agent/features/accounts.md` | +| `account upsert` | shipped | Seeds or updates a `users` row (`--phone` = user's own number, optional `--name`, `--from-did` Telnyx caller ID, `--minutes`, `--retain`, `--user`, `--plan`). Prints the JSON record. No HMAC. Omitted `--from-did`, `--name`, prefs, and `minutes_used` keep existing values. | `call_agent/cli.py` `account_upsert`. `tests/test_accounts.py` `test_cli_account_upsert_get_list` `test_upsert_merges_from_did_when_omitted` `test_cli_account_upsert_omitted_name_preserves`. `.cursor/skills/verify-call-agent/features/accounts.md` | | `account get` | shipped | Prints the JSON `users` row for `--phone`, including `from_did`. Exit 1 when missing. | `call_agent/cli.py` `account_get`. `tests/test_accounts.py` `test_cli_account_get_missing_exits_one` | | `account list` | shipped | Prints every `users` row as USER, PHONE, NAME, FROM_DID, MINUTES, RETAIN. | `call_agent/cli.py` `account_list`. `tests/test_accounts.py` `test_cli_account_upsert_get_list` | | `POST /accounts/sync` | shipped | Intended Fly route on `hidden-beacon-080`, not accidental. HMAC-SHA256 of the raw body with `ACCOUNT_SYNC_SECRET` (`X-Account-Sync-Signature: sha256=`). Fly upserts `users`. Missing secret or bad signature is 401. Bad JSON is 400. Phone collision across Stripe customers is 409. Older `stripe_event_created` is skipped. Omitted `from_did`, `first_name`, `prefs`, and `minutes_used` keep the existing values. Explicit `minutes_used` `0` writes 0. Stripe webhooks without a name do not wipe `first_name`. This tree does not `fly deploy`. | `call_agent/server.py` `accounts_sync`. `call_agent/accounts.py` `verify_account_sync_signature` `upsert_account`. `tests/test_accounts.py` `test_accounts_sync_upserts_with_hmac` `test_accounts_sync_without_from_did_preserves_seeded_did` `test_accounts_sync_without_name_prefs_or_minutes_used_preserves`. `call-agent verify-accounts` `sync_hmac`. `docs/accounts.md` | -| `GET /accounts/dashboard` | shipped | Intended Fly route on `hidden-beacon-080`, not accidental. HMAC-SHA256 of the `phone` query value with `ACCOUNT_SYNC_SECRET` (same `X-Account-Sync-Signature` family as POST). Packages the `users` row (name = `first_name` + `prefs.full_name`), a linq thread index under `CALL_AGENT_ARTIFACTS_DIR/linq/*`, and `call_report` / transcript when those files exist. Empty lists when unknown or wiped. Dashboard UI stays on Pages (web). Web must not open Neon/R2. | `call_agent/server.py` `accounts_dashboard`. `call_agent/dashboard.py` `build_account_dashboard`. `tests/test_accounts.py` `test_accounts_dashboard_empty_for_unknown` `test_accounts_dashboard_includes_retained_report`. `call-agent verify-accounts` `dashboard_get`. `docs/accounts.md` | +| `GET /accounts/dashboard` | shipped | Intended Fly route on `hidden-beacon-080`, not accidental. HMAC-SHA256 of the `phone` query value with `ACCOUNT_SYNC_SECRET` (same `X-Account-Sync-Signature` family as POST). Packages the `users` row (name = `first_name` + `prefs.full_name`), sitewide `bot_number` from `LINQ_FROM_E164`, a linq thread index under `CALL_AGENT_ARTIFACTS_DIR/linq/*`, and `call_report` / transcript when those files exist. Empty lists when unknown or wiped. Dashboard UI stays on Pages (web). Web must not open Neon/R2. | `call_agent/server.py` `accounts_dashboard`. `call_agent/dashboard.py` `build_account_dashboard`. `tests/test_accounts.py` `test_accounts_dashboard_empty_for_unknown` `test_accounts_dashboard_includes_retained_report` `test_accounts_dashboard_includes_shared_bot_number`. `call-agent verify-accounts` `dashboard_get`. `docs/accounts.md` | | `Stripe → users sync` | gated | call-agent-web POSTs this signed body on checkout, `/start` name, customer update, and account-settings save. Website writer is sot-ready (`docs/accounts.md`). This tree does not claim that writer live. Dashboard UI stays on Pages. Website is not expected to send `from_did` this climb. Later Stripe events without a name must omit `first_name`. | `docs/accounts.md`. TRE-20 follow-up in call-agent-web | | `verify-accounts` | shipped | Five stub checks: `cli_roundtrip`, `omit_preserve`, `sync_hmac`, `dashboard_get`, `no_name_column`. In-process FastAPI, throwaway SQLite. Never POSTs Fly. CI runs the CLI verb. | `call_agent/cli.py` `verify_accounts_command`. `call_agent/verify_accounts.py` `verify_accounts`. `.github/workflows/ci.yml`. `tests/test_verify_accounts.py`. `.cursor/skills/verify-call-agent/features/verify-accounts.md` | | `minutes ledger` | not shipped | Kickoff records `minutes_included` / `minutes_used` on `kickoff.json`. It does not refuse at zero and does not decrement at hangup. | `docs/FEATURE_MAP.md` this row. `call_agent/kickoff.py` `claim_kickoff` | @@ -123,10 +125,10 @@ Closed-alpha testers text Treena on iMessage. FastAPI on Fly owns BOOK and media | name | status | does | proof | | --- | --- | --- | --- | | `linq-to-call` | shipped | Stub-proven iMessage book: natural first text with party + time → Treena asks only for the restaurant phone number. If time is missing, Treena asks for time (or party+time) before the phone ask; `at ` is not a time; "2 people" alone does not unlock the phone ask. Then the number → Treena asks `Okay, I'm going to call this number. Do you confirm? Reply "approve"` → user says confirm / approve / yes → Treena texts `Okay, I'm going to call them now.` → 202 `{ok, kicked_off, event_id}` and `BackgroundTasks` `run_kickoff`. Collect and confirm reply 200. The first message never dials, even when it already includes +E164 plus party/time. Non-kickoff chat still echoes `Received: {text}`. Cap recorded `live-prove: PASS (#34)`. qa owns live-prove sign-off (TRE-19). This tree does not redial. | `call_agent/server.py` `process_linq_webhook`. `call_agent/kickoff.py` `intake_kickoff` `run_kickoff` `KickoffSession`. `tests/test_messaging.py` `test_linq_webhook_two_step_collects_then_dials` `test_linq_webhook_first_message_never_dials`. `tests/test_kickoff.py` `test_at_restaurant_does_not_invent_time` `test_party_only_followup_asks_for_time_not_phone`. `.cursor/skills/verify-call-agent/features/linq-to-call.md` | -| `linq-to-call --live` | gated | Fly FastAPI live needs `QUICK_ASK_LIVE=1`, Telnyx and OpenAI keys, `PUBLIC_BASE_URL` = `PUBLIC_WEBHOOK_URL` = `https://hidden-beacon-080.fly.dev`, `LINQ_API_TOKEN`, `LINQ_FROM_E164`, `LINQ_WEBHOOK_SIGNING_SECRET`, `NEON_DATABASE_URL` (durable `users`). Host header must match `PUBLIC_BASE_URL`. A tester needs a `users` row with `first_name` and `from_did` (CLI `account upsert --from-did` or `POST /accounts/sync`). Cap recorded `live-prove: PASS (#34)`. qa owns live-prove sign-off (TRE-19). This tree does not redial. | `call_agent/kickoff.py` `kickoff_host_mismatch`. `call_agent/runtime.py` `assert_live_dial_origins`. `tests/test_messaging.py` `test_linq_webhook_live_host_guard_does_not_dial`. `tests/test_kickoff.py` `test_run_kickoff_live_preflight_fails_loud_without_sockets` | +| `linq-to-call --live` | gated | Fly FastAPI live needs `QUICK_ASK_LIVE=1`, Telnyx and OpenAI keys, `PUBLIC_BASE_URL` = `PUBLIC_WEBHOOK_URL` = `https://hidden-beacon-080.fly.dev`, `LINQ_API_TOKEN`, `LINQ_FROM_E164` (shared bot `+12138653024` until ~Sep 18), `LINQ_WEBHOOK_SIGNING_SECRET`, `NEON_DATABASE_URL` (durable `users`). Host header must match `PUBLIC_BASE_URL`. A tester needs a `users` row with `first_name` and Telnyx `from_did` (CLI `account upsert --from-did` or `POST /accounts/sync`). Cap recorded `live-prove: PASS (#34)`. qa owns live-prove sign-off (TRE-19). This tree does not redial. | `call_agent/kickoff.py` `kickoff_host_mismatch`. `call_agent/runtime.py` `assert_live_dial_origins`. `tests/test_messaging.py` `test_linq_webhook_live_host_guard_does_not_dial`. `tests/test_kickoff.py` `test_run_kickoff_live_preflight_fails_loud_without_sockets` | | `parse_kickoff` | shipped | Thin natural intake. Book intent collects party and time from the first text when they are present, then asks only for the restaurant phone number. Missing `time_text` asks for time (or party+time together) and never advances to phone collect or confirm. `_extract_time` keeps a clock / am/pm / noon span, not `at `. After the number, intake waits for confirm/approve/yes. Ask intent still maps `ask <+E164> hours\|stock \|aisle ` and still waits for confirm before `QuickAskRequest`. Caller name comes from the `users` row (website `/start` → settings after signup), never from iMessage `name` and never from `LINQ_KICKOFF_CALLER`. A complete first message that includes +E164 never becomes `KickoffParsed` and never dials. Other text stays on the echo path. | `call_agent/kickoff.py` `intake_kickoff` `parse_kickoff` `_decide_book` `_extract_time`. `tests/test_kickoff.py` `test_parse_natural_book_asks_only_for_phone` `test_at_restaurant_does_not_invent_time` `test_name_token_in_text_is_ignored` | | `kickoff account lookup` | shipped | `process_linq_webhook` looks up `users` by inbound phone (`InboundMessage.sender`). `BookRequest.caller` is that row's first name. `BookRequest.user_id` is that row's `user_id`. `kickoff.json` records `account.user_id`, phone, `from_did`, and minutes, not the name. | `call_agent/server.py` `process_linq_webhook`. `call_agent/kickoff.py` `claim_kickoff`. `call_agent/verify_linq_call.py` `account_lookup`. `tests/test_kickoff.py` `test_parse_uses_account_name_when_text_omits_name` | -| `kickoff from-DID` | shipped | Confirm/dial uses `users.from_did` as `DialSpec.from_did`. `run_call` takes a required `from_did` kwarg. Never `ledger.provision` / `search_and_order_number` / `POST /v2/number_orders`. | `call_agent/runtime.py` `run_call`. `call_agent/kickoff.py` `KickoffParsed.from_did`. `tests/test_kickoff.py` `test_run_kickoff_dials_from_users_row_not_linq_line`. `tests/test_runtime.py` `test_run_call_never_orders_a_number` | +| `kickoff from-DID` | shipped | Confirm/dial uses `users.from_did` as `DialSpec.from_did`. `run_call` takes a required `from_did` kwarg. Never `ledger.provision` / `search_and_order_number` / `POST /v2/number_orders`. Linq replies use the shared bot (`LINQ_FROM_E164`), not this field. | `call_agent/runtime.py` `run_call`. `call_agent/kickoff.py` `KickoffParsed.from_did`. `tests/test_kickoff.py` `test_run_kickoff_dials_from_users_row_not_linq_line`. `tests/test_runtime.py` `test_run_call_never_orders_a_number` | | `no-account reply` | shipped | Unknown inbound phone texts a plain account-settings line, not `Call failed:`. No `kickoff.json`, no dial. | `call_agent/kickoff.py` `NO_ACCOUNT_TEXT` `KickoffBlocked`. `tests/test_kickoff.py` `test_no_account_replies_plain_and_does_not_dial`. `tests/test_messaging.py` `test_linq_webhook_no_account_replies_plain_and_does_not_dial` | | `no-name reply` | shipped | A `users` row with no first name texts a plain account-settings line, not `Call failed: missing caller name`. No dial. | `call_agent/kickoff.py` `NO_NAME_TEXT`. `tests/test_kickoff.py` `test_no_name_replies_plain_and_does_not_dial` `test_no_missing_caller_name_path` | | `no-from-did reply` | shipped | A `users` row with no `from_did` texts a plain support line. Never `Call failed:`. Does not buy a number. | `call_agent/kickoff.py` `NO_FROM_DID_TEXT`. `tests/test_kickoff.py` `test_no_from_did_replies_plain_and_does_not_dial`. `tests/test_messaging.py` `test_linq_webhook_no_from_did_replies_plain_and_does_not_dial` | @@ -151,8 +153,8 @@ The Cursor skill drives the CLI verbs in stub mode and never sets live keys or N | `.cursor/skills/verify-call-agent/features/linq-inbound.md` | shipped | Maps closed-alpha iMessage Worker forward at `https://call-agent-webhooks.usetreena.workers.dev/linq/webhook`, MessagingPort ack on `message.received`, and inbound artifacts. SMS stays off. | `.cursor/skills/verify-call-agent/features/linq-inbound.md` | | `.cursor/skills/verify-call-agent/features/verify-webhooks.md` | shipped | Covers the Worker inbox stub checks, `--url` wiring, and exit codes. | `.cursor/skills/verify-call-agent/features/verify-webhooks.md` | | `.cursor/skills/verify-call-agent/features/did-provision.md` | shipped | Covers first provision, repeat provision, a second user, and `did list`. Ops verb only; confirm/dial does not provision. | `.cursor/skills/verify-call-agent/features/did-provision.md` | -| `.cursor/skills/verify-call-agent/features/accounts.md` | shipped | Covers `account upsert --from-did` / `get` / `list` seed, omit-preserve on sync (`first_name`, `prefs`, `minutes_used`, `from_did`), signed `GET /accounts/dashboard`, `/start` name in settings, and that kickoff reads `users.from_did`. | `.cursor/skills/verify-call-agent/features/accounts.md` | -| `.cursor/skills/verify-call-agent/features/verify-accounts.md` | shipped | Covers the five-check lever (`cli_roundtrip`, `omit_preserve`, `sync_hmac`, `dashboard_get`, `no_name_column`), in-process HMAC, and that the lever never POSTs Fly. | `.cursor/skills/verify-call-agent/features/verify-accounts.md` | +| `.cursor/skills/verify-call-agent/features/accounts.md` | shipped | Covers `account upsert --from-did` / `get` / `list` seed, omit-preserve on sync (`first_name`, `prefs`, `minutes_used`, `from_did`), signed `GET /accounts/dashboard` (`bot_number` from `LINQ_FROM_E164`), `/start` name in settings, Cap phone = user's number, and that kickoff reads `users.from_did` for Telnyx. | `.cursor/skills/verify-call-agent/features/accounts.md` | +| `.cursor/skills/verify-call-agent/features/verify-accounts.md` | shipped | Covers the five-check lever (`cli_roundtrip`, `omit_preserve`, `sync_hmac`, `dashboard_get` including `bot_number`, `no_name_column`), in-process HMAC, and that the lever never POSTs Fly. | `.cursor/skills/verify-call-agent/features/verify-accounts.md` | | `.cursor/skills/verify-call-agent/features/quick-ask.md` | shipped | Covers hours, stock, aisle, missing `--item`, artifacts, and the live gate. | `.cursor/skills/verify-call-agent/features/quick-ask.md` | | `.cursor/skills/verify-call-agent/features/gpt-live-prompt.md` | shipped | Covers the live prompt lines in `session_start.json`, Responses delegation, `speak_first` append frames, and Realtime reject. | `.cursor/skills/verify-call-agent/features/gpt-live-prompt.md` | | `.cursor/skills/verify-call-agent/features/verify-quick-ask.md` | shipped | Covers the eight stub checks, defaults, the live gate, and exit codes. | `.cursor/skills/verify-call-agent/features/verify-quick-ask.md` | @@ -213,13 +215,13 @@ This section is the inventory for the current MessagingPort implementation. Upda Telnyx owns PSTN calls. Linq Partner API V3 owns the closed-alpha iMessage kickoff thread. SMS stays off. -Closed-alpha testers text Treena on this Linq path. The sending line is the sandbox DID (`LINQ_FROM_E164`; sandbox has used `+12138653024`). That is not a public landing claim and not a blue-bubble marketing claim. Outbound `message.parts` text from `+12138653024` to `+17272701288` landed on Builder's box; that prove is outside this repository. The Workers inbound URL is live and the Linq subscription is registered. The Worker forwards Linq POST to `APP_BASE_URL`. FastAPI in this process owns HMAC verify, `message.received` mapping, and the MessagingPort reply. +Closed-alpha testers text Treena on this Linq path. The sending line is the one shared Treena iMessage bot (`LINQ_FROM_E164`; Cap/ops sandbox `+12138653024` until ~Sep 18). That is not a unique DID per user, not a public landing claim, and not a blue-bubble marketing claim. Outbound `message.parts` text from `+12138653024` to `+17272701288` landed on Builder's box; that prove is outside this repository. The Workers inbound URL is live and the Linq subscription is registered. The Worker forwards Linq POST to `APP_BASE_URL`. FastAPI in this process owns HMAC verify, `message.received` mapping, and the MessagingPort reply. | name | status | does | proof | | --- | --- | --- | --- | | `MessagingPort` | shipped | Protocol `send` / `receive` over chat `parts`, not an SMS body. | `call_agent/messaging.py` `MessagingPort` `OutboundChat` `InboundMessage`. `tests/test_messaging.py` `test_stub_send_and_receive_round_trip` | | Linq V3 outbound stub | shipped | `StubLinq.send` records `POST /chats` with `preferred_service: iMessage` and `message.parts` text. | `call_agent/linq.py` `serialize_outbound` `StubLinq`. `tests/test_messaging.py` `test_text_chat_is_parts_not_sms_body`. `call_agent/verify_message.py` `stub_send` | -| Linq V3 live send | gated | Closed-alpha outbound on the sandbox line. `LiveLinqHttp` posts the same JSON and fails loud without `LINQ_API_TOKEN`. `kickoff_from_settings` fails loud without `LINQ_FROM_E164`. This repository has not live-sent. Builder-box prove used `+12138653024` → `+17272701288`. | `call_agent/linq.py` `LiveLinqHttp` `linq_from` `kickoff_from_settings`. `tests/test_messaging.py` `test_live_constructor_fails_without_token` `test_kickoff_from_settings_requires_from` `test_live_send_posts_chats_parts_shape`. `docs/linq.md` | +| Linq V3 live send | gated | Closed-alpha outbound on the shared bot. `LiveLinqHttp` posts the same JSON and fails loud without `LINQ_API_TOKEN`. `kickoff_from_settings` fails loud without `LINQ_FROM_E164`. This repository has not live-sent. Builder-box prove used `+12138653024` → `+17272701288`. | `call_agent/linq.py` `LiveLinqHttp` `linq_from` `shared_bot_e164` `kickoff_from_settings`. `tests/test_messaging.py` `test_live_constructor_fails_without_token` `test_kickoff_from_settings_requires_from` `test_live_send_posts_chats_parts_shape`. `docs/linq.md` | | Linq V3 inbound | shipped | L1 path: Worker `POST /linq/webhook` forwards to `APP_BASE_URL/linq/webhook`. FastAPI `POST /webhooks/linq` and `POST /linq/webhook` verify Standard Webhooks on raw bytes using `LINQ_WEBHOOK_SIGNING_SECRET`, map iMessage `message.received` into `InboundMessage`. Non-kickoff chat sends a `Received: {text}` ack and writes `inbound.json` / `reply.json`. A natural book/ask without the restaurant number replies on the same chat and writes `linq/pending/.json`. The number follow-up asks to confirm on that pending file. Confirm / approve / yes returns 202 and `run_kickoff` texts `Okay, I'm going to call them now.` then the call result (`replies.jsonl`). The first message never dials. Missing secret or bad signature is 401. Missing artifact dir is 500. `message.sent` / `delivered` / `read` / `failed` and SMS are ignored. Unset Worker `APP_BASE_URL` is 501. Live reply needs `LINQ_API_TOKEN` and `LINQ_FROM_E164`. | `workers/webhooks/src/linq.ts` `parseAppBaseUrl`. `call_agent/linq.py` `parse_inbound` `verify_linq_signature` `handle_inbound`. `call_agent/kickoff.py` `intake_kickoff` `run_kickoff`. `call_agent/server.py` `process_linq_webhook`. `tests/test_messaging.py` `test_linq_webhook_replies_and_writes_inbound_text` `test_linq_webhook_two_step_collects_then_dials` `test_linq_webhook_fails_when_artifacts_dir_missing` `test_receive_ignores_sms_and_non_message_events` | | `verify-message` | shipped | Six stub checks: send shape, inbound map, inbound reply + artifact text, signature, live token gate, iMessage-only. CI runs the CLI verb. | `call_agent/verify_message.py` `verify_message`. `call_agent/cli.py` `verify_message_command`. `.github/workflows/ci.yml`. `tests/test_cli.py` `test_verify_message_stub_exits_zero`. `.cursor/skills/verify-call-agent/features/verify-message.md` | | `linq register-webhook` | gated | Closed-alpha subscription `4c1ad6c6-d674-4fa2-b63a-ca947f2d9b7c` is already registered. The verb still `POST /webhook-subscriptions` when `LINQ_API_TOKEN` and `LINQ_WEBHOOK_URL` or `PUBLIC_WEBHOOK_URL` are set, and prints `signing_secret` to store as `LINQ_WEBHOOK_SIGNING_SECRET`. Fails loud if token or URL is missing. | `call_agent/cli.py` `linq_register_webhook`. `tests/test_cli.py` `test_linq_register_webhook_fails_without_token` `test_linq_register_webhook_fails_without_url` | diff --git a/docs/WAVE1_HOLD_FEATURE_MAP_PLAN.md b/docs/WAVE1_HOLD_FEATURE_MAP_PLAN.md index 828c5c9..0957830 100644 --- a/docs/WAVE1_HOLD_FEATURE_MAP_PLAN.md +++ b/docs/WAVE1_HOLD_FEATURE_MAP_PLAN.md @@ -26,6 +26,8 @@ This wave is docs + maps + verifiers. No Fly deploy. No L1 redial. No wave 2. No - Website writer live (TRE-20) and dashboard GO on Pages. - Live book redial (HOLD REDIAL). - Minutes ledger (not shipped). +- Telnyx caller ID: kickoff still requires per-account `users.from_did` (not unique). Cap said no per-user Treena DID. Do not invent a shared Telnyx from-number. The Linq sandbox bot `+12138653024` is the iMessage bot, not Telnyx `from`. +- Stripe live flip (Cap/ops blocked on Nathan live keys). Not this climb. ## Out of this wave diff --git a/docs/accounts.md b/docs/accounts.md index 76fe66c..80fd9a4 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -2,6 +2,15 @@ This is the contract for the follow-up **call-agent-web** PR. This repository (`call-agent`) owns the `users` table and is the sole writer. Stripe stays checkout and billing. Do not treat a Stripe Customer as the runtime account Fly reads. Neon is out of scope on the website today (TRE-20); the website must not open `NEON_DATABASE_URL`. +## Cap account model + +1. One shared Treena iMessage bot for everyone. People text `LINQ_FROM_E164` (Linq sandbox `+12138653024` until ~Sep 18). Not a unique Treena DID per user. +2. Each account stores the user's own phone on `users.phone` (the inbound iMessage sender). That is the kickoff key. + +The website "Text Treena" number is that one env key. Signed `GET /accounts/dashboard` includes `bot_number` from it (`null` when unset). Do not invent a second bot env var. + +Telnyx outbound caller ID is a separate field: `users.from_did` (nullable, **not unique**). Kickoff still requires it before dial. Cap has not named a shared Telnyx from-number. Do not use the Linq bot as Telnyx `from`, and do not use the user's phone. See **Cap decision** below. + Name is collected on website `/start`. After signup that copy is in account settings. Fly `users.first_name` is that name (website POSTs `/accounts/sync`). Kickoff never collects a name over iMessage. `POST /accounts/sync` and `GET /accounts/dashboard` are **intended** Fly routes on `https://hidden-beacon-080.fly.dev`, not accidental. Dashboard UI stays on Pages (web). This tree does not `fly deploy`. @@ -10,16 +19,16 @@ This tree does not contain `call-agent-web`. Builder’s second climb implements ## Runtime account -Fly reads `users` (same DB as `dids`: SQLite on a laptop, Neon when `NEON_DATABASE_URL` is set). Kickoff looks up `phone = inbound iMessage sender`. `BookRequest.caller` is `users.first_name`. `BookRequest.user_id` is `users.user_id`. The outbound Telnyx caller ID is `users.from_did` (the assigned Treena number). Confirm/dial never calls `ledger.provision` and never `POST /v2/number_orders`. `LINQ_KICKOFF_USER_ID` is gone. `kickoff.json` records `account.user_id`, phone, `from_did`, and minutes. +Fly reads `users` (same DB as `dids`: SQLite on a laptop, Neon when `NEON_DATABASE_URL` is set). Kickoff looks up `phone = inbound iMessage sender`. `BookRequest.caller` is `users.first_name`. `BookRequest.user_id` is `users.user_id`. The outbound Telnyx caller ID is `users.from_did` (not unique; not the shared iMessage bot). Confirm/dial never calls `ledger.provision` and never `POST /v2/number_orders`. `LINQ_KICKOFF_USER_ID` is gone. `kickoff.json` records `account.user_id`, phone, `from_did`, and minutes. Columns (see `USERS_SCHEMA` in `call_agent/accounts.py`): | column | role | | --- | --- | | `user_id` | lowercase `UserId` (uuid4 hex on insert if omitted). Kickoff `BookRequest.user_id`. | -| `phone` | E.164, unique, kickoff key (linked iMessage number). Not the Telnyx from-number. | +| `phone` | E.164, unique, kickoff key. The user's own number on the account. Not the shared Treena iMessage bot and not the Telnyx from-number. | | `first_name` | nullable. Missing → Treena texts a plain account-settings line. Never collected in iMessage. Omitted on upsert keeps the existing value so Stripe webhooks without a name cannot wipe it. | -| `from_did` | nullable E.164 assigned Treena number. Telnyx `DialSpec.from_did`. Rejected when equal to `phone`. Not unique. Additive `ALTER TABLE` on existing SQLite and Neon `users` tables. Omitted on upsert keeps the existing value. | +| `from_did` | nullable E.164 Telnyx caller ID. **Not unique.** Rejected when equal to `phone`. Additive `ALTER TABLE` on existing SQLite and Neon `users` tables. Omitted on upsert keeps the existing value. Not the iMessage bot (`LINQ_FROM_E164`). | | `stripe_customer_id` | unique billing link | | `plan` | Stripe/TRE-20 plan metadata | | `minutes_included` / `minutes_used` | recorded; kickoff does not refuse at zero or decrement. Omitted `minutes_used` keeps the existing value. Explicit `0` writes 0. | @@ -71,9 +80,9 @@ Same HMAC family as POST `/accounts/sync`: - `X-Account-Sync-Signature: sha256=` where the hex is HMAC-SHA256 of the **phone query value** (E.164, not URL-encoded) with `ACCOUNT_SYNC_SECRET`. -The website must not open `NEON_DATABASE_URL` or `R2_*`. This GET is the packaged read: `users` row plus linq artifacts on Fly. +The website must not open `NEON_DATABASE_URL` or `R2_*`. This GET is the packaged read: `users` row plus linq artifacts on Fly, plus the sitewide `bot_number` from `LINQ_FROM_E164`. -`phone` is required. Missing secret, missing header, or bad HMAC is `401`. Missing or invalid phone is `400`. Unknown phone is `200` with `"user": null` and empty `threads` / `calls`. +`phone` is required. Missing secret, missing header, or bad HMAC is `401`. Missing or invalid phone is `400`. Unknown phone is `200` with `"user": null` and empty `threads` / `calls`. `bot_number` is still the shared bot (or `null` if the env key is unset). ```json { @@ -86,7 +95,8 @@ The website must not open `NEON_DATABASE_URL` or `R2_*`. This GET is the package "name": {"first_name": "Nathan", "full_name": "Nathan Tester"} }, "threads": [], - "calls": [] + "calls": [], + "bot_number": "+12138653024" } ``` @@ -120,7 +130,7 @@ Retry on 5xx (Fly down). Do not fall back to writing Neon from the website. call-agent account upsert --phone +17272701288 --name Nathan --from-did +12135690899 ``` -That writes the same table, including the assigned Treena `from_did`. Personal phone stays the linked iMessage number, not the from-number. Do not pass the same E.164 as `--phone` and `--from-did`. Set Fly `ACCOUNT_SYNC_SECRET` when the website PR is ready. This climb does not `fly deploy`. +That writes the same table. `--phone` is the user's own number (linked iMessage sender). `--from-did` is the Telnyx caller ID, not the shared bot people text. The bot is `LINQ_FROM_E164=+12138653024`. Do not pass the same E.164 as `--phone` and `--from-did`. Set Fly `ACCOUNT_SYNC_SECRET` when the website PR is ready. This climb does not `fly deploy`. ## Kickoff copy @@ -137,3 +147,9 @@ Row exists but `from_did` is empty: `This account doesn't have a Treena calling number assigned yet. Ask support to assign one, then text me again.` None of these is prefixed `Call failed:`. There is no `Call failed: missing caller name`. `LINQ_KICKOFF_CALLER` and `LINQ_KICKOFF_USER_ID` are gone. + +## Cap decision (Telnyx from-DID) + +Schema does **not** unique-constrain `users.from_did`. Two accounts may share one Telnyx from-number. Kickoff still **requires** a non-null `users.from_did` before dial, and `run_call` still takes a required `from_did` kwarg. `did provision` still assigns unique rows in the separate `dids` table (ops verb; confirm/dial never uses it). + +Cap said no per-user Treena DID. If Telnyx should also use one shared number, Cap/Nathan must name that E.164. Do not invent it. Do not reuse the Linq sandbox bot as Telnyx `from` (Linq line and Telnyx from stay distinct). Stripe live flip is not this climb. diff --git a/docs/linq.md b/docs/linq.md index f5dbea5..296d1e5 100644 --- a/docs/linq.md +++ b/docs/linq.md @@ -2,14 +2,14 @@ Telnyx places the PSTN call. Linq Partner API V3 owns the peer iMessage kickoff thread (Instinct/Orchid class). That is not Apple Messages for Business. SMS stays off. -Closed-alpha testers text Treena on this Linq path. The sending line is the sandbox DID (`LINQ_FROM_E164`; sandbox has used `+12138653024`). That is not a public landing claim and not a blue-bubble marketing claim. Builder-box outbound prove (not this VM): `message.parts` text from `+12138653024` landed on `+17272701288`. +Closed-alpha testers text Treena on this Linq path. The sending line is the one shared Treena iMessage bot (`LINQ_FROM_E164`; Cap/ops sandbox `+12138653024` until ~Sep 18). That is not a unique DID per user, not a public landing claim, and not a blue-bubble marketing claim. Builder-box outbound prove (not this VM): `message.parts` text from `+12138653024` landed on `+17272701288`. ## Settings -Read these from env. Do not hardcode the sandbox line as the only source. +Read these from env. Do not hardcode the sandbox line as the only source. `.env.example` documents the Cap-confirmed sandbox value. - `LINQ_API_TOKEN`. Bearer token. Required for live send and `linq register-webhook`. -- `LINQ_FROM_E164`. Sending line. +- `LINQ_FROM_E164`. The one shared Treena iMessage bot (Text Treena). Sitewide. Not a per-user DID and not Telnyx `users.from_did`. - `LINQ_WEBHOOK_SIGNING_SECRET`. Standard Webhooks secret (`whsec_...`) from the create-subscription response. Lives on Builder's box. Not in this VM. - `LINQ_WEBHOOK_URL` or `PUBLIC_WEBHOOK_URL`. Always-on HTTPS URL for inbound. This is the Cloudflare Workers target, not `PUBLIC_BASE_URL`. @@ -36,7 +36,7 @@ User: approve Treena: Okay, I'm going to call them now. ``` -Party and time come from the first text when present. If time is missing, Treena asks for time (or party and time) before the restaurant phone number. `at ` is not a time. "2 people" alone does not unlock the phone ask. After the number she always asks to confirm before dial. Confirm / approve / yes are accepted. The first message never dials, even when it already includes `+E164`. Caller name comes from the `users` row for the inbound phone, never from iMessage and never from `LINQ_KICKOFF_CALLER`. Website `/start` collects that name; after signup the copy is in settings. `BookRequest.user_id` is that row's `user_id`. The outbound Telnyx caller ID is `users.from_did`, not the Linq sending line and not `ledger.provision`. No row, a row with no first name, or a row with no `from_did` texts a plain line (see [accounts.md](accounts.md)). Anything else stays on the `Received:` echo. Collect and confirm reply 200. Approve returns 202 `{ok, kicked_off, event_id}` and runs `run_call` in this process. After hangup it texts the result on `POST /chats/{chat_id}/messages`. Failed talk and place-exceptions use friendly `user_text` (`I couldn't finish the call` / `I couldn't place the call`) and never echo raw R2, env, or HTTP. Mid-call chat is not shipped; clarification needed texts a plain intake line. Cap recorded `live-prove: PASS (#34)`. qa owns live-prove sign-off (TRE-19). This tree does not redial. +Party and time come from the first text when present. If time is missing, Treena asks for time (or party and time) before the restaurant phone number. `at ` is not a time. "2 people" alone does not unlock the phone ask. After the number she always asks to confirm before dial. Confirm / approve / yes are accepted. The first message never dials, even when it already includes `+E164`. Caller name comes from the `users` row for the inbound phone, never from iMessage and never from `LINQ_KICKOFF_CALLER`. Website `/start` collects that name; after signup the copy is in settings. `BookRequest.user_id` is that row's `user_id`. The outbound Telnyx caller ID is `users.from_did`, not the shared Linq bot and not `ledger.provision`. No row, a row with no first name, or a row with no `from_did` texts a plain line (see [accounts.md](accounts.md)). Anything else stays on the `Received:` echo. Collect and confirm reply 200. Approve returns 202 `{ok, kicked_off, event_id}` and runs `run_call` in this process. After hangup it texts the result on `POST /chats/{chat_id}/messages`. Failed talk and place-exceptions use friendly `user_text` (`I couldn't finish the call` / `I couldn't place the call`) and never echo raw R2, env, or HTTP. Mid-call chat is not shipped; clarification needed texts a plain intake line. Cap recorded `live-prove: PASS (#34)`. qa owns live-prove sign-off (TRE-19). This tree does not redial. Prove the stub path: diff --git a/tests/test_accounts.py b/tests/test_accounts.py index 61616d2..5c17506 100644 --- a/tests/test_accounts.py +++ b/tests/test_accounts.py @@ -342,6 +342,31 @@ def test_upsert_merges_from_did_when_omitted(tmp_path: Path) -> None: assert second.record.stripe_customer_id == "cus_123" +def test_two_accounts_can_share_the_same_from_did(tmp_path: Path) -> None: + store = Accounts(tmp_path / "ledger.sqlite3") + shared = E164("+15555550177") + first = store.upsert( + AccountWrite( + phone=E164("+14155552671"), + first_name="Nathan", + user_id=UserId("acctnathan"), + from_did=shared, + ) + ) + second = store.upsert( + AccountWrite( + phone=E164("+17272701288"), + first_name="Cap", + user_id=UserId("acctcap"), + from_did=shared, + ) + ) + + assert first.record.from_did == shared + assert second.record.from_did == shared + assert first.record.phone != second.record.phone + + def test_from_did_equal_to_phone_fails_loud(tmp_path: Path) -> None: with pytest.raises(ValueError, match="not the user's own phone"): AccountWrite( @@ -644,6 +669,7 @@ def test_accounts_dashboard_empty_for_unknown( monkeypatch.setenv("ACCOUNT_SYNC_SECRET", secret) monkeypatch.setenv("CALL_AGENT_DB_PATH", str(tmp_path / "ledger.sqlite3")) monkeypatch.setenv("CALL_AGENT_ARTIFACTS_DIR", str(tmp_path / "artifacts")) + monkeypatch.setenv("LINQ_FROM_E164", "") phone = "+14155552671" signed = dashboard_signature_payload(phone) with TestClient(fastapi_app) as client: @@ -677,6 +703,7 @@ def test_accounts_dashboard_empty_for_unknown( assert payload["user"] is None assert payload["threads"] == [] assert payload["calls"] == [] + assert payload["bot_number"] is None def test_accounts_dashboard_includes_retained_report( @@ -821,6 +848,35 @@ def test_accounts_dashboard_includes_retained_report( assert empty["user"] is None assert empty["threads"] == [] assert empty["calls"] == [] + assert empty["bot_number"] is None + + +def test_accounts_dashboard_includes_shared_bot_number( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + secret = "sync-secret" + monkeypatch.setenv("ACCOUNT_SYNC_SECRET", secret) + monkeypatch.setenv("CALL_AGENT_DB_PATH", str(tmp_path / "ledger.sqlite3")) + monkeypatch.setenv("CALL_AGENT_ARTIFACTS_DIR", str(tmp_path / "artifacts")) + monkeypatch.setenv("LINQ_FROM_E164", "+12138653024") + phone = "+14155552671" + signed = dashboard_signature_payload(phone) + with TestClient(fastapi_app) as client: + response = client.get( + "/accounts/dashboard", + params={"phone": phone}, + headers={ + ACCOUNT_SYNC_SIGNATURE_HEADER: sign_account_sync( + secret, signed + ) + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["ok"] is True + assert payload["user"] is None + assert payload["bot_number"] == "+12138653024" def test_cli_account_upsert_omitted_name_preserves( diff --git a/tests/test_feature_map.py b/tests/test_feature_map.py index c3ad9bb..d4d8837 100644 --- a/tests/test_feature_map.py +++ b/tests/test_feature_map.py @@ -76,6 +76,7 @@ def test_feature_map_lists_account_rows() -> None: required_rows = ( "| `users` | shipped |", "| `users.from_did` | shipped |", + "| `shared Treena iMessage bot` | shipped |", "| `open_accounts` | shipped |", "| `account upsert` | shipped |", "| `account get` | shipped |", @@ -215,6 +216,9 @@ def test_feature_map_records_cap_locks() -> None: assert "TRE-22 unfreeze ordered" in INVENTORY assert "Dashboard UI stays on Pages" in INVENTORY assert "Intended Fly route" in INVENTORY + assert "shared Treena iMessage bot" in INVENTORY + assert "LINQ_FROM_E164" in INVENTORY + assert "+12138653024" in INVENTORY assert "missing_public_key" in INVENTORY assert "bad_signature" in INVENTORY assert "website `/start`" in INVENTORY diff --git a/tests/test_messaging.py b/tests/test_messaging.py index 444bfb7..9e20a70 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -18,6 +18,7 @@ kickoff_from_settings, parse_inbound, serialize_outbound, + shared_bot_e164, sign_linq_webhook, verify_linq_signature, ) @@ -199,6 +200,16 @@ def test_live_constructor_fails_without_token() -> None: LiveLinqHttp("") +def test_shared_bot_e164_is_linq_from_and_not_per_user( + settings: Settings, +) -> None: + assert shared_bot_e164(settings) is None + filled = settings.model_copy(update={"linq_from_e164": "+12138653024"}) + assert shared_bot_e164(filled) == E164("+12138653024") + other = filled.model_copy() + assert shared_bot_e164(other) == shared_bot_e164(filled) + + def test_kickoff_from_settings_requires_from(settings: Settings) -> None: with pytest.raises(LinqConfigError, match="LINQ_FROM_E164"): kickoff_from_settings(settings, E164("+14155552671"), "hello") @@ -206,6 +217,7 @@ def test_kickoff_from_settings_requires_from(settings: Settings) -> None: message = kickoff_from_settings(filled, E164("+17272701288"), "hello") assert message.from_did == "+12138653024" assert serialize_outbound(message)["from"] == "+12138653024" + assert shared_bot_e164(filled) == E164("+12138653024") def test_live_send_posts_chats_parts_shape() -> None: From 632607ae94a90e48b0886f362d2ef750df46d64c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 13 Sep 2026 22:24:41 +0000 Subject: [PATCH 2/2] feat(kickoff): shared Telnyx FROM via TELNYX_FROM_NUMBER Cap GO: one sitewide FROM (+12135690899) for all users, wired through TELNYX_FROM_NUMBER / TELNYX_PHONE_NUMBER. Kickoff no longer blocks on users.from_did. Linq bot stays LINQ_FROM_E164=+12138653024. Stripe live HOLD. Co-authored-by: natecl --- .../verify-call-agent/features/accounts.md | 15 +++-- .../features/did-provision.md | 4 +- .../features/linq-to-call.md | 14 ++-- .../features/verify-linq-call.md | 12 ++-- .env.example | 12 +++- README.md | 8 +-- call_agent/accounts.py | 8 +-- call_agent/cli.py | 25 ++++---- call_agent/config.py | 13 +++- call_agent/kickoff.py | 29 +++++---- call_agent/linq.py | 2 +- call_agent/runtime.py | 30 +++++++++ call_agent/user_text.py | 2 + call_agent/verify_linq_call.py | 25 ++++---- docs/FEATURE_MAP.md | 22 ++++--- docs/WAVE1_HOLD_FEATURE_MAP_PLAN.md | 2 +- docs/accounts.md | 27 ++++---- docs/linq.md | 4 +- tests/test_cli.py | 21 +++--- tests/test_feature_map.py | 8 ++- tests/test_kickoff.py | 64 +++++++++++++++---- tests/test_messaging.py | 10 +-- tests/test_runtime.py | 26 ++++++++ tests/test_server.py | 1 + 24 files changed, 256 insertions(+), 128 deletions(-) diff --git a/.cursor/skills/verify-call-agent/features/accounts.md b/.cursor/skills/verify-call-agent/features/accounts.md index 3054440..e7ccb5f 100644 --- a/.cursor/skills/verify-call-agent/features/accounts.md +++ b/.cursor/skills/verify-call-agent/features/accounts.md @@ -1,19 +1,20 @@ # Accounts (`users` row) -The `users` table is the runtime account record. Cap model: one shared Treena iMessage bot (`LINQ_FROM_E164`); each account stores the user's own phone. Kickoff looks it up by inbound phone. Stripe stays checkout and billing. call-agent-web POSTs a signed body to Fly `POST /accounts/sync`; Fly is the sole writer. Those Fly routes (`POST /accounts/sync` and `GET /accounts/dashboard`) are intended, not accidental. Dashboard UI stays on Pages (web). CLI `account upsert --from-did` seeds a row, including the Telnyx caller ID, until that website PR lands. Name is collected on website `/start` and that copy is in settings after signup. Kickoff reads `users.first_name`. Never collected over iMessage. Display name is `first_name` + `prefs.full_name`. There is no `users.name` column. Signed `GET /accounts/dashboard` packages that row, the sitewide `bot_number`, plus linq artifacts so the website does not open Neon or R2. Prove with `call-agent verify-accounts`. This skill never sets `NEON_DATABASE_URL` or `ACCOUNT_SYNC_SECRET`. +The `users` table is the runtime account record. Cap model: one shared Treena iMessage bot (`LINQ_FROM_E164`); one shared Telnyx FROM (`TELNYX_FROM_NUMBER` / `TELNYX_PHONE_NUMBER`); each account stores the user's own phone. Kickoff looks it up by inbound phone. Stripe stays checkout and billing. call-agent-web POSTs a signed body to Fly `POST /accounts/sync`; Fly is the sole writer. Those Fly routes (`POST /accounts/sync` and `GET /accounts/dashboard`) are intended, not accidental. Dashboard UI stays on Pages (web). CLI `account upsert` seeds a row until that website PR lands. `--from-did` still writes the legacy column; kickoff does not use it. Name is collected on website `/start` and that copy is in settings after signup. Kickoff reads `users.first_name`. Never collected over iMessage. Display name is `first_name` + `prefs.full_name`. There is no `users.name` column. Signed `GET /accounts/dashboard` packages that row, the sitewide `bot_number`, plus linq artifacts so the website does not open Neon or R2. Prove with `call-agent verify-accounts`. This skill never sets `NEON_DATABASE_URL` or `ACCOUNT_SYNC_SECRET`. ## Sub-features -- `users-row` one `users` row per phone: `first_name`, the user's own phone, Telnyx `from_did` (not unique), minutes, retain, prefs, Stripe ids. No `users.name` column. -- `from-did` nullable E.164 Telnyx caller ID. Not unique. Rejected when equal to `phone`. Additive `ALTER TABLE` on existing SQLite and Neon `users` tables. Not the shared iMessage bot. +- `users-row` one `users` row per phone: `first_name`, the user's own phone, legacy Telnyx `from_did` (not unique, not required at kickoff), minutes, retain, prefs, Stripe ids. No `users.name` column. +- `from-did` nullable legacy E.164. Not unique. Rejected when equal to `phone`. Additive `ALTER TABLE` on existing SQLite and Neon `users` tables. Not the shared iMessage bot and not the live Telnyx FROM. - `shared-bot` `LINQ_FROM_E164` is the one Text Treena number (sandbox `+12138653024` until ~Sep 18). Dashboard `bot_number` is that key. +- `shared-telnyx-from` `TELNYX_FROM_NUMBER` / `TELNYX_PHONE_NUMBER` is the one Telnyx FROM (Cap `+12135690899`). Kickoff dials from that env, not `users.from_did`. - `omit-preserve` omitted `first_name`, `prefs`, `minutes_used`, and `from_did` keep the existing values. Explicit `minutes_used` `0` writes 0. -- `account-upsert` CLI seed without HMAC. `--phone` is the user's number. `--from-did` sets the Telnyx from-number. Omitted `--from-did` and `--name` keep existing values. +- `account-upsert` CLI seed without HMAC. `--phone` is the user's number. `--from-did` still writes the legacy column. Omitted `--from-did` and `--name` keep existing values. - `account-get` read-back of that phone, including `from_did`. - `account-list` every row (`USER PHONE NAME FROM_DID MINUTES RETAIN`). - `accounts-sync` HMAC `POST /accounts/sync` upserts; Fly is the only writer. Omitted name/prefs/`minutes_used`/`from_did` keep seeded values. - `accounts-dashboard` HMAC `GET /accounts/dashboard?phone=` packages `users` + `bot_number` + linq thread index + retained `call_report`/transcript. Empty lists when unknown or wiped. -- `kickoff-lookup` inbound phone → `users.first_name`, `users.user_id`, and `users.from_did` for the Telnyx call. +- `kickoff-lookup` inbound phone → `users.first_name` and `users.user_id`. Telnyx FROM is `TELNYX_FROM_NUMBER`. ## How to get to it (user POV) @@ -40,8 +41,8 @@ Preconditions: - SQLite `users` is the same file as `dids`. On Fly, set `NEON_DATABASE_URL` or the row dies on redeploy. - `account upsert` does not need `ACCOUNT_SYNC_SECRET`. The HTTP routes do. -- Kickoff `BookRequest.user_id` is `users.user_id`. The outbound Telnyx caller ID is `users.from_did`. Confirm/dial never calls `ledger.provision`. The iMessage bot is `LINQ_FROM_E164`, not `from_did`. -- `from_did` must not equal `phone`. Personal number is not the Telnyx from-number. Two accounts may share one `from_did`. +- Kickoff `BookRequest.user_id` is `users.user_id`. The outbound Telnyx caller ID is `TELNYX_FROM_NUMBER` / `TELNYX_PHONE_NUMBER`. Confirm/dial never calls `ledger.provision`. The iMessage bot is `LINQ_FROM_E164`. Kickoff ignores `users.from_did`. +- `from_did` must not equal `phone`. Personal number is not the Telnyx from-number. Two accounts may share one legacy `from_did`. - Omitted `--from-did` / `--name` (and omitted `from_did` / `first_name` / `prefs` / `minutes_used` on `/accounts/sync`) keep existing values so Stripe sync cannot wipe an ops-seeded DID, name, or minutes count. Explicit `minutes_used` `0` is a write. - Dashboard HMAC signs the E.164 `phone` query value, not the URL-encoded query string. Unsigned GET is 401. Unknown phone returns empty lists. `bot_number` still comes from `LINQ_FROM_E164`. - Do not collect a name over iMessage. Name is collected on website `/start` and is in settings after signup. Do not set `LINQ_KICKOFF_CALLER` or `LINQ_KICKOFF_USER_ID`. diff --git a/.cursor/skills/verify-call-agent/features/did-provision.md b/.cursor/skills/verify-call-agent/features/did-provision.md index 8b1b3b7..5b16dc5 100644 --- a/.cursor/skills/verify-call-agent/features/did-provision.md +++ b/.cursor/skills/verify-call-agent/features/did-provision.md @@ -13,7 +13,7 @@ - Run `call-agent did provision --user ` in a terminal. - Run `call-agent did list` in a terminal. -- Confirm/dial does not provision. Live from-DID is `users.from_did` or CLI `--from-did`. +- Confirm/dial does not provision. Live from-DID is `TELNYX_FROM_NUMBER` / `TELNYX_PHONE_NUMBER` or CLI `--from-did`. ## Driving it with call-agent @@ -32,4 +32,4 @@ Preconditions: - A leftover ledger from a previous run makes the first provision return a number that is not `+15555550100`. Use a fresh `CALL_AGENT_DB_PATH`. - Stub numbers are assigned in order of first provision, not by user name. - Live provisioning orders a real number and costs money. This skill never runs it. -- Confirm/dial never calls `Ledger.provision`. `did provision` is an ops verb, not a live-call prerequisite. Cap account model does not assign a unique Treena iMessage DID per user. Live from-DID is `users.from_did` or CLI `--from-did`. +- Confirm/dial never calls `Ledger.provision`. `did provision` is an ops verb, not a live-call prerequisite. Cap account model does not assign a unique Treena DID per user. Live from-DID is `TELNYX_FROM_NUMBER` / `TELNYX_PHONE_NUMBER` or CLI `--from-did`. Kickoff ignores `users.from_did`. diff --git a/.cursor/skills/verify-call-agent/features/linq-to-call.md b/.cursor/skills/verify-call-agent/features/linq-to-call.md index 0d1ed3e..82767b0 100644 --- a/.cursor/skills/verify-call-agent/features/linq-to-call.md +++ b/.cursor/skills/verify-call-agent/features/linq-to-call.md @@ -1,6 +1,6 @@ # Linq to call (closed alpha) -Closed alpha testers text Treena on iMessage. A natural book ask on that chat collects party and time, then the restaurant phone number, then confirm/approve, then kicks off the same general `run_call` path FastAPI already uses for CLI book and quick-ask. If time is missing, Treena asks for time (or party+time) before the phone number. Caller name, kickoff `user_id`, and Telnyx from-DID come from the `users` row for the inbound phone, not from the chat. Name is collected on website `/start` and is in settings after signup. The Worker forwards Linq POST to Fly. Collect and confirm reply 200. Approve returns 202, texts `Okay, I'm going to call them now.`, places the call, hangs up, and texts the result on the same Linq chat. The first message never dials. It does not pause the live call for mid-call chat. Cap recorded `live-prove: PASS (#34)`. qa owns live-prove sign-off (TRE-19). This skill does not redial. +Closed alpha testers text Treena on iMessage. A natural book ask on that chat collects party and time, then the restaurant phone number, then confirm/approve, then kicks off the same general `run_call` path FastAPI already uses for CLI book and quick-ask. If time is missing, Treena asks for time (or party+time) before the phone number. Caller name and kickoff `user_id` come from the `users` row for the inbound phone, not from the chat. Telnyx FROM is the shared env (`TELNYX_FROM_NUMBER` / `TELNYX_PHONE_NUMBER`), not `users.from_did`. Name is collected on website `/start` and is in settings after signup. The Worker forwards Linq POST to Fly. Collect and confirm reply 200. Approve returns 202, texts `Okay, I'm going to call them now.`, places the call, hangs up, and texts the result on the same Linq chat. The first message never dials. It does not pause the live call for mid-call chat. Cap recorded `live-prove: PASS (#34)`. qa owns live-prove sign-off (TRE-19). This skill does not redial. ## Sub-features @@ -10,9 +10,9 @@ Closed alpha testers text Treena on iMessage. A natural book ask on that chat co - `collect-phone` Treena texts `okay, sounds good. What's the restaurant's phone number?` and does not dial. The next text on that chat supplies the E164, then Treena asks to confirm. - `confirm-before-dial` After the number, Treena texts `Okay, I'm going to call this number. Do you confirm? Reply "approve"`. User must say confirm / approve / yes. Then Treena texts `Okay, I'm going to call them now.` and dials. A first message that already has +E164 never dials. - `parse-fail` A phone follow-up that is not a number, or a confirm follow-up that is not an affirmative, texts a plain intake line. No `Call failed:` / `Call not placed:` prefix. No dial. -- `account-lookup` Inbound phone must match a `users` row. The call uses that row's first name, `user_id`, and `from_did`. -- `account-gate` No row, a row with no first name, or a row with no `from_did` texts a plain line. Never `Call failed: missing caller name`. Never `ledger.provision`. -- `from-did` Confirm/dial uses `users.from_did` as `DialSpec.from_did`. `run_call` takes a required `from_did` kwarg. `KickoffSession.from_did` stays the shared Linq iMessage bot (`LINQ_FROM_E164`). +- `account-lookup` Inbound phone must match a `users` row. The call uses that row's first name and `user_id`. +- `account-gate` No row or a row with no first name texts a plain line. Missing `users.from_did` still collects. Never `Call failed: missing caller name`. Never `ledger.provision`. +- `from-did` Confirm/dial uses `resolve_telnyx_from` as `DialSpec.from_did`. `run_call` takes a required `from_did` kwarg. `KickoffSession.from_did` stays the shared Linq iMessage bot (`LINQ_FROM_E164`). Kickoff ignores `users.from_did`. - `no-number-order` Confirm/dial never calls `ledger.provision`, `search_and_order_number`, or `POST /v2/number_orders`. - `echo-else` Any other inbound text stays on `Received: {text}`. - `async-b` HMAC verify, parse, event-id dedupe, then 202 `{ok, kicked_off, event_id}` on the approve follow-up. `BackgroundTasks` runs `run_kickoff` in this process. @@ -22,7 +22,7 @@ Closed alpha testers text Treena on iMessage. A natural book ask on that chat co ## How to get to it (user POV) -- Have an account (website checkout, or `call-agent account upsert --phone --name Nathan --from-did `) with a first name and Telnyx from-number. The number people text is the shared bot `LINQ_FROM_E164`, not a per-user DID. +- Have an account (website checkout, or `call-agent account upsert --phone --name Nathan`) with a first name. The number people text is the shared bot `LINQ_FROM_E164`, not a per-user DID. Telnyx FROM is `TELNYX_FROM_NUMBER`. - Text Treena `can you make a reservation with this restaurant at 7:30 for a party of two?`. - If she asks for time (or party and time), reply with those before the restaurant number. - Reply with the restaurant phone number when she asks. @@ -45,8 +45,8 @@ Preconditions: - This lever never hits Telnyx or Linq live. Cap recorded `live-prove: PASS (#34)`. qa owns live-prove sign-off (TRE-19). This skill does not redial. - `PUBLIC_BASE_URL` and `PUBLIC_WEBHOOK_URL` on Fly must both be `https://hidden-beacon-080.fly.dev`. The Worker does not terminate media. - One Fly machine: `BOOK` is process-local. Do not scale this app until that is no longer true. -- Confirm/dial never provisions a DID. A missing `users.from_did` texts `NO_FROM_DID_TEXT` and does not buy a number. -- Live kickoff needs a `users` row on Fly's Neon for the tester's iMessage phone, with Telnyx `from_did` set. Seed with `account upsert --from-did` until call-agent-web POSTs `/accounts/sync`. +- Confirm/dial never provisions a DID. Empty `users.from_did` still collects. Live missing `TELNYX_FROM_NUMBER` fails with `I couldn't place the call.` and does not buy a number. +- Live kickoff needs a `users` row on Fly's Neon for the tester's iMessage phone, with a first name. Seed with `account upsert` until call-agent-web POSTs `/accounts/sync`. Set Fly `TELNYX_FROM_NUMBER` / `TELNYX_PHONE_NUMBER=+12135690899`. - `KickoffSession.from_did` is the shared Linq iMessage bot (`LINQ_FROM_E164`). Telnyx caller ID is `KickoffParsed.from_did` / `run_call(from_did=...)`. - Mid-call iMessage is not shipped. Do not treat a missing pause/resume as a failed L1 kickoff. - Pending collect and confirm live at `linq/pending/.json` under artifacts. It is not the live-call pause/resume seam. diff --git a/.cursor/skills/verify-call-agent/features/verify-linq-call.md b/.cursor/skills/verify-call-agent/features/verify-linq-call.md index b5930a7..d0e9547 100644 --- a/.cursor/skills/verify-call-agent/features/verify-linq-call.md +++ b/.cursor/skills/verify-call-agent/features/verify-linq-call.md @@ -1,6 +1,6 @@ # Verify linq-to-call -`verify-linq-call` is the stub lever for iMessage natural book → collect time when missing → collect phone → confirm/approve → `run_call` → Linq result reply with artifact cross-links. It seeds a `users` row for the inbound fixture phone, including `from_did=+15555550177`. It needs no Telnyx, OpenAI, or Linq keys and opens no sockets. It exits `0` only when all fourteen checks pass. It fails if dial happens before confirm. +`verify-linq-call` is the stub lever for iMessage natural book → collect time when missing → collect phone → confirm/approve → `run_call` → Linq result reply with artifact cross-links. It seeds a `users` row for the inbound fixture phone **without** `from_did`. Telnyx FROM is the shared env (`+12135690899`). It needs no Telnyx, OpenAI, or Linq keys and opens no sockets. It exits `0` only when all fourteen checks pass. It fails if dial happens before confirm. ## Sub-features @@ -12,13 +12,13 @@ - `confirm-before-dial` `approve` / `yes` / `I confirm` become a `BookRequest`. A complete first-message one-shot never dials. A non-affirmative while waiting for confirm texts a plain intake line. - `parse-ask` `ask +14155552671 hours name Nathan` asks to confirm, then `approve` becomes a hours `QuickAskRequest`. The `name` token is ignored; caller comes from the `users` row. - `parse-fail` a non-number follow-up while waiting for the phone texts a plain intake line, writes no `kickoff.json`. -- `account-lookup` approve builds `BookRequest.caller` and `user_id` from the seeded `users` row. `kickoff.json` records `account.user_id` and `from_did`. -- `account-gate` no row, a row with no first name, and a row with no `from_did` reply plain lines. Never `Call failed: missing caller name`. No dial. +- `account-lookup` approve builds `BookRequest.caller` and `user_id` from the seeded `users` row. `kickoff.json` records `account.user_id` (legacy `from_did` may be null) and `dial_from` as the shared FROM. +- `account-gate` no row and a row with no first name reply plain lines. A row with no `from_did` still collects. Never `Call failed: missing caller name`. No dial. - `kickoff-reply` two `POST /chats/{chat_id}/messages` sends: `Okay, I'm going to call them now.` then `Booked:`. - `call-ran` stub call is `booked`, hangup path tool, `hangup.json` present. This lever retains artifacts so it can read them. - `cross-link` `linq//call.json` and `/linq.json` point at each other. - `same-host` `dial.json` webhook host equals stream host (`stub.invalid`). -- `from-did-users-row` `dial.json` `from` is the seeded `users.from_did` (`+15555550177`), not the inbound personal phone and not the Linq line. +- `shared-from-did` `dial.json` `from` is the shared Telnyx FROM (`+12135690899`), not the inbound personal phone and not the Linq line. - `no-number-order` confirm/dial source has no `provision(`, `search_and_order_number`, `number_orders`, or `open_ledger`. Stub `dids` stays empty. ## How to get to it (user POV) @@ -33,7 +33,7 @@ Preconditions: - Baseline env from `README.md`. This verb opens no sockets. It writes under a temp artifacts dir, not the process `CALL_AGENT_ARTIFACTS_DIR`, unless you pass a workspace through pytest. -- **CI command.** Run `.venv/bin/call-agent verify-linq-call`. Exit code `0`. Stdout has exactly fourteen lines that start with `[PASS]`, in this order: `parse_book`, `collect_time`, `collect_phone`, `confirm_before_dial`, `parse_quick_ask`, `parse_fail_loud`, `account_lookup`, `account_gate`, `kickoff_reply`, `call_ran`, `cross_link`, `same_host_dial`, `from_did_users_row`, `no_number_order`. The last line is `14/14 passed`. +- **CI command.** Run `.venv/bin/call-agent verify-linq-call`. Exit code `0`. Stdout has exactly fourteen lines that start with `[PASS]`, in this order: `parse_book`, `collect_time`, `collect_phone`, `confirm_before_dial`, `parse_quick_ask`, `parse_fail_loud`, `account_lookup`, `account_gate`, `kickoff_reply`, `call_ran`, `cross_link`, `same_host_dial`, `shared_from_did`, `no_number_order`. The last line is `14/14 passed`. - **Defaults.** Run `.venv/bin/call-agent verify-linq-call`. Same fourteen lines, exit code `0`. - **Proof.** Save stdout as `verify-linq-call.txt` in the evidence directory. @@ -43,4 +43,4 @@ Preconditions: - Stub Telnyx always uses `call_control_id` `stub-call-0001`. Two stub kickoffs in one artifacts dir reuse that run dir. - This lever sets `retain_call_artifacts=True` so `call_ran` and `same_host_dial` can read hangup and dial files. Default CLI `book`/`quick-ask` wipe those files. The seeded account's retain flag is off, so the `BookRequest` flag is false; the verify Settings still retain. - Echo-only inbound is `verify-message`, not this verb. -- Seeded stub `from_did` is `+15555550177`. The Linq line fixture is `+15555550100`. The inbound sender is `+14155552671`. Those three must stay distinct. +- Shared stub FROM is `+12135690899`. The Linq line fixture is `+15555550100`. The inbound sender is `+14155552671`. Those three must stay distinct. Product `call_agent/*.py` must not hardcode `+12135690899`. diff --git a/.env.example b/.env.example index 1b9a56b..edf3961 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,11 @@ TELNYX_API_KEY= TELNYX_CONNECTION_ID= TELNYX_PUBLIC_KEY= +# Shared Telnyx FROM for every Treena user (Cap GO). Fly live sets both +# keys to the same E.164. Product code has no default digits. Stub +# without these uses +15555550100. Distinct from LINQ_FROM_E164. +TELNYX_FROM_NUMBER=+12135690899 +TELNYX_PHONE_NUMBER=+12135690899 # L16 asks Telnyx for 16 kHz PCM and passes it to GPT-Live untouched. PCMU is the 8 kHz fallback. TELNYX_STREAM_CODEC=L16 @@ -59,11 +64,12 @@ LINQ_WEBHOOK_URL= # L1 iMessage kickoff. Each account stores the user's own phone # (`users.phone`, the inbound iMessage sender). The shared bot people # text is LINQ_FROM_E164, not a per-user Treena DID. The outbound Telnyx -# caller ID is still users.from_did (not unique; not the iMessage bot). +# caller ID is TELNYX_FROM_NUMBER / TELNYX_PHONE_NUMBER (Cap GO +# +12135690899). Kickoff does not require users.from_did. # Seed a tester with: -# call-agent account upsert --phone --name --from-did +# call-agent account upsert --phone --name # Caller name comes from the users row, never from iMessage and never -# from LINQ_KICKOFF_CALLER. No env var sets from_did. +# from LINQ_KICKOFF_CALLER. # HMAC secret for POST /accounts/sync. Fly is the sole writer of users. # call-agent-web POSTs signed checkout/settings payloads here. CLI diff --git a/README.md b/README.md index c14b279..7ebea83 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # call-agent -A phone agent that calls a business on your behalf, asks one quick question, and writes down the answer. You paste an E.164 number, pick `hours`, `stock`, or `aisle`, and give your first name. The agent dials from a Telnyx number (`users.from_did`), opens with "I'm calling on behalf of Nathan", gets the answer, hangs up, and leaves a result, a transcript, and the call audio on disk. Closed-alpha testers text one shared Treena iMessage bot (`LINQ_FROM_E164`); each account stores the user's own phone. +A phone agent that calls a business on your behalf, asks one quick question, and writes down the answer. You paste an E.164 number, pick `hours`, `stock`, or `aisle`, and give your first name. The agent dials from a shared Telnyx FROM (`TELNYX_FROM_NUMBER` / `TELNYX_PHONE_NUMBER`), opens with "I'm calling on behalf of Nathan", gets the answer, hangs up, and leaves a result, a transcript, and the call audio on disk. Closed-alpha testers text one shared Treena iMessage bot (`LINQ_FROM_E164`); each account stores the user's own phone. The stack is Python 3.12, Pydantic v2, Typer, FastAPI (two routes), Telnyx Call Control with media streaming, an app-owned GPT-Live bridge that follows [Telnyx's outbound GPT-Live recipe](https://telnyx.com/resources/outbound-ai-calls-python-openai-live), and a DID ledger that defaults to SQLite. Set `NEON_DATABASE_URL` to use Neon. Set the four `R2_*` vars to put finished artifacts on R2. The design and its rationale are in [docs/design.md](docs/design.md). How V1 prompts GPT-Live, and the OpenAI page that is the source of truth for it, are in [docs/gpt-live-prompting.md](docs/gpt-live-prompting.md). What this tree implements today is listed in [docs/FEATURE_MAP.md](docs/FEATURE_MAP.md). The standing verification map is [`.cursor/skills/verify-call-agent/features/`](.cursor/skills/verify-call-agent/features/README.md). Both maps update when a lever ships. @@ -24,7 +24,7 @@ fly secrets set ACCOUNT_SYNC_SECRET=... fly deploy ``` -`LINQ_WEBHOOK_SIGNING_SECRET` is required for inbound HMAC. `ACCOUNT_SYNC_SECRET` is required for `POST /accounts/sync` (call-agent-web checkout/settings). Live inbound reply also needs `LINQ_API_TOKEN` and `LINQ_FROM_E164` on this process. `LINQ_FROM_E164` is the one shared Treena iMessage bot (Cap/ops sandbox `+12138653024` until ~Sep 18). HMAC verify without a token still ACKs ignored events; `message.received` send uses StubLinq when the token is unset. Seed L1 testers with `call-agent account upsert --phone +1... --name Nathan --from-did +1...` until the website PR POSTs sync. `--phone` is the user's own number. `from_did` is the Telnyx caller ID, not the shared bot. The sync contract is [docs/accounts.md](docs/accounts.md). This tree does not `fly deploy`. +`LINQ_WEBHOOK_SIGNING_SECRET` is required for inbound HMAC. `ACCOUNT_SYNC_SECRET` is required for `POST /accounts/sync` (call-agent-web checkout/settings). Live inbound reply also needs `LINQ_API_TOKEN` and `LINQ_FROM_E164` on this process. `LINQ_FROM_E164` is the one shared Treena iMessage bot (Cap/ops sandbox `+12138653024` until ~Sep 18). Live dial also needs `TELNYX_FROM_NUMBER` and `TELNYX_PHONE_NUMBER` (`+12135690899`). HMAC verify without a token still ACKs ignored events; `message.received` send uses StubLinq when the token is unset. Seed L1 testers with `call-agent account upsert --phone +1... --name Nathan` until the website PR POSTs sync. `--phone` is the user's own number. Kickoff does not need `users.from_did`. The sync contract is [docs/accounts.md](docs/accounts.md). This tree does not `fly deploy`. Then point the Worker at the Fly origin (no path) and deploy the Worker: @@ -78,7 +78,7 @@ call-agent verify-message You should see six `[PASS]` lines and `6/6 passed`. The checks are stub send shape, inbound `message.received` mapping, inbound reply + artifact text, Standard Webhooks signature accept/reject, live constructor without `LINQ_API_TOKEN`, and SMS ignored. See [docs/linq.md](docs/linq.md). -`call-agent verify-linq-call` proves iMessage natural collect + confirm-before-dial → stub `run_call` → result reply on the same chat, plus artifact cross-links. Fourteen `[PASS]` lines and `14/14 passed`, including `collect_time`, `from_did_users_row` and `no_number_order`. It fails if dial happens before confirm. It does not dial Telnyx or hit Linq live. Cap recorded `live-prove: PASS (#34)`. qa owns live-prove sign-off (TRE-19). +`call-agent verify-linq-call` proves iMessage natural collect + confirm-before-dial → stub `run_call` → result reply on the same chat, plus artifact cross-links. Fourteen `[PASS]` lines and `14/14 passed`, including `collect_time`, `shared_from_did` and `no_number_order`. It fails if dial happens before confirm. It does not dial Telnyx or hit Linq live. Cap recorded `live-prove: PASS (#34)`. qa owns live-prove sign-off (TRE-19). `call-agent verify-persistence` checks the Neon and R2 ports. With those vars unset it uses SQLite and a local directory, prints four `[PASS]` lines, and exits 0. If some `R2_*` vars are set and others are not, or if `NEON_DATABASE_URL` is not a `postgresql://` URL, it exits 2 and names the problem. CI runs it with the vars unset. It never opens Neon or R2. @@ -114,7 +114,7 @@ Live mode places a real call. It is gated so that no test or CI run can reach th 1. Copy `.env.example` to `.env` and fill in `TELNYX_API_KEY`, `TELNYX_CONNECTION_ID`, and `OPENAI_API_KEY`. Keep `.env` out of git. 2. Start a public tunnel to `CALL_AGENT_PORT` (default 8000), for example `ngrok http 8000` or `cloudflared tunnel --url http://localhost:8000`, and put that tunnel's `https://` URL in **both** `PUBLIC_BASE_URL` and `PUBLIC_WEBHOOK_URL`. CLI `--live` hosts CallHandle in this process. Telnyx must open `/media-stream` and post the per-call `webhook_url` on that same host. Process env overrides `.env`, so a leftover `PUBLIC_WEBHOOK_URL` can send webhooks to a stale host while media follows `PUBLIC_BASE_URL` (Fly media + old tunnel). The verb fails before dial if webhook host != stream host after wss/https normalize, or if `{PUBLIC_BASE_URL}/health` is not HTTP 200 with this process's `serve_token`. Fly `https://hidden-beacon-080.fly.dev` is a different process with no BOOK entry; it is not valid as `PUBLIC_BASE_URL` for Mac CLI live. The verb waits until local `/health` accepts, then probes `{PUBLIC_BASE_URL}/health` until HTTP 200 and this process's `serve_token` match (and `/media-stream` accepts) before it dials. A printed trycloudflare URL is not enough: DNS can lag after the URL is advertised. The Call Control application `webhook_event_url` is independently `https://call-agent-webhooks.usetreena.workers.dev/webhooks/telnyx` (always-on, laptop-off). That Worker does not terminate media: `/media-stream` returns 501. Optional delivery proof through a working tunnel: `POST /webhooks/telnyx` with an unknown `call_control_id` returns 404 `unknown call` from this process (the Worker unsigned POST is 401). Linq registers `https://call-agent-webhooks.usetreena.workers.dev/linq/webhook` (alias `/webhooks/linq`). See [workers/webhooks/README.md](workers/webhooks/README.md). 3. Set `QUICK_ASK_LIVE=1`. -4. Assign the outbound caller ID. Live CLI takes `--from-did` or reads `users.from_did` for `--user`. Seed with `call-agent account upsert --phone +1... --name Nathan --from-did +1...`. Confirm/dial never calls `ledger.provision` and never `POST /v2/number_orders`. `did provision --live` is an ops verb that can order a number into the `dids` table; it is not a live-call prerequisite. +4. Assign the outbound caller ID. Live CLI takes `--from-did` or reads `TELNYX_FROM_NUMBER` / `TELNYX_PHONE_NUMBER` (Cap `+12135690899`). Kickoff ignores `users.from_did`. Confirm/dial never calls `ledger.provision` and never `POST /v2/number_orders`. `did provision --live` is an ops verb that can order a number into the `dids` table; it is not a live-call prerequisite. 5. Run `call-agent quick-ask --to +1... --ask hours --name Nathan --live --from-did +1...`. The command hosts the two FastAPI routes in the same process, dials, bridges audio between Telnyx and GPT-Live, and prints the result when the call ends. `call-agent book --to +1... --name Nathan --party 2 --time "Friday 7pm" --live --from-did +1...` is the same path with a `BookRequest`. The later live target for book is `+17272701288`. Do not dial it until `{PUBLIC_BASE_URL}/health` returns HTTP 200 with this process's `serve_token`. Cap GO on this fail-loud climb. HOLD REDIAL for the live book. This tree does not place that call. `call-agent verify-quick-ask --mode live` runs the same eight checks against a real call. `call-agent verify-book --mode live` is gated the same way and still runs the stub adapters unless you change the code. Only call numbers you control until you have reviewed consent, calling-hour, and AI-disclosure rules for your destination. diff --git a/call_agent/accounts.py b/call_agent/accounts.py index f272dea..e87b3ff 100644 --- a/call_agent/accounts.py +++ b/call_agent/accounts.py @@ -22,10 +22,10 @@ ACCOUNT_SYNC_SIGNATURE_HEADER = "x-account-sync-signature" # Cap account model: `phone` is the user's own number (unique kickoff -# key). The Treena iMessage bot is shared sitewide (`LINQ_FROM_E164`), -# not a unique DID per user. `from_did` is the Telnyx caller ID: nullable, -# not unique. Kickoff still requires it until Cap names a shared Telnyx -# from-number. Do not store the shared bot on this column. +# key). The Treena iMessage bot is shared sitewide (`LINQ_FROM_E164`). +# Telnyx FROM is shared sitewide (`TELNYX_FROM_NUMBER` / +# `TELNYX_PHONE_NUMBER`). `users.from_did` is a nullable legacy column, +# not unique, not required at kickoff. Do not store the shared bot here. USERS_SCHEMA = """ CREATE TABLE IF NOT EXISTS users ( diff --git a/call_agent/cli.py b/call_agent/cli.py index a9604a1..207c13b 100644 --- a/call_agent/cli.py +++ b/call_agent/cli.py @@ -31,7 +31,12 @@ validate_linq_live_token, ) from .persistence import PersistenceConfigError -from .runtime import assert_live_dial_origins, run_call, validate_live +from .runtime import ( + assert_live_dial_origins, + resolve_telnyx_from, + run_call, + validate_live, +) from .server import probe_live_ingress, serve_in_process from .telnyx import LiveTelnyxHttp, StubTelnyxHttp, TelnyxHttp from .verify import ( @@ -472,16 +477,14 @@ def _resolve_from_did( ) -> E164: if flag: return E164(flag) - account = open_accounts(settings).get(user_id) - if account is not None and account.from_did is not None: - return account.from_did - if mode is Mode.STUB: - return STUB_FROM_DID - raise ValueError( - f"no from-DID for user {user_id}: pass --from-did or assign one with " - "`call-agent account upsert --phone --from-did " - "`. Live calls never order a number." - ) + try: + return resolve_telnyx_from(settings, live=(mode is Mode.LIVE)) + except ValueError as error: + raise ValueError( + f"no from-DID for user {user_id}: pass --from-did or set " + "TELNYX_FROM_NUMBER / TELNYX_PHONE_NUMBER. " + "Live calls never order a number." + ) from error async def _run_request( diff --git a/call_agent/config.py b/call_agent/config.py index 030a7a3..bc9ac0e 100644 --- a/call_agent/config.py +++ b/call_agent/config.py @@ -1,6 +1,6 @@ from pathlib import Path -from pydantic import Field +from pydantic import AliasChoices, Field from pydantic_settings import BaseSettings, SettingsConfigDict from .persistence import ( @@ -20,6 +20,17 @@ class Settings(BaseSettings): telnyx_connection_id: str | None = None telnyx_public_key: str | None = None telnyx_stream_codec: str = "L16" + # Shared Telnyx FROM for every Treena user (Cap). Fly live sets both + # TELNYX_FROM_NUMBER and TELNYX_PHONE_NUMBER to the same E.164. + # Do not default the digits here. + telnyx_from_number: str | None = Field( + default=None, + validation_alias=AliasChoices( + "telnyx_from_number", + "TELNYX_FROM_NUMBER", + "TELNYX_PHONE_NUMBER", + ), + ) linq_api_token: str | None = None # Shared Treena iMessage bot (Cap). One number for everyone. # Sandbox +12138653024 until ~Sep 18. Not a per-user DID. diff --git a/call_agent/kickoff.py b/call_agent/kickoff.py index 426b3a4..f424d61 100644 --- a/call_agent/kickoff.py +++ b/call_agent/kickoff.py @@ -36,7 +36,12 @@ followup_chat, ) from .persistence import PersistenceConfigError -from .runtime import normalized_public_host, run_call +from .runtime import ( + TelnyxFromConfigError, + normalized_public_host, + resolve_telnyx_from, + run_call, +) from .artifacts import artifact_dir, artifact_slug from .user_text import intake_text, not_placed_text, result_text @@ -60,10 +65,6 @@ "I need your name for the call. " "Add it in your account settings, then text me again." ) -NO_FROM_DID_TEXT = ( - "This account doesn't have a Treena calling number assigned yet. " - "Ask support to assign one, then text me again." -) _BOOK_RE = re.compile( r"^book\s+(?P\+[1-9]\d{7,14})\s+party\s+(?P\d+)\s+at\s+" @@ -155,9 +156,9 @@ class KickoffParsed: """Confirmed book/ask ready to dial. - `from_did` is the Telnyx caller ID from `users.from_did`. Not - `KickoffSession.from_did`, which is the shared Linq iMessage bot - (`LINQ_FROM_E164`). + `from_did` is the shared Telnyx FROM (`TELNYX_FROM_NUMBER` / + `TELNYX_PHONE_NUMBER`). Not `KickoffSession.from_did`, which is the + shared Linq iMessage bot (`LINQ_FROM_E164`). Not `users.from_did`. """ request: BookRequest | QuickAskRequest @@ -475,6 +476,7 @@ def claim_kickoff( if account is not None else None ), + "dial_from": str(resolve_telnyx_from(settings)), "parsed_at": datetime.now(UTC).isoformat(), } return claim_path(path, payload) @@ -647,7 +649,10 @@ def _decide( assert account is not None caller = FirstName(account.first_name or "") user_id = account.user_id - assert account.from_did is not None + try: + from_did = resolve_telnyx_from(settings) + except TelnyxFromConfigError: + return KickoffFailed("I couldn't place the call.", verb) if verb == "ask": return _decide_ask( extracted, @@ -655,7 +660,7 @@ def _decide( caller, user_id, account, - account.from_did, + from_did, confirmed=confirmed, ) return _decide_book( @@ -664,7 +669,7 @@ def _decide( caller, user_id, account, - account.from_did, + from_did, confirmed=confirmed, ) @@ -1038,8 +1043,6 @@ def _account_gate(account: UserRecord | None) -> str | None: FirstName(raw) except ValueError: return NO_NAME_TEXT - if account.from_did is None: - return NO_FROM_DID_TEXT return None diff --git a/call_agent/linq.py b/call_agent/linq.py index fe6c851..70bb7e9 100644 --- a/call_agent/linq.py +++ b/call_agent/linq.py @@ -332,7 +332,7 @@ def shared_bot_e164(settings: Settings) -> E164 | None: """Sitewide Treena iMessage bot. One number for everyone. Driven by `LINQ_FROM_E164`. Not a unique Treena DID per user and not - the Telnyx caller ID (`users.from_did`). + the Telnyx FROM (`TELNYX_FROM_NUMBER`). """ raw = (settings.linq_from_e164 or "").strip() if not raw: diff --git a/call_agent/runtime.py b/call_agent/runtime.py index ac3cd96..72dd180 100644 --- a/call_agent/runtime.py +++ b/call_agent/runtime.py @@ -607,6 +607,34 @@ def webhook_and_stream_urls(settings: Settings) -> tuple[str, str]: ) +def shared_telnyx_from(settings: Settings) -> E164 | None: + """Sitewide Telnyx FROM. One number for every Treena user. + + Driven by `TELNYX_FROM_NUMBER` or `TELNYX_PHONE_NUMBER`. Not a + unique DID per user and not the Linq iMessage bot. + """ + raw = (settings.telnyx_from_number or "").strip() + if not raw: + return None + return E164(raw) + + +def resolve_telnyx_from(settings: Settings, *, live: bool | None = None) -> E164: + from_did = shared_telnyx_from(settings) + if from_did is not None: + return from_did + is_live = settings.quick_ask_live if live is None else live + if is_live: + raise TelnyxFromConfigError( + "live dial requires TELNYX_FROM_NUMBER or TELNYX_PHONE_NUMBER" + ) + return E164("+15555550100") + + +class TelnyxFromConfigError(ValueError): + pass + + def validate_live(settings: Settings) -> None: missing: list[str] = [] if not settings.quick_ask_live: @@ -615,6 +643,8 @@ def validate_live(settings: Settings) -> None: missing.append("TELNYX_API_KEY") if not settings.telnyx_connection_id: missing.append("TELNYX_CONNECTION_ID") + if not (settings.telnyx_from_number or "").strip(): + missing.append("TELNYX_FROM_NUMBER") if not settings.openai_api_key: missing.append("OPENAI_API_KEY") if not settings.openai_responses_model: diff --git a/call_agent/user_text.py b/call_agent/user_text.py index cbe58c3..abc21f9 100644 --- a/call_agent/user_text.py +++ b/call_agent/user_text.py @@ -19,6 +19,8 @@ "PUBLIC_WEBHOOK_URL", "OPENAI_API_KEY", "TELNYX_API_KEY", + "TELNYX_FROM_NUMBER", + "TELNYX_PHONE_NUMBER", ) NOT_PLACED_TEXT = ( diff --git a/call_agent/verify_linq_call.py b/call_agent/verify_linq_call.py index 7073b16..7dfd052 100644 --- a/call_agent/verify_linq_call.py +++ b/call_agent/verify_linq_call.py @@ -25,7 +25,6 @@ CONFIRM_ASK_TEXT, DIAL_NOW_TEXT, NO_ACCOUNT_TEXT, - NO_FROM_DID_TEXT, NO_NAME_TEXT, PARTY_AND_TIME_ASK_TEXT, PHONE_ASK_TEXT, @@ -70,7 +69,7 @@ BOOK_EVENT_ID = "kickoff-book-0001" BAD_EVENT_ID = "kickoff-bad-0001" STUB_FROM = E164("+15555550100") -ACCOUNT_FROM_DID = E164("+15555550177") +SHARED_FROM_DID = E164("+12135690899") class LinqCallVerifyInput(BaseModel): @@ -94,7 +93,7 @@ class LinqCallVerifyInput(BaseModel): "call_ran", "cross_link", "same_host_dial", - "from_did_users_row", + "shared_from_did", "no_number_order", ] @@ -167,6 +166,7 @@ def verify_linq_call( _env_file=None, linq_api_token=None, linq_from_e164=str(active.from_did), + telnyx_from_number=str(SHARED_FROM_DID), call_agent_db_path=root / "ledger.sqlite3", call_agent_artifacts_dir=artifacts, hangup_trailing_silence_s=0, @@ -179,7 +179,6 @@ def verify_linq_call( phone=E164(INBOUND_SENDER), first_name="Nathan", user_id=UserId("acctnathan"), - from_did=ACCOUNT_FROM_DID, ) ).record @@ -475,12 +474,10 @@ def verify_linq_call( and isinstance(no_name, KickoffBlocked) and no_name.reply == NO_NAME_TEXT and not contains_banned_user_text(no_name.reply) - and isinstance(no_from_did, KickoffBlocked) - and no_from_did.reply == NO_FROM_DID_TEXT - and not contains_banned_user_text(no_from_did.reply) + and isinstance(no_from_did, KickoffCollecting) + and no_from_did.verb == "book" and "missing caller name" not in no_account.reply and "missing caller name" not in no_name.reply - and "missing caller name" not in no_from_did.reply and not (artifacts / "stub-call-0001").exists() ) gate_check = LinqCallVerifyCheck( @@ -616,7 +613,8 @@ def verify_linq_call( and confirm_parsed.request.user_id == nathan.user_id and account_blob.get("user_id") == str(nathan.user_id) and account_blob.get("phone") == INBOUND_SENDER - and account_blob.get("from_did") == str(ACCOUNT_FROM_DID) + and account_blob.get("from_did") is None + and kickoff_payload.get("dial_from") == str(SHARED_FROM_DID) and "first_name" not in account_blob and "name" not in account_blob ) @@ -624,15 +622,16 @@ def verify_linq_call( f"caller={confirm_parsed.request.caller} " f"request_user={confirm_parsed.request.user_id} " f"account_user={account_blob.get('user_id')} " - f"account_from={account_blob.get('from_did')}" + f"dial_from={kickoff_payload.get('dial_from')}" ) client_state = ClientState.decode(str(dial.get("client_state") or "")) from_did_ok = ( - dial.get("from") == str(ACCOUNT_FROM_DID) + dial.get("from") == str(SHARED_FROM_DID) and dial.get("from") != INBOUND_SENDER and dial.get("from") != str(active.from_did) - and confirm_parsed.from_did == ACCOUNT_FROM_DID + and confirm_parsed.from_did == SHARED_FROM_DID + and nathan.from_did is None and client_state.user_id == nathan.user_id ) from_did_detail = ( @@ -698,7 +697,7 @@ def verify_linq_call( name="same_host_dial", passed=host_ok, detail=host_detail ), LinqCallVerifyCheck( - name="from_did_users_row", + name="shared_from_did", passed=from_did_ok, detail=from_did_detail, ), diff --git a/docs/FEATURE_MAP.md b/docs/FEATURE_MAP.md index 14f17b7..00e18dd 100644 --- a/docs/FEATURE_MAP.md +++ b/docs/FEATURE_MAP.md @@ -22,6 +22,7 @@ Cap locks for surfaces this repository owns. Do not invent product. Do not `fly | hangup | shipped | Done-state + far-party. Legal terminal `record_task_result` or `end_call` owns hangup. Informal goodbye is not a hangup trigger. | `call_agent/domain.py` `_enter_farewell`. `tests/test_runtime.py` `test_done_state_hangup_skips_end_call_path_is_tool` | | `users` | shipped | Stub shipped. Website writer sot-ready (`docs/accounts.md`). Cap model: `users.phone` is the user's own number; one shared iMessage bot (`LINQ_FROM_E164`). Omit-preserve + signed `GET /accounts/dashboard` (includes `bot_number`) shipped in tree. Fly `POST /accounts/sync` and `GET /accounts/dashboard` are intended Fly routes, not accidental. Dashboard UI stays on Pages (web). Name is collected on website `/start` and that copy is in settings after signup. Kickoff never collects a name over iMessage. | `call_agent/accounts.py`. `call_agent/server.py` `accounts_sync` `accounts_dashboard`. `call-agent verify-accounts`. `docs/accounts.md` | | shared Treena iMessage bot | shipped | One sitewide bot. `LINQ_FROM_E164` is the one env key. Cap/ops sandbox `+12138653024` until ~Sep 18. Signed dashboard includes `bot_number` from that key. | `call_agent/linq.py` `shared_bot_e164`. `call_agent/dashboard.py`. `.env.example`. `docs/accounts.md`. `tests/test_accounts.py` `test_accounts_dashboard_includes_shared_bot_number` | +| shared Telnyx from | shipped | One sitewide Telnyx FROM for every user. Fly sets `TELNYX_FROM_NUMBER` and alias `TELNYX_PHONE_NUMBER` to `+12135690899`. Not hardcoded in product code. Distinct from the Linq bot. | `call_agent/config.py` `telnyx_from_number`. `call_agent/runtime.py` `resolve_telnyx_from`. `.env.example`. `docs/accounts.md`. `tests/test_kickoff.py` `test_run_kickoff_dials_from_shared_env_not_linq_line` | | friendly iMessage errors | shipped | Linq/iMessage never shows raw R2, env, or HTTP. | `call_agent/user_text.py`. `tests/test_user_text.py` | | landing unfreeze | pending web | TRE-22 unfreeze ordered. Mostly call-agent-web. This repo keeps the closed-alpha iMessage line and does not claim a public landing. | TRE-22. `docs/linq.md` | @@ -38,15 +39,16 @@ The ledger is SQLite unless `NEON_DATABASE_URL` is set. Then it is Neon. ## accounts -The `users` table is the runtime account record Fly reads. Same ledger DB as `dids` (SQLite unless `NEON_DATABASE_URL` is set). Cap model: one shared Treena iMessage bot (`LINQ_FROM_E164`); each account stores the user's own phone. Stripe is billing only. The website POSTs signed checkout/settings payloads to Fly; Fly is the sole writer. Kickoff looks up by inbound phone. The outbound Telnyx caller ID is `users.from_did` (not unique; not the iMessage bot). `BookRequest.user_id` is `users.user_id`. CLI `account upsert --from-did` seeds the Telnyx from-number. Confirm/dial never calls `ledger.provision`. Name is collected on website `/start` and that copy is in settings after signup. Kickoff reads `users.first_name`. Never collected over iMessage. Display name is `first_name` plus `prefs.full_name`. There is no `users.name` column. Fly `POST /accounts/sync` and `GET /accounts/dashboard` are intended routes on `hidden-beacon-080`, not accidental. Dashboard UI stays on Pages (web). +The `users` table is the runtime account record Fly reads. Same ledger DB as `dids` (SQLite unless `NEON_DATABASE_URL` is set). Cap model: one shared Treena iMessage bot (`LINQ_FROM_E164`); one shared Telnyx FROM (`TELNYX_FROM_NUMBER` / `TELNYX_PHONE_NUMBER`); each account stores the user's own phone. Stripe is billing only. The website POSTs signed checkout/settings payloads to Fly; Fly is the sole writer. Kickoff looks up by inbound phone. The outbound Telnyx caller ID is the shared env FROM, not a unique DID per user and not the iMessage bot. `users.from_did` is a nullable legacy column, not required at kickoff. `BookRequest.user_id` is `users.user_id`. CLI `account upsert --from-did` still seeds the legacy column. Confirm/dial never calls `ledger.provision`. Name is collected on website `/start` and that copy is in settings after signup. Kickoff reads `users.first_name`. Never collected over iMessage. Display name is `first_name` plus `prefs.full_name`. There is no `users.name` column. Fly `POST /accounts/sync` and `GET /accounts/dashboard` are intended routes on `hidden-beacon-080`, not accidental. Dashboard UI stays on Pages (web). | name | status | does | proof | | --- | --- | --- | --- | -| `users` | shipped | Stores `first_name`, the user's own `phone`, Telnyx `from_did` (not unique), minutes, retain/`prefs`, and Stripe ids on table `users`. Display name is `first_name` + `prefs.full_name`. No `users.name` column. Phone is unique. Kickoff reads this row. Name originates on website `/start` and is in settings after signup. | `call_agent/accounts.py` `USERS_SCHEMA` `UserRecord`. `tests/test_accounts.py` `test_upsert_get_by_phone_and_list` `test_users_schema_has_no_name_column`. `docs/accounts.md`. `call-agent verify-accounts` | -| `users.from_did` | shipped | Nullable E.164 Telnyx caller ID. Not unique. Rejected when equal to `phone`. Additive `ALTER TABLE` on existing SQLite and Neon `users` tables. Kickoff still requires it before dial. Cap decision: not a unique Treena iMessage DID; if Telnyx should also be one shared number, Cap/Nathan name the E.164. | `call_agent/accounts.py` `USERS_FROM_DID_MIGRATION` `_reject_from_did_equal_to_phone`. `tests/test_accounts.py` `test_existing_users_table_gains_from_did_column` `test_from_did_equal_to_phone_fails_loud` `test_two_accounts_can_share_the_same_from_did` | +| `users` | shipped | Stores `first_name`, the user's own `phone`, legacy Telnyx `from_did` (not unique, not required at kickoff), minutes, retain/`prefs`, and Stripe ids on table `users`. Display name is `first_name` + `prefs.full_name`. No `users.name` column. Phone is unique. Kickoff reads this row for name and `user_id`. Name originates on website `/start` and is in settings after signup. | `call_agent/accounts.py` `USERS_SCHEMA` `UserRecord`. `tests/test_accounts.py` `test_upsert_get_by_phone_and_list` `test_users_schema_has_no_name_column`. `docs/accounts.md`. `call-agent verify-accounts` | +| `users.from_did` | shipped | Nullable legacy E.164. Not unique. Rejected when equal to `phone`. Additive `ALTER TABLE` on existing SQLite and Neon `users` tables. Kickoff does not require it. Telnyx FROM is the shared env. | `call_agent/accounts.py` `USERS_FROM_DID_MIGRATION` `_reject_from_did_equal_to_phone`. `tests/test_accounts.py` `test_existing_users_table_gains_from_did_column` `test_from_did_equal_to_phone_fails_loud` `test_two_accounts_can_share_the_same_from_did` | | `shared Treena iMessage bot` | shipped | One sitewide bot. `LINQ_FROM_E164` is the one env/config key (sandbox `+12138653024` until ~Sep 18). Signed `GET /accounts/dashboard` includes `bot_number` from that key (`null` when unset). Not stored per user. | `call_agent/linq.py` `shared_bot_e164`. `call_agent/dashboard.py` `build_account_dashboard`. `.env.example`. `tests/test_accounts.py` `test_accounts_dashboard_includes_shared_bot_number`. `tests/test_messaging.py` `test_shared_bot_e164_is_linq_from_and_not_per_user` | +| `shared Telnyx from` | shipped | One sitewide Telnyx FROM. `TELNYX_FROM_NUMBER` with alias `TELNYX_PHONE_NUMBER`. Cap/ops `+12135690899`. Not hardcoded in `call_agent/*.py`. Stub without env uses `+15555550100`. Live without env fails loud (`I couldn't place the call.`). Distinct from `LINQ_FROM_E164`. | `call_agent/config.py` `telnyx_from_number`. `call_agent/runtime.py` `shared_telnyx_from` `resolve_telnyx_from`. `call_agent/kickoff.py` `_decide`. `.env.example`. `tests/test_kickoff.py` `test_run_kickoff_dials_from_shared_env_not_linq_line` `tests/test_cli.py` `test_book_stub_uses_shared_telnyx_from_not_users_row` | | `open_accounts` | shipped | SQLite `Accounts` when `NEON_DATABASE_URL` is unset, `NeonAccounts` when set. Same ledger config as `open_ledger`. | `call_agent/accounts.py` `open_accounts`. `tests/test_accounts.py` `test_open_accounts_unset_returns_sqlite` `test_neon_accounts_roundtrip_through_sqlite_bridge` | -| `account upsert` | shipped | Seeds or updates a `users` row (`--phone` = user's own number, optional `--name`, `--from-did` Telnyx caller ID, `--minutes`, `--retain`, `--user`, `--plan`). Prints the JSON record. No HMAC. Omitted `--from-did`, `--name`, prefs, and `minutes_used` keep existing values. | `call_agent/cli.py` `account_upsert`. `tests/test_accounts.py` `test_cli_account_upsert_get_list` `test_upsert_merges_from_did_when_omitted` `test_cli_account_upsert_omitted_name_preserves`. `.cursor/skills/verify-call-agent/features/accounts.md` | +| `account upsert` | shipped | Seeds or updates a `users` row (`--phone` = user's own number, optional `--name`, `--from-did` legacy column, `--minutes`, `--retain`, `--user`, `--plan`). Prints the JSON record. No HMAC. Omitted `--from-did`, `--name`, prefs, and `minutes_used` keep existing values. Kickoff does not read `--from-did` for Telnyx. | `call_agent/cli.py` `account_upsert`. `tests/test_accounts.py` `test_cli_account_upsert_get_list` `test_upsert_merges_from_did_when_omitted` `test_cli_account_upsert_omitted_name_preserves`. `.cursor/skills/verify-call-agent/features/accounts.md` | | `account get` | shipped | Prints the JSON `users` row for `--phone`, including `from_did`. Exit 1 when missing. | `call_agent/cli.py` `account_get`. `tests/test_accounts.py` `test_cli_account_get_missing_exits_one` | | `account list` | shipped | Prints every `users` row as USER, PHONE, NAME, FROM_DID, MINUTES, RETAIN. | `call_agent/cli.py` `account_list`. `tests/test_accounts.py` `test_cli_account_upsert_get_list` | | `POST /accounts/sync` | shipped | Intended Fly route on `hidden-beacon-080`, not accidental. HMAC-SHA256 of the raw body with `ACCOUNT_SYNC_SECRET` (`X-Account-Sync-Signature: sha256=`). Fly upserts `users`. Missing secret or bad signature is 401. Bad JSON is 400. Phone collision across Stripe customers is 409. Older `stripe_event_created` is skipped. Omitted `from_did`, `first_name`, `prefs`, and `minutes_used` keep the existing values. Explicit `minutes_used` `0` writes 0. Stripe webhooks without a name do not wipe `first_name`. This tree does not `fly deploy`. | `call_agent/server.py` `accounts_sync`. `call_agent/accounts.py` `verify_account_sync_signature` `upsert_account`. `tests/test_accounts.py` `test_accounts_sync_upserts_with_hmac` `test_accounts_sync_without_from_did_preserves_seeded_did` `test_accounts_sync_without_name_prefs_or_minutes_used_preserves`. `call-agent verify-accounts` `sync_hmac`. `docs/accounts.md` | @@ -125,13 +127,13 @@ Closed-alpha testers text Treena on iMessage. FastAPI on Fly owns BOOK and media | name | status | does | proof | | --- | --- | --- | --- | | `linq-to-call` | shipped | Stub-proven iMessage book: natural first text with party + time → Treena asks only for the restaurant phone number. If time is missing, Treena asks for time (or party+time) before the phone ask; `at ` is not a time; "2 people" alone does not unlock the phone ask. Then the number → Treena asks `Okay, I'm going to call this number. Do you confirm? Reply "approve"` → user says confirm / approve / yes → Treena texts `Okay, I'm going to call them now.` → 202 `{ok, kicked_off, event_id}` and `BackgroundTasks` `run_kickoff`. Collect and confirm reply 200. The first message never dials, even when it already includes +E164 plus party/time. Non-kickoff chat still echoes `Received: {text}`. Cap recorded `live-prove: PASS (#34)`. qa owns live-prove sign-off (TRE-19). This tree does not redial. | `call_agent/server.py` `process_linq_webhook`. `call_agent/kickoff.py` `intake_kickoff` `run_kickoff` `KickoffSession`. `tests/test_messaging.py` `test_linq_webhook_two_step_collects_then_dials` `test_linq_webhook_first_message_never_dials`. `tests/test_kickoff.py` `test_at_restaurant_does_not_invent_time` `test_party_only_followup_asks_for_time_not_phone`. `.cursor/skills/verify-call-agent/features/linq-to-call.md` | -| `linq-to-call --live` | gated | Fly FastAPI live needs `QUICK_ASK_LIVE=1`, Telnyx and OpenAI keys, `PUBLIC_BASE_URL` = `PUBLIC_WEBHOOK_URL` = `https://hidden-beacon-080.fly.dev`, `LINQ_API_TOKEN`, `LINQ_FROM_E164` (shared bot `+12138653024` until ~Sep 18), `LINQ_WEBHOOK_SIGNING_SECRET`, `NEON_DATABASE_URL` (durable `users`). Host header must match `PUBLIC_BASE_URL`. A tester needs a `users` row with `first_name` and Telnyx `from_did` (CLI `account upsert --from-did` or `POST /accounts/sync`). Cap recorded `live-prove: PASS (#34)`. qa owns live-prove sign-off (TRE-19). This tree does not redial. | `call_agent/kickoff.py` `kickoff_host_mismatch`. `call_agent/runtime.py` `assert_live_dial_origins`. `tests/test_messaging.py` `test_linq_webhook_live_host_guard_does_not_dial`. `tests/test_kickoff.py` `test_run_kickoff_live_preflight_fails_loud_without_sockets` | +| `linq-to-call --live` | gated | Fly FastAPI live needs `QUICK_ASK_LIVE=1`, Telnyx and OpenAI keys, `TELNYX_FROM_NUMBER` / `TELNYX_PHONE_NUMBER` (Cap `+12135690899`), `PUBLIC_BASE_URL` = `PUBLIC_WEBHOOK_URL` = `https://hidden-beacon-080.fly.dev`, `LINQ_API_TOKEN`, `LINQ_FROM_E164` (shared bot `+12138653024` until ~Sep 18), `LINQ_WEBHOOK_SIGNING_SECRET`, `NEON_DATABASE_URL` (durable `users`). Host header must match `PUBLIC_BASE_URL`. A tester needs a `users` row with `first_name` (CLI `account upsert` or `POST /accounts/sync`). Kickoff does not need `users.from_did`. Cap recorded `live-prove: PASS (#34)`. qa owns live-prove sign-off (TRE-19). This tree does not redial. | `call_agent/kickoff.py` `kickoff_host_mismatch`. `call_agent/runtime.py` `assert_live_dial_origins` `resolve_telnyx_from`. `tests/test_messaging.py` `test_linq_webhook_live_host_guard_does_not_dial`. `tests/test_kickoff.py` `test_run_kickoff_live_preflight_fails_loud_without_sockets` | | `parse_kickoff` | shipped | Thin natural intake. Book intent collects party and time from the first text when they are present, then asks only for the restaurant phone number. Missing `time_text` asks for time (or party+time together) and never advances to phone collect or confirm. `_extract_time` keeps a clock / am/pm / noon span, not `at `. After the number, intake waits for confirm/approve/yes. Ask intent still maps `ask <+E164> hours\|stock \|aisle ` and still waits for confirm before `QuickAskRequest`. Caller name comes from the `users` row (website `/start` → settings after signup), never from iMessage `name` and never from `LINQ_KICKOFF_CALLER`. A complete first message that includes +E164 never becomes `KickoffParsed` and never dials. Other text stays on the echo path. | `call_agent/kickoff.py` `intake_kickoff` `parse_kickoff` `_decide_book` `_extract_time`. `tests/test_kickoff.py` `test_parse_natural_book_asks_only_for_phone` `test_at_restaurant_does_not_invent_time` `test_name_token_in_text_is_ignored` | | `kickoff account lookup` | shipped | `process_linq_webhook` looks up `users` by inbound phone (`InboundMessage.sender`). `BookRequest.caller` is that row's first name. `BookRequest.user_id` is that row's `user_id`. `kickoff.json` records `account.user_id`, phone, `from_did`, and minutes, not the name. | `call_agent/server.py` `process_linq_webhook`. `call_agent/kickoff.py` `claim_kickoff`. `call_agent/verify_linq_call.py` `account_lookup`. `tests/test_kickoff.py` `test_parse_uses_account_name_when_text_omits_name` | -| `kickoff from-DID` | shipped | Confirm/dial uses `users.from_did` as `DialSpec.from_did`. `run_call` takes a required `from_did` kwarg. Never `ledger.provision` / `search_and_order_number` / `POST /v2/number_orders`. Linq replies use the shared bot (`LINQ_FROM_E164`), not this field. | `call_agent/runtime.py` `run_call`. `call_agent/kickoff.py` `KickoffParsed.from_did`. `tests/test_kickoff.py` `test_run_kickoff_dials_from_users_row_not_linq_line`. `tests/test_runtime.py` `test_run_call_never_orders_a_number` | +| `kickoff from-DID` | shipped | Confirm/dial uses `resolve_telnyx_from` (`TELNYX_FROM_NUMBER` / `TELNYX_PHONE_NUMBER`) as `DialSpec.from_did`. `run_call` takes a required `from_did` kwarg. Never `ledger.provision` / `search_and_order_number` / `POST /v2/number_orders`. Kickoff ignores `users.from_did`. Linq replies use the shared bot (`LINQ_FROM_E164`). | `call_agent/runtime.py` `run_call` `resolve_telnyx_from`. `call_agent/kickoff.py` `KickoffParsed.from_did`. `tests/test_kickoff.py` `test_run_kickoff_dials_from_shared_env_not_linq_line`. `tests/test_runtime.py` `test_run_call_never_orders_a_number` | | `no-account reply` | shipped | Unknown inbound phone texts a plain account-settings line, not `Call failed:`. No `kickoff.json`, no dial. | `call_agent/kickoff.py` `NO_ACCOUNT_TEXT` `KickoffBlocked`. `tests/test_kickoff.py` `test_no_account_replies_plain_and_does_not_dial`. `tests/test_messaging.py` `test_linq_webhook_no_account_replies_plain_and_does_not_dial` | | `no-name reply` | shipped | A `users` row with no first name texts a plain account-settings line, not `Call failed: missing caller name`. No dial. | `call_agent/kickoff.py` `NO_NAME_TEXT`. `tests/test_kickoff.py` `test_no_name_replies_plain_and_does_not_dial` `test_no_missing_caller_name_path` | -| `no-from-did reply` | shipped | A `users` row with no `from_did` texts a plain support line. Never `Call failed:`. Does not buy a number. | `call_agent/kickoff.py` `NO_FROM_DID_TEXT`. `tests/test_kickoff.py` `test_no_from_did_replies_plain_and_does_not_dial`. `tests/test_messaging.py` `test_linq_webhook_no_from_did_replies_plain_and_does_not_dial` | +| `no-from-did reply` | not shipped | Kickoff no longer blocks on empty `users.from_did`. A row with a first name still collects. Missing live `TELNYX_FROM_NUMBER` fails with `I couldn't place the call.` and does not buy a number. | `call_agent/kickoff.py` `_decide`. `tests/test_kickoff.py` `test_no_from_did_still_collects_and_does_not_dial`. `tests/test_messaging.py` `test_linq_webhook_without_users_from_did_still_collects` | | `no number_orders at dial` | shipped | Confirm/dial source (`runtime.py`, `kickoff.py`, `server.py`) has no `provision(`, `search_and_order_number`, `number_orders`, or `open_ledger`. Stub `dids` stays empty after kickoff dial. | `call_agent/verify_linq_call.py` `no_number_order`. `tests/test_runtime.py` `test_dial_path_source_has_no_provision` | | `pre-call collect` | shipped | Pending book/ask fields persist as `linq/pending/.json` under artifacts (same Linq artifact volume as inbound/kickoff). Not mid-call: `KickoffSession.ask` stays reserved. Missing time or party gets a natural clarification before the phone ask. A first reservation without party or time asks for both. A party-only follow-up ("2 people") asks for time, not the restaurant number. `at ` is not a time. A follow-up that is not the asked field texts a plain intake line (`intake_text`) and does not dial. | `call_agent/kickoff.py` `PendingCollect` `save_pending_collect` `intake_kickoff` `_decide_book` `_extract_time`. `call_agent/user_text.py` `intake_text`. `tests/test_kickoff.py` `test_pending_collect_roundtrip_on_chat` `test_missing_time_asks_for_time_not_phone` `test_party_only_followup_asks_for_time_not_phone` `test_poke_palace_collects_time_before_phone`. `tests/test_messaging.py` `test_linq_webhook_bad_phone_followup_fails_and_does_not_dial` | | `pre-call confirm` | shipped | After Treena has the restaurant number, pending `waiting_for` is `confirm` on the same `linq/pending/.json`. Treena texts `Okay, I'm going to call this number. Do you confirm? Reply "approve"`. User must say confirm / approve / yes. Then Treena texts `Okay, I'm going to call them now.` and the existing 202 `BackgroundTasks` `run_kickoff` dials. A non-affirmative texts a plain intake line and does not dial. First message never dials. | `call_agent/kickoff.py` `CONFIRM_ASK_TEXT` `DIAL_NOW_TEXT` `intake_kickoff`. `tests/test_kickoff.py` `test_phone_followup_asks_to_confirm` `test_approve_after_phone_completes_book_request` `test_first_message_never_dials`. `tests/test_messaging.py` `test_linq_webhook_first_message_never_dials`. `call_agent/verify_linq_call.py` `confirm_before_dial` | @@ -139,7 +141,7 @@ Closed-alpha testers text Treena on iMessage. FastAPI on Fly owns BOOK and media | friendly iMessage errors | shipped | Linq/iMessage never shows raw R2, env, or HTTP. Successful talk never says the call was not placed. Intake failures are plain reasons. | `call_agent/user_text.py`. `call_agent/kickoff.py` `failed_reply_text` `kickoff_host_mismatch` `run_kickoff`. `tests/test_user_text.py` `test_banned_tokens_only_live_in_the_banned_list`. `tests/test_kickoff.py` `test_run_kickoff_publish_failure_keeps_booked_reply` | | `linq ↔ call cross-link` | shipped | `linq//inbound.json`, `kickoff.json`, `replies.jsonl`, `call.json`. Call run dir `/linq.json` points back at the event. | `call_agent/kickoff.py` `write_call_cross_links`. `tests/test_kickoff.py` `test_run_kickoff_cross_links_and_replies` | | `kickoff dedupe` | shipped | Exclusive `kickoff.json` create on `event_id`. Retry returns `{ok:true, duplicate:true}` and does not dial again. | `call_agent/linq.py` `claim_path`. `call_agent/server.py` `process_linq_webhook`. `tests/test_messaging.py` `test_linq_webhook_kickoff_duplicate_does_not_redial` | -| `verify-linq-call` | shipped | Fourteen stub checks: parse book (natural first text with party+time asks for the number, no dial), collect time (reservation at a place name asks for party+time; "2 people" asks for time, not the phone), collect phone (number follow-up asks to confirm, no dial), confirm before dial (approve then `BookRequest`; first message never dials), parse quick-ask, fail loud, account lookup (inbound phone → `users` row name, `user_id`, and `from_did`), account gate (no row / no name / no from-DID reply plain, never `Call failed: missing caller name`), kickoff reply, call ran, cross-link, same-host dial, from-DID from the users row (not the personal phone, not the Linq line), no number_orders on dial. Fails if dial happens before confirm. CI runs the CLI verb. | `call_agent/verify_linq_call.py` `verify_linq_call`. `call_agent/cli.py` `verify_linq_call_command`. `.github/workflows/ci.yml`. `tests/test_cli.py` `test_verify_linq_call_stub_exits_zero`. `.cursor/skills/verify-call-agent/features/verify-linq-call.md` | +| `verify-linq-call` | shipped | Fourteen stub checks: parse book (natural first text with party+time asks for the number, no dial), collect time (reservation at a place name asks for party+time; "2 people" asks for time, not the phone), collect phone (number follow-up asks to confirm, no dial), confirm before dial (approve then `BookRequest`; first message never dials), parse quick-ask, fail loud, account lookup (inbound phone → `users` row name and `user_id`; `kickoff.json` `dial_from` is the shared FROM), account gate (no row / no name reply plain; missing `users.from_did` still collects; never `Call failed: missing caller name`), kickoff reply, call ran, cross-link, same-host dial, shared FROM (`shared_from_did`: not the personal phone, not the Linq line), no number_orders on dial. Fails if dial happens before confirm. CI runs the CLI verb. | `call_agent/verify_linq_call.py` `verify_linq_call`. `call_agent/cli.py` `verify_linq_call_command`. `.github/workflows/ci.yml`. `tests/test_cli.py` `test_verify_linq_call_stub_exits_zero`. `.cursor/skills/verify-call-agent/features/verify-linq-call.md` | ## verify-call-agent @@ -153,7 +155,7 @@ The Cursor skill drives the CLI verbs in stub mode and never sets live keys or N | `.cursor/skills/verify-call-agent/features/linq-inbound.md` | shipped | Maps closed-alpha iMessage Worker forward at `https://call-agent-webhooks.usetreena.workers.dev/linq/webhook`, MessagingPort ack on `message.received`, and inbound artifacts. SMS stays off. | `.cursor/skills/verify-call-agent/features/linq-inbound.md` | | `.cursor/skills/verify-call-agent/features/verify-webhooks.md` | shipped | Covers the Worker inbox stub checks, `--url` wiring, and exit codes. | `.cursor/skills/verify-call-agent/features/verify-webhooks.md` | | `.cursor/skills/verify-call-agent/features/did-provision.md` | shipped | Covers first provision, repeat provision, a second user, and `did list`. Ops verb only; confirm/dial does not provision. | `.cursor/skills/verify-call-agent/features/did-provision.md` | -| `.cursor/skills/verify-call-agent/features/accounts.md` | shipped | Covers `account upsert --from-did` / `get` / `list` seed, omit-preserve on sync (`first_name`, `prefs`, `minutes_used`, `from_did`), signed `GET /accounts/dashboard` (`bot_number` from `LINQ_FROM_E164`), `/start` name in settings, Cap phone = user's number, and that kickoff reads `users.from_did` for Telnyx. | `.cursor/skills/verify-call-agent/features/accounts.md` | +| `.cursor/skills/verify-call-agent/features/accounts.md` | shipped | Covers `account upsert --from-did` / `get` / `list` seed, omit-preserve on sync (`first_name`, `prefs`, `minutes_used`, `from_did`), signed `GET /accounts/dashboard` (`bot_number` from `LINQ_FROM_E164`), `/start` name in settings, Cap phone = user's number, and that kickoff dials from shared `TELNYX_FROM_NUMBER`, not `users.from_did`. | `.cursor/skills/verify-call-agent/features/accounts.md` | | `.cursor/skills/verify-call-agent/features/verify-accounts.md` | shipped | Covers the five-check lever (`cli_roundtrip`, `omit_preserve`, `sync_hmac`, `dashboard_get` including `bot_number`, `no_name_column`), in-process HMAC, and that the lever never POSTs Fly. | `.cursor/skills/verify-call-agent/features/verify-accounts.md` | | `.cursor/skills/verify-call-agent/features/quick-ask.md` | shipped | Covers hours, stock, aisle, missing `--item`, artifacts, and the live gate. | `.cursor/skills/verify-call-agent/features/quick-ask.md` | | `.cursor/skills/verify-call-agent/features/gpt-live-prompt.md` | shipped | Covers the live prompt lines in `session_start.json`, Responses delegation, `speak_first` append frames, and Realtime reject. | `.cursor/skills/verify-call-agent/features/gpt-live-prompt.md` | @@ -162,7 +164,7 @@ The Cursor skill drives the CLI verbs in stub mode and never sets live keys or N | `.cursor/skills/verify-call-agent/features/verify-book.md` | shipped | Covers the ten stub checks, theater fail, decline, never-resumes stall, media-drop hangup, the live gate, and exit codes. | `.cursor/skills/verify-call-agent/features/verify-book.md` | | `.cursor/skills/verify-call-agent/features/verify-message.md` | shipped | Covers the six MessagingPort checks, inbound reply artifacts, and the register-webhook gate. | `.cursor/skills/verify-call-agent/features/verify-message.md` | | `.cursor/skills/verify-call-agent/features/linq-to-call.md` | shipped | Covers iMessage natural collect + confirm-before-dial, 202 async B, result reply, and cross-links. | `.cursor/skills/verify-call-agent/features/linq-to-call.md` | -| `.cursor/skills/verify-call-agent/features/verify-linq-call.md` | shipped | Covers the fourteen stub checks, including collect_time, collect_phone, confirm_before_dial, account_lookup, account_gate, from_did_users_row, no_number_order, defaults, and exit codes. | `.cursor/skills/verify-call-agent/features/verify-linq-call.md` | +| `.cursor/skills/verify-call-agent/features/verify-linq-call.md` | shipped | Covers the fourteen stub checks, including collect_time, collect_phone, confirm_before_dial, account_lookup, account_gate, shared_from_did, no_number_order, defaults, and exit codes. | `.cursor/skills/verify-call-agent/features/verify-linq-call.md` | | `.cursor/skills/verify-call-agent/features/verify-persistence.md` | shipped | Covers the four stub checks, the SQLite and local fallback, and the misconfig exit. | `.cursor/skills/verify-call-agent/features/verify-persistence.md` | | `.cursor/skills/verify-call-agent/features/privacy-wipe.md` | shipped | Covers stub delete-at-hangup, retain via env or request flag, live Fly prove-retain override OFF, and that wipe-unless-opted-in is the live default. Privacy page stays not shipped. | `.cursor/skills/verify-call-agent/features/privacy-wipe.md` | | `.cursor/skills/verify-call-agent/scripts/verify_stub.sh` | shipped | Runs one isolated stub drive of those recipes, including `verify-book`, `verify-message`, `verify-linq-call`, `verify-webhooks`, `verify-persistence`, `verify-accounts`, and `linq register-webhook` exit 2 without a token. | `.cursor/skills/verify-call-agent/scripts/verify_stub.sh` | diff --git a/docs/WAVE1_HOLD_FEATURE_MAP_PLAN.md b/docs/WAVE1_HOLD_FEATURE_MAP_PLAN.md index 0957830..6b23445 100644 --- a/docs/WAVE1_HOLD_FEATURE_MAP_PLAN.md +++ b/docs/WAVE1_HOLD_FEATURE_MAP_PLAN.md @@ -16,6 +16,7 @@ This wave is docs + maps + verifiers. No Fly deploy. No L1 redial. No wave 2. No | Hangup | Shipped: done-state + far-party, not phrase-gated. | | Hangup wipe | Stub default wipe shipped. Live Fly `hidden-beacon-080` image still sha `ebcf350` (PR #35); secrets release **v22** only. `RETAIN_CALL_ARTIFACTS` unset (`None`). `settings.retain_call_artifacts=False`. `should_retain(request=None)=False`. **Prove-retain override OFF, wipe-unless-opted-in is the live default.** Account opt-in (`users.retain_call_artifacts`) still wins. Health 200. No Fly deploy. No L1 redial. | | Name | Website `/start` collects the name. After signup that copy is in settings. Kickoff reads `users.first_name`. Never collect a name over iMessage. | +| Shared Telnyx FROM | Cap GO. Fly `TELNYX_FROM_NUMBER` / `TELNYX_PHONE_NUMBER` = `+12135690899`. Not hardcoded in product code. Kickoff does not require `users.from_did`. Linq bot stays `LINQ_FROM_E164=+12138653024`. | | `verify-accounts` | CloudAgent-runnable CLI verb. Not pytest-only. | | `AGENTS.md` | Yes, this repo. | | Worker 401 | Unset `TELNYX_PUBLIC_KEY` → 401 `missing_public_key`. Key present but signature missing/wrong → 401 `bad_signature`. Do not guess which secret is set live. | @@ -26,7 +27,6 @@ This wave is docs + maps + verifiers. No Fly deploy. No L1 redial. No wave 2. No - Website writer live (TRE-20) and dashboard GO on Pages. - Live book redial (HOLD REDIAL). - Minutes ledger (not shipped). -- Telnyx caller ID: kickoff still requires per-account `users.from_did` (not unique). Cap said no per-user Treena DID. Do not invent a shared Telnyx from-number. The Linq sandbox bot `+12138653024` is the iMessage bot, not Telnyx `from`. - Stripe live flip (Cap/ops blocked on Nathan live keys). Not this climb. ## Out of this wave diff --git a/docs/accounts.md b/docs/accounts.md index 80fd9a4..e2c2905 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -6,10 +6,11 @@ This is the contract for the follow-up **call-agent-web** PR. This repository (` 1. One shared Treena iMessage bot for everyone. People text `LINQ_FROM_E164` (Linq sandbox `+12138653024` until ~Sep 18). Not a unique Treena DID per user. 2. Each account stores the user's own phone on `users.phone` (the inbound iMessage sender). That is the kickoff key. +3. One shared Telnyx FROM for everyone. Fly sets `TELNYX_FROM_NUMBER` and alias `TELNYX_PHONE_NUMBER` to `+12135690899`. Not hardcoded in product code. Not the Linq bot and not the user's phone. -The website "Text Treena" number is that one env key. Signed `GET /accounts/dashboard` includes `bot_number` from it (`null` when unset). Do not invent a second bot env var. +The website "Text Treena" number is `LINQ_FROM_E164`. Signed `GET /accounts/dashboard` includes `bot_number` from it (`null` when unset). Do not invent a second bot env var. -Telnyx outbound caller ID is a separate field: `users.from_did` (nullable, **not unique**). Kickoff still requires it before dial. Cap has not named a shared Telnyx from-number. Do not use the Linq bot as Telnyx `from`, and do not use the user's phone. See **Cap decision** below. +Kickoff does **not** require `users.from_did`. That column stays nullable/legacy (not unique). See **Shared Telnyx FROM** below. Name is collected on website `/start`. After signup that copy is in account settings. Fly `users.first_name` is that name (website POSTs `/accounts/sync`). Kickoff never collects a name over iMessage. @@ -19,7 +20,7 @@ This tree does not contain `call-agent-web`. Builder’s second climb implements ## Runtime account -Fly reads `users` (same DB as `dids`: SQLite on a laptop, Neon when `NEON_DATABASE_URL` is set). Kickoff looks up `phone = inbound iMessage sender`. `BookRequest.caller` is `users.first_name`. `BookRequest.user_id` is `users.user_id`. The outbound Telnyx caller ID is `users.from_did` (not unique; not the shared iMessage bot). Confirm/dial never calls `ledger.provision` and never `POST /v2/number_orders`. `LINQ_KICKOFF_USER_ID` is gone. `kickoff.json` records `account.user_id`, phone, `from_did`, and minutes. +Fly reads `users` (same DB as `dids`: SQLite on a laptop, Neon when `NEON_DATABASE_URL` is set). Kickoff looks up `phone = inbound iMessage sender`. `BookRequest.caller` is `users.first_name`. `BookRequest.user_id` is `users.user_id`. The outbound Telnyx caller ID is `resolve_telnyx_from` (`TELNYX_FROM_NUMBER` / `TELNYX_PHONE_NUMBER`), not `users.from_did`, not the shared iMessage bot, and not `ledger.provision`. Confirm/dial never calls `ledger.provision` and never `POST /v2/number_orders`. `LINQ_KICKOFF_USER_ID` is gone. `kickoff.json` records `account.user_id`, phone, legacy `from_did`, minutes, and `dial_from` (the shared FROM). Columns (see `USERS_SCHEMA` in `call_agent/accounts.py`): @@ -28,7 +29,7 @@ Columns (see `USERS_SCHEMA` in `call_agent/accounts.py`): | `user_id` | lowercase `UserId` (uuid4 hex on insert if omitted). Kickoff `BookRequest.user_id`. | | `phone` | E.164, unique, kickoff key. The user's own number on the account. Not the shared Treena iMessage bot and not the Telnyx from-number. | | `first_name` | nullable. Missing → Treena texts a plain account-settings line. Never collected in iMessage. Omitted on upsert keeps the existing value so Stripe webhooks without a name cannot wipe it. | -| `from_did` | nullable E.164 Telnyx caller ID. **Not unique.** Rejected when equal to `phone`. Additive `ALTER TABLE` on existing SQLite and Neon `users` tables. Omitted on upsert keeps the existing value. Not the iMessage bot (`LINQ_FROM_E164`). | +| `from_did` | nullable legacy E.164. **Not unique.** Not required at kickoff. Rejected when equal to `phone`. Additive `ALTER TABLE` on existing SQLite and Neon `users` tables. Omitted on upsert keeps the existing value. Not the iMessage bot (`LINQ_FROM_E164`) and not the live Telnyx FROM. | | `stripe_customer_id` | unique billing link | | `plan` | Stripe/TRE-20 plan metadata | | `minutes_included` / `minutes_used` | recorded; kickoff does not refuse at zero or decrement. Omitted `minutes_used` keeps the existing value. Explicit `0` writes 0. | @@ -62,7 +63,7 @@ Body: } ``` -`phone` is required. `first_name` may be omitted (row exists, kickoff will ask them to set it in account settings). Omitted `first_name` keeps the existing value so a later Stripe webhook without a name cannot wipe it. `from_did` is optional. The website is not expected to send `from_did` this climb; omitted `from_did` keeps the ops-seeded value so Stripe sync cannot wipe it. Omitted `prefs` keeps the existing object. Omitted `minutes_used` keeps the existing count; explicit `0` writes 0. `stripe_event_created` is ISO-8601. Equal or older than `stripe_synced_at` returns `{"ok": true, "skipped": true, "user_id": "..."}`. +`phone` is required. `first_name` may be omitted (row exists, kickoff will ask them to set it in account settings). Omitted `first_name` keeps the existing value so a later Stripe webhook without a name cannot wipe it. `from_did` is optional (legacy). The website is not expected to send `from_did`; omitted `from_did` keeps the existing value. Kickoff dials from `TELNYX_FROM_NUMBER`, not this field. Omitted `prefs` keeps the existing object. Omitted `minutes_used` keeps the existing count; explicit `0` writes 0. `stripe_event_created` is ISO-8601. Equal or older than `stripe_synced_at` returns `{"ok": true, "skipped": true, "user_id": "..."}`. Status: @@ -127,10 +128,10 @@ Retry on 5xx (Fly down). Do not fall back to writing Neon from the website. ## L1 before the website PR ```bash -call-agent account upsert --phone +17272701288 --name Nathan --from-did +12135690899 +call-agent account upsert --phone +17272701288 --name Nathan ``` -That writes the same table. `--phone` is the user's own number (linked iMessage sender). `--from-did` is the Telnyx caller ID, not the shared bot people text. The bot is `LINQ_FROM_E164=+12138653024`. Do not pass the same E.164 as `--phone` and `--from-did`. Set Fly `ACCOUNT_SYNC_SECRET` when the website PR is ready. This climb does not `fly deploy`. +That writes the same table. `--phone` is the user's own number (linked iMessage sender). Kickoff does not need `--from-did`. Set Fly `TELNYX_FROM_NUMBER` and `TELNYX_PHONE_NUMBER` to `+12135690899`. The bot people text is `LINQ_FROM_E164=+12138653024`. Do not pass the same E.164 as `--phone` and `--from-did` if you still seed the legacy column. Set Fly `ACCOUNT_SYNC_SECRET` when the website PR is ready. This climb does not `fly deploy`. ## Kickoff copy @@ -142,14 +143,12 @@ Row exists but `first_name` is empty: `I need your name for the call. Add it in your account settings, then text me again.` -Row exists but `from_did` is empty: +None of these is prefixed `Call failed:`. There is no `Call failed: missing caller name`. Empty `users.from_did` does not block. Live missing `TELNYX_FROM_NUMBER` texts `I couldn't place the call.` `LINQ_KICKOFF_CALLER` and `LINQ_KICKOFF_USER_ID` are gone. -`This account doesn't have a Treena calling number assigned yet. Ask support to assign one, then text me again.` +## Shared Telnyx FROM (Cap GO) -None of these is prefixed `Call failed:`. There is no `Call failed: missing caller name`. `LINQ_KICKOFF_CALLER` and `LINQ_KICKOFF_USER_ID` are gone. +One sitewide Telnyx caller ID for every Treena user. Fly live sets both `TELNYX_FROM_NUMBER` and `TELNYX_PHONE_NUMBER` to `+12135690899`. Settings read those env keys (no default digits in product code). Stub without the env uses `+15555550100`. Live without the env fails before dial. -## Cap decision (Telnyx from-DID) +Kickoff does **not** require `users.from_did`. That column stays nullable/legacy (not unique, rejected when equal to `phone`) so existing rows and CLI `--from-did` still work. `run_call` still takes a required `from_did` kwarg; kickoff fills it from the shared env. `did provision` still assigns unique rows in the separate `dids` table (ops verb; confirm/dial never uses it). -Schema does **not** unique-constrain `users.from_did`. Two accounts may share one Telnyx from-number. Kickoff still **requires** a non-null `users.from_did` before dial, and `run_call` still takes a required `from_did` kwarg. `did provision` still assigns unique rows in the separate `dids` table (ops verb; confirm/dial never uses it). - -Cap said no per-user Treena DID. If Telnyx should also use one shared number, Cap/Nathan must name that E.164. Do not invent it. Do not reuse the Linq sandbox bot as Telnyx `from` (Linq line and Telnyx from stay distinct). Stripe live flip is not this climb. +Do not reuse the Linq sandbox bot as Telnyx `from` (`LINQ_FROM_E164=+12138653024` is iMessage only). Do not use the user's phone as Telnyx `from`. Stripe live flip is not this climb. diff --git a/docs/linq.md b/docs/linq.md index 296d1e5..c6e6671 100644 --- a/docs/linq.md +++ b/docs/linq.md @@ -9,7 +9,7 @@ Closed-alpha testers text Treena on this Linq path. The sending line is the one Read these from env. Do not hardcode the sandbox line as the only source. `.env.example` documents the Cap-confirmed sandbox value. - `LINQ_API_TOKEN`. Bearer token. Required for live send and `linq register-webhook`. -- `LINQ_FROM_E164`. The one shared Treena iMessage bot (Text Treena). Sitewide. Not a per-user DID and not Telnyx `users.from_did`. +- `LINQ_FROM_E164`. The one shared Treena iMessage bot (Text Treena). Sitewide. Not a per-user DID and not Telnyx FROM (`TELNYX_FROM_NUMBER`). - `LINQ_WEBHOOK_SIGNING_SECRET`. Standard Webhooks secret (`whsec_...`) from the create-subscription response. Lives on Builder's box. Not in this VM. - `LINQ_WEBHOOK_URL` or `PUBLIC_WEBHOOK_URL`. Always-on HTTPS URL for inbound. This is the Cloudflare Workers target, not `PUBLIC_BASE_URL`. @@ -36,7 +36,7 @@ User: approve Treena: Okay, I'm going to call them now. ``` -Party and time come from the first text when present. If time is missing, Treena asks for time (or party and time) before the restaurant phone number. `at ` is not a time. "2 people" alone does not unlock the phone ask. After the number she always asks to confirm before dial. Confirm / approve / yes are accepted. The first message never dials, even when it already includes `+E164`. Caller name comes from the `users` row for the inbound phone, never from iMessage and never from `LINQ_KICKOFF_CALLER`. Website `/start` collects that name; after signup the copy is in settings. `BookRequest.user_id` is that row's `user_id`. The outbound Telnyx caller ID is `users.from_did`, not the shared Linq bot and not `ledger.provision`. No row, a row with no first name, or a row with no `from_did` texts a plain line (see [accounts.md](accounts.md)). Anything else stays on the `Received:` echo. Collect and confirm reply 200. Approve returns 202 `{ok, kicked_off, event_id}` and runs `run_call` in this process. After hangup it texts the result on `POST /chats/{chat_id}/messages`. Failed talk and place-exceptions use friendly `user_text` (`I couldn't finish the call` / `I couldn't place the call`) and never echo raw R2, env, or HTTP. Mid-call chat is not shipped; clarification needed texts a plain intake line. Cap recorded `live-prove: PASS (#34)`. qa owns live-prove sign-off (TRE-19). This tree does not redial. +Party and time come from the first text when present. If time is missing, Treena asks for time (or party and time) before the restaurant phone number. `at ` is not a time. "2 people" alone does not unlock the phone ask. After the number she always asks to confirm before dial. Confirm / approve / yes are accepted. The first message never dials, even when it already includes `+E164`. Caller name comes from the `users` row for the inbound phone, never from iMessage and never from `LINQ_KICKOFF_CALLER`. Website `/start` collects that name; after signup the copy is in settings. `BookRequest.user_id` is that row's `user_id`. The outbound Telnyx caller ID is the shared env (`TELNYX_FROM_NUMBER` / `TELNYX_PHONE_NUMBER`), not `users.from_did`, not the shared Linq bot, and not `ledger.provision`. No row or a row with no first name texts a plain line (see [accounts.md](accounts.md)). Empty `users.from_did` still collects. Anything else stays on the `Received:` echo. Collect and confirm reply 200. Approve returns 202 `{ok, kicked_off, event_id}` and runs `run_call` in this process. After hangup it texts the result on `POST /chats/{chat_id}/messages`. Failed talk and place-exceptions use friendly `user_text` (`I couldn't finish the call` / `I couldn't place the call`) and never echo raw R2, env, or HTTP. Mid-call chat is not shipped; clarification needed texts a plain intake line. Cap recorded `live-prove: PASS (#34)`. qa owns live-prove sign-off (TRE-19). This tree does not redial. Prove the stub path: diff --git a/tests/test_cli.py b/tests/test_cli.py index b77015f..142f7ee 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -53,7 +53,8 @@ def test_verify_quick_ask_live_names_missing_keys(tmp_path: Path) -> None: assert result.exit_code == 2 assert ( "live mode requires: TELNYX_API_KEY, TELNYX_CONNECTION_ID, " - "OPENAI_API_KEY, OPENAI_RESPONSES_MODEL, PUBLIC_BASE_URL" + "TELNYX_FROM_NUMBER, OPENAI_API_KEY, OPENAI_RESPONSES_MODEL, " + "PUBLIC_BASE_URL" ) in result.output @@ -366,7 +367,8 @@ def test_verify_linq_call_stub_exits_zero(tmp_path: Path) -> None: assert "[PASS] account_gate" in result.stdout assert "[PASS] kickoff_reply" in result.stdout assert "[PASS] cross_link" in result.stdout - assert "[PASS] from_did_users_row" in result.stdout + assert "[PASS] from_did_users_row" not in result.stdout + assert "[PASS] shared_from_did" in result.stdout assert "[PASS] no_number_order" in result.stdout @@ -387,7 +389,7 @@ def test_verify_accounts_stub_exits_zero(tmp_path: Path) -> None: assert "[PASS] no_name_column" in result.stdout -def test_book_live_without_from_did_exits_two_before_any_socket( +def test_book_live_without_shared_from_exits_two_before_any_socket( tmp_path: Path, ) -> None: result = runner.invoke( @@ -417,16 +419,18 @@ def test_book_live_without_from_did_exits_two_before_any_socket( ) assert result.exit_code == 2 - assert "--from-did" in result.output - assert "account upsert" in result.output + assert "TELNYX_FROM_NUMBER" in result.output artifacts = tmp_path / "artifacts" assert not artifacts.exists() or not any(artifacts.iterdir()) -def test_book_stub_uses_users_row_from_did_when_present( +def test_book_stub_uses_shared_telnyx_from_not_users_row( tmp_path: Path, ) -> None: - env = environment(tmp_path) | {"RETAIN_CALL_ARTIFACTS": "1"} + env = environment(tmp_path) | { + "RETAIN_CALL_ARTIFACTS": "1", + "TELNYX_FROM_NUMBER": "+12135690899", + } seeded = runner.invoke( app, [ @@ -472,7 +476,8 @@ def test_book_stub_uses_users_row_from_did_when_present( / "dial.json" ).read_text() ) - assert dial["from"] == "+15555550177" + assert dial["from"] == "+12135690899" + assert dial["from"] != "+15555550177" def test_verify_webhooks_broken_url_fails_loud(tmp_path: Path) -> None: diff --git a/tests/test_feature_map.py b/tests/test_feature_map.py index d4d8837..612e85a 100644 --- a/tests/test_feature_map.py +++ b/tests/test_feature_map.py @@ -77,6 +77,7 @@ def test_feature_map_lists_account_rows() -> None: "| `users` | shipped |", "| `users.from_did` | shipped |", "| `shared Treena iMessage bot` | shipped |", + "| `shared Telnyx from` | shipped |", "| `open_accounts` | shipped |", "| `account upsert` | shipped |", "| `account get` | shipped |", @@ -90,7 +91,7 @@ def test_feature_map_lists_account_rows() -> None: "| `kickoff from-DID` | shipped |", "| `no-account reply` | shipped |", "| `no-name reply` | shipped |", - "| `no-from-did reply` | shipped |", + "| `no-from-did reply` | not shipped |", "| `LINQ_KICKOFF_USER_ID` | not shipped |", ) @@ -121,7 +122,7 @@ def test_feature_map_lists_linq_to_call_rows() -> None: "| `kickoff from-DID` | shipped |", "| `no-account reply` | shipped |", "| `no-name reply` | shipped |", - "| `no-from-did reply` | shipped |", + "| `no-from-did reply` | not shipped |", "| `no number_orders at dial` | shipped |", "| friendly iMessage errors | shipped |", ) @@ -217,8 +218,11 @@ def test_feature_map_records_cap_locks() -> None: assert "Dashboard UI stays on Pages" in INVENTORY assert "Intended Fly route" in INVENTORY assert "shared Treena iMessage bot" in INVENTORY + assert "shared Telnyx from" in INVENTORY assert "LINQ_FROM_E164" in INVENTORY + assert "TELNYX_FROM_NUMBER" in INVENTORY assert "+12138653024" in INVENTORY + assert "+12135690899" in INVENTORY assert "missing_public_key" in INVENTORY assert "bad_signature" in INVENTORY assert "website `/start`" in INVENTORY diff --git a/tests/test_kickoff.py b/tests/test_kickoff.py index b4cddfa..b8e0bfa 100644 --- a/tests/test_kickoff.py +++ b/tests/test_kickoff.py @@ -21,7 +21,6 @@ CONFIRM_ASK_TEXT, DIAL_NOW_TEXT, NO_ACCOUNT_TEXT, - NO_FROM_DID_TEXT, NO_NAME_TEXT, PHONE_ASK_TEXT, TIME_ASK_TEXT, @@ -100,6 +99,7 @@ def _settings(tmp_path: Path, **updates: object) -> Settings: _env_file=None, linq_api_token=None, linq_from_e164="+15555550100", + telnyx_from_number="+12135690899", call_agent_db_path=tmp_path / "ledger.sqlite3", call_agent_artifacts_dir=tmp_path / "artifacts", ) @@ -310,7 +310,7 @@ def test_account_retain_flag_reaches_request(tmp_path: Path) -> None: assert parsed.request.retain_call_artifacts is True -def test_no_from_did_replies_plain_and_does_not_dial(tmp_path: Path) -> None: +def test_no_from_did_still_collects_and_does_not_dial(tmp_path: Path) -> None: settings = _settings(tmp_path) parsed = parse_kickoff( "book +14155552671 party 2 at Friday 7pm", @@ -318,10 +318,34 @@ def test_no_from_did_replies_plain_and_does_not_dial(tmp_path: Path) -> None: account=_account(from_did=None), ) - assert isinstance(parsed, KickoffBlocked) - assert parsed.reply == NO_FROM_DID_TEXT - assert not parsed.reply.startswith("Call failed:") - assert "missing caller name" not in parsed.reply + assert isinstance(parsed, KickoffCollecting) + assert parsed.pending.waiting_for == "confirm" + assert not (tmp_path / "artifacts" / "stub-call-0001").exists() + approved = intake_kickoff( + APPROVE_FOLLOWUP_TEXT, + settings, + pending=parsed.pending, + account=_account(from_did=None), + ) + assert isinstance(approved, KickoffParsed) + assert str(approved.from_did) == "+12135690899" + + +def test_live_missing_shared_from_fails_friendly_without_env_names( + tmp_path: Path, +) -> None: + settings = _settings( + tmp_path, telnyx_from_number=None, quick_ask_live=True + ) + parsed = parse_kickoff( + "book +14155552671 party 2 at Friday 7pm", + settings, + account=_account(from_did=None), + ) + + assert isinstance(parsed, KickoffFailed) + assert parsed.reason == "I couldn't place the call." + assert "TELNYX" not in parsed.reason assert not (tmp_path / "artifacts" / "stub-call-0001").exists() @@ -338,11 +362,15 @@ def test_no_missing_caller_name_path() -> None: or "linq_kickoff_caller" in text or "LINQ_KICKOFF_USER_ID" in text or "linq_kickoff_user_id" in text - or "TELNYX_FROM_NUMBER" in text - or "telnyx_from_number" in text ): hits.append(path) assert hits == [] + product = "".join( + path.read_text() + for path in root.rglob("*.py") + if not path.name.startswith("verify") + ) + assert "+12135690899" not in product def test_missing_time_asks_for_time_not_phone(tmp_path: Path) -> None: @@ -910,7 +938,7 @@ def test_verify_linq_call_passes() -> None: "call_ran", "cross_link", "same_host_dial", - "from_did_users_row", + "shared_from_did", "no_number_order", ] assert all(check.passed for check in report.checks) @@ -921,7 +949,7 @@ def test_sender_constant_is_imessage_handle() -> None: assert IMESSAGE == "iMessage" -async def test_run_kickoff_dials_from_users_row_not_linq_line( +async def test_run_kickoff_dials_from_shared_env_not_linq_line( tmp_path: Path, ) -> None: settings = _settings(tmp_path, retain_call_artifacts=True) @@ -929,14 +957,22 @@ async def test_run_kickoff_dials_from_users_row_not_linq_line( kickoff_inbound_bytes(APPROVE_FOLLOWUP_TEXT, "evt-from-did") ) assert inbound is not None - first = intake_kickoff(NATURAL_BOOK_TEXT, settings) + first = intake_kickoff( + NATURAL_BOOK_TEXT, settings, account=_account(from_did=None) + ) assert isinstance(first, KickoffCollecting) phone = intake_kickoff( - PHONE_FOLLOWUP_TEXT, settings, pending=first.pending + PHONE_FOLLOWUP_TEXT, + settings, + pending=first.pending, + account=_account(from_did=None), ) assert isinstance(phone, KickoffCollecting) parsed = intake_kickoff( - APPROVE_FOLLOWUP_TEXT, settings, pending=phone.pending + APPROVE_FOLLOWUP_TEXT, + settings, + pending=phone.pending, + account=_account(from_did=None), ) assert isinstance(parsed, KickoffParsed) session = KickoffSession( @@ -956,7 +992,7 @@ async def test_run_kickoff_dials_from_users_row_not_linq_line( from call_agent.domain import ClientState from call_agent.ledger import open_ledger - assert dial["from"] == "+15555550177" + assert dial["from"] == "+12135690899" assert dial["from"] != str(session.from_did) assert dial["from"] != INBOUND_SENDER assert ClientState.decode(dial["client_state"]).user_id == UserId( diff --git a/tests/test_messaging.py b/tests/test_messaging.py index 9e20a70..8b6289e 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -37,7 +37,6 @@ CONFIRM_ASK_TEXT, DIAL_NOW_TEXT, NO_ACCOUNT_TEXT, - NO_FROM_DID_TEXT, NO_NAME_TEXT, PHONE_ASK_TEXT, pending_collect_path, @@ -307,6 +306,7 @@ def _webhook_env( artifacts.mkdir(parents=True, exist_ok=True) monkeypatch.setenv("LINQ_WEBHOOK_SIGNING_SECRET", STUB_WEBHOOK_SECRET) monkeypatch.setenv("LINQ_FROM_E164", "+15555550100") + monkeypatch.setenv("TELNYX_FROM_NUMBER", "+12135690899") monkeypatch.delenv("LINQ_API_TOKEN", raising=False) monkeypatch.setenv("CALL_AGENT_ARTIFACTS_DIR", str(artifacts)) db_path = artifacts.parent / "ledger.sqlite3" @@ -716,6 +716,7 @@ def test_linq_webhook_two_step_collects_then_dials( assert not pending_collect_path(artifacts, INBOUND_CHAT_ID).exists() kickoff_payload = json.loads((approve_dir / "kickoff.json").read_text()) assert kickoff_payload["account"]["from_did"] == "+15555550177" + assert kickoff_payload["dial_from"] == "+12135690899" assert kickoff_payload["request"]["user_id"] == kickoff_payload["account"][ "user_id" ] @@ -884,7 +885,7 @@ def test_linq_webhook_no_name_replies_plain_and_does_not_dial( assert not (artifacts / "stub-call-0001").exists() -def test_linq_webhook_no_from_did_replies_plain_and_does_not_dial( +def test_linq_webhook_without_users_from_did_still_collects( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: artifacts = tmp_path / "artifacts" @@ -911,8 +912,7 @@ def test_linq_webhook_no_from_did_replies_plain_and_does_not_dial( ] assert response.status_code == 200 - assert response.json()["blocked"] is True - assert replies[0]["text"] == NO_FROM_DID_TEXT - assert not replies[0]["text"].startswith("Call failed:") + assert response.json()["collecting"] is True + assert replies[0]["text"] == PHONE_ASK_TEXT assert not (linq_dir / "kickoff.json").exists() assert not (artifacts / "stub-call-0001").exists() diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 400e605..fa51d20 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -43,11 +43,14 @@ CallHandle, Ports, RunDir, + TelnyxFromConfigError, assert_live_dial_origins, normalized_public_host, public_url_host, + resolve_telnyx_from, run_call, run_quick_ask, + shared_telnyx_from, ) from call_agent.telnyx import StubTelnyxHttp, parse_media_frame from call_agent.artifacts import artifact_dir, artifact_slug @@ -604,6 +607,28 @@ def test_settings_process_env_overrides_dotenv_webhook_url( assert_live_dial_origins(settings) +def test_telnyx_phone_number_alias_resolves_shared_from( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("TELNYX_FROM_NUMBER", raising=False) + monkeypatch.setenv("TELNYX_PHONE_NUMBER", "+12135690899") + settings = Settings(_env_file=None) + assert shared_telnyx_from(settings) == E164("+12135690899") + assert resolve_telnyx_from(settings) == E164("+12135690899") + + +def test_stub_without_telnyx_from_uses_stub_number( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("TELNYX_FROM_NUMBER", raising=False) + monkeypatch.delenv("TELNYX_PHONE_NUMBER", raising=False) + settings = Settings(_env_file=None) + assert shared_telnyx_from(settings) is None + assert resolve_telnyx_from(settings, live=False) == E164("+15555550100") + with pytest.raises(TelnyxFromConfigError, match="TELNYX_FROM_NUMBER"): + resolve_telnyx_from(settings, live=True) + + async def test_run_call_live_fails_when_webhook_host_diverges( settings: Settings, ) -> None: @@ -616,6 +641,7 @@ async def test_run_call_live_fails_when_webhook_host_diverges( telnyx_connection_id="connection-1", openai_api_key="test-openai", openai_responses_model="gpt-5.6-terra", + telnyx_from_number="+12135690899", public_base_url="https://hidden-beacon-080.fly.dev", public_webhook_url="https://stale-tunnel.trycloudflare.com", ) diff --git a/tests/test_server.py b/tests/test_server.py index 13a21a0..27e06dd 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -364,6 +364,7 @@ def live_settings( "quick_ask_live": True, "telnyx_api_key": "test-key", "telnyx_connection_id": "connection-1", + "telnyx_from_number": "+12135690899", "openai_api_key": "test-openai", "openai_responses_model": "gpt-5.6-terra", "public_base_url": public_base,