# WhatsApp — sales app adapter

> **LINE twin (2026-09-19):** `/agent-api/line/*` serves LINE chats with this exact contract (+ `platform: 'line'`) and pushes `line_inbound` — see [LINE → Agent App](/docs/modules_handbook/manage/messages/line/readMe.md#agent-app-agent-apiline).

## What it does

Serves the authenticated Flutter sales app from the existing WhatsApp inbox.
This adapter exposes individual Cloud API and QR/Bridge conversations, text
replies, customer/channel context and permission-scoped account-manager filters.
It does not create a separate messaging store or change web inbox behavior.

## How it works

- `GET /agent-api/whatsapp/conversations` retains `limit` (1–200), `search`
  (up to 100 characters) and `meta.has_more`. Optional filters intersect:
  `assigned_to=me|<user UUID>` and `channel_type=cloud_api|bridge`.
- `ListAgentWhatsappRequest` validates inputs. `AgentWhatsappQuery` starts from
  `AgentInbox::conversations`, so filters never replace `LeadVisibility` or
  WhatsApp access checks. Unknown/out-of-scope owner UUIDs return 403; malformed
  values return 422. Omitting filters means all **accessible**, not all company
  conversations.
- `meta.supports_filters=true` advertises the capability. `meta.assignees` contains
  only UUID/name pairs for account managers assigned to Leads visible to this
  caller. This reflects `Lead.assigned_admin_id` (a User ID), not conversation
  routing ownership or future multi-person Caller/Closer roles.
- List and detail conversation objects add `channel_type`, `channel_phone`,
  `avatar_url`, `lead_uuid`, `lead_status` and nullable `assigned_to:{uuid,name}`.
  Missing data stays null. Credentials/provider configuration are never included.
- **A listed `lead_uuid` opens and can be called — WhatsApp or LINE.** The app
  shows a call button on every Messages conversation with a `lead_uuid` (both
  platforms), which opens `GET /agent-api/agent-leads/{uuid}` and then creates a
  call interaction. Both accept, besides the app-wide Lead search permission and
  assignment, a Lead
  [`AgentAppLeadSearchQuery::shownInMessages()`](/src/Lead/Queries/AgentAppLeadSearchQuery.php)
  confirms — the one place both endpoints ask, so they cannot drift. It is
  [`AgentInbox::showsLead()`](/src/Whatsapp/Support/AgentInbox.php) OR its LINE
  twin [`AgentLineInbox::showsLead()`](/src/Line/Support/AgentLineInbox.php):
  the user passes that inbox's `permitted()` and its `conversations()` holds a
  conversation whose `contact.user.lead` is that Lead, so every inbox rule
  (visibility, chat type, sandbox, covered / active channel) applies unchanged.
  Group Super Admins and Sales Leaders hold group/team `LeadVisibility` but not
  the search permission, so without this they got a 404 on a conversation they
  could see. A user without an inbox gets `false` for it, never a 403. The
  customer eligibility checks (staff, merged, inactive, trashed profile) still
  refuse on this path. F2F meeting reports keep the assignment-only rule
  (`findAccessibleByUuid`). Tests:
  [`AgentLeadOpenFromChatTest`](/tests/Feature/AgentApi/AgentLeadOpenFromChatTest.php).
- Existing detail pagination, text-send idempotency, dispatch-time authorization,
  blocked-contact checks and provider-specific reply restrictions remain in place.
  Cloud replies use server time and the last inbound message's 24-hour window.
- **Covered channel, one definition.** `AgentInbox::conversations()` — and through
  it every single-conversation endpoint (show, read, lookup, send, media, since
  they all resolve the conversation the same way) — only ever surfaces a channel
  [`AgentInbox::applyCoveredChannels()`](/src/Whatsapp/Support/AgentInbox.php) (or
  its object twin `coversChannel()`) covers: provider in `CHANNEL_TYPES` **and**
  `channel.isSharedInbox()` (`purpose !== WhatsappChannel::PURPOSE_CEO`). A CEO Dashboard personal number is
  therefore never listed, and a bookmarked or guessed uuid on one 404s for
  everyone — including a full-access (`LeadVisibility::LEVEL_ALL`) admin, since
  `LeadVisibility::applyToConversations()` does not itself filter by channel.
  `AgentChannels::whatsappLinked()` and
  `WhatsappInboundPushNotifier::eligible()` build on the same helper (the latter
  also requires `is_active`) so the three rules cannot drift apart again.
- Roll out the backend before enabling filters in the app. Older clients ignore
  additive fields; the updated app hides controls on older backends and rejects
  an unacknowledged filtered response rather than presenting it as filtered.
- Not implemented here: template sending, multi-person
  assignments, or changes to the web role/ownership model.
- `GET /agent-api/agent-channels` → `{"data":{"platforms":["whatsapp","line"]}}` — the inboxes the app shows: permitted (`AgentInbox::permitted` / `AgentLineInbox::permitted`) AND linked (an active shared WhatsApp line; an active LINE account). The app shows a WhatsApp / LINE switch only when both are listed; `platforms: []` means no inbox at all (the app shows "no channel linked"). `is_active` is switched off only by an admin, from the channel edit form — a dropped QR phone sets `status` to `STATUS_DISCONNECTED` and still counts as linked. If an admin deactivates the last line, only the tab is hidden: `AgentInbox::conversations()` does not filter on `is_active`, so that channel's message history stays reachable through the inbox itself. A server that predates this endpoint 404s, and the app falls back to treating that the same as WhatsApp-only. Rule: `Src\AgentApp\Support\AgentChannels`.

### Unread, needs-reply and phone notifications (September 16)

**Inbox state.** List and detail conversation objects add `unread_count`,
`needs_reply` and `last_inbound_at`. Both states come from
[`InboxState`](/src/Whatsapp/Support/InboxState.php), which the web inbox also
uses, so a phone badge and a web chip cannot disagree:

- `unread_count` — inbound messages newer than the **team-global**
  `last_read_at` (all of them when never read). Deleted messages never count.
  Opening a thread in either inbox clears it for everyone.
- `needs_reply` — the customer's latest inbound has no **confirmed** outbound
  reply (status sent / delivered / read) at or after it. A reply that is still
  queued, failed or deleted leaves it `true`; an AI auto-reply that was sent
  answers it. The web-only *Needs review* concept is not exposed here.

`GET /agent-api/whatsapp/conversations` accepts `unread=1` and `needs_reply=1`
(booleans; `true`/`false` strings are rejected with 422). They intersect with
`assigned_to`, `channel_type` and `search` and start from
`AgentInbox::conversations`, so they can never widen visibility.
`meta.supports_state_filters=true` advertises the capability and
`meta.counts: {unread, needs_reply}` counts conversations in the selected scope
(`assigned_to` + `channel_type`), ignoring the state toggles and search, so the
tab badge does not change when a chip is tapped.

`POST /agent-api/whatsapp/conversations/{uuid}/read` behaves like opening the
thread on the web: stamps `last_read_at`, queues `MarkWhatsAppRead` (customer
blue ticks) and broadcasts `WhatsAppConversationRead`. It is scoped by
`AgentInbox` (404 outside scope) and returns
`{uuid, unread_count, needs_reply, last_read_at}`. `GET` thread reads never mark
read, so the app's polling cannot send receipts.

**Push token lifecycle.** `POST /agent-api/agent-app/installations` accepts
`platform=android` with `tech.propertylab.agent` or `platform=ios` with
`tech.propertylab.salesagent` — each platform only with its own **production**
identity; pilot IDs are refused so a pilot token can never enter production
pushes. The token stays encrypted at rest (`encrypted` cast) and hidden from
responses. Re-registering replaces the token (FCM refresh). When an installation
is taken over by a different account without a new token, the old token is
cleared rather than delivering to the new account. `DELETE
/agent-api/agent-app/installations/{installationUuid}/push-token` (204, owner-scoped,
idempotent) stops delivery before logout / account switching; the installation
row stays for version tracking. FCM `UNREGISTERED` / `SENDER_ID_MISMATCH` clears
the token unless the device registered a replacement in the meantime.

**Who is notified.** `ProcessInboundWhatsAppWebhook::considerAgentAppPush` queues
`SendWhatsappInboundPushNotifications` (message id only) for each **live 1:1
customer message** on a covered, active channel (see *Covered channel, one
definition* above — a CEO Dashboard line or a deactivated channel never queues
this), after the contact is linked to its Lead. Outbound and AI replies, history
backfill, reactions, calls, system rows, revoked/deleted messages, groups and
sandbox threads never notify; status, edit and media-download events do not go
through this path at all.
[`WhatsappInboundPushNotifier`](/src/AgentApp/Services/WhatsappInboundPushNotifier.php)
resolves recipients at send time:

1. people **responsible** for the Lead — `leads.assigned_admin_id` plus every
   project role holder (Caller, Closer, …) via `AgentLeadAssignments::assignedTo`;
2. who still pass `AgentInbox` (active staff role, WhatsApp permission,
   `LeadVisibility`, channel/chat type) for that conversation;
3. who have an installation with a live token (deduplicated per device token).

Company-wide viewers and team leads are **not** notified about Leads they do not
hold, and a conversation without a linked Lead notifies nobody (it still shows as
unread to whoever can see it). Widening the audience is a product decision.

**Payload and idempotency.** The alert says **who** wrote and nothing else
(product decision, 2026-09-29 — until then it named no one): title = the
customer's name, body = `New WhatsApp message`. The name is exactly what the
inbox list shows (`AgentInbox::displayName()` — the Lead's profile name, else the
WhatsApp contact name; `conversation()` reads the same helper, so the two cannot
drift), passed through `FirebaseCloudMessaging::safeSenderName()`: whitespace
collapsed; dropped when the **whole** name carries a contact detail — 7 or more
numeric characters in total, any script (`0-9`, `①`, `¹`), separators ignored
(`SENDER_NAME_PHONE_DIGITS` — it may carry a phone number: `Ali 012-345 6789`
goes, `Unit 12B Tower 3` and `Jason 88` stay), anything URL-shaped (`://`,
`www.`, a `word.tld/` path such as `wa.me/…`), a bare domain with a common TLD
written all lower- or all upper-case (`example.com`, `shop.my`, `BIT.LY`,
`wa.me`; a title-case one is a name — `Nguyen T.Ly`, `Tan Bros.Co` stay) or an
email (`ali@example.com`; a Malay alias like `Muhammad bin Ali @ Mat` stays).
These checks run before the cut, so a number or link straddling or past it
cannot leave in pieces, and ignore invisible format characters and combining
marks, so a zero-width space or variation selector cannot hide one (the text
sent keeps them, so joined emoji and accents still render). Then the name is cut to 60
characters (`SENDER_NAME_MAX_LENGTH`) and dropped when what is left has no
letter (digits or emoji only).
With no usable name the alert is the generic `New WhatsApp message` /
`Open WhatsApp to view the message`. The message text, phone numbers, URLs and
attachment details **never** leave the server, and the name is never put in
`data`, which stays `{type: whatsapp_inbound, conversation_uuid}` (the app
routes on those two). **Lock screen:** the device redacts the text — the app's
Android channel `whatsapp_messages` is `VISIBILITY_PRIVATE` (every build since
the channel existed; the payload repeats `android.notification.visibility:
PRIVATE` as a backstop) and iOS shows previews only when unlocked by default.
Android uses a per-conversation tag; iOS uses `apns-collapse-id`, so a newer
alert replaces an older one for the same thread. `agent_app_push_deliveries`
holds one row per (kind, message, installation) and a conditional `pending → sending` claim, so a
replayed webhook, a retried job or two workers never notify a device twice
(at most once). Any send error (an FCM status such as `UNAVAILABLE`, a
connection error, a Google OAuth token failure) releases the claim for a retry
after 30 s and 120 s, then marks it failed, so no device is left stuck in
`sending`. The ledger and logs hold ids, statuses, error codes and exception
class names only — never tokens, content or the sender's name. **APM:** Inspector
records every outgoing Http-client call with its request headers and body (and
response headers/body), which for FCM would ship the FCM access token, the
device token and the alert. The app ships Inspector data through
[`RedactingInspectorTransport`](/app/Support/Monitoring/RedactingInspectorTransport.php)
(wired in `AppServiceProvider::register()`), which cuts every
`fcm.googleapis.com` segment down to method, URL, status and timing — for the
inbound pushes and `sendUpdate` alike. The Google OAuth token fetch
(`google/auth`) uses its own Guzzle client, not Laravel's, so Inspector never
records it.

**Deploy before shipping the app build.**

1. Run the migration (`agent_app_push_deliveries`).
2. `FIREBASE_PROJECT_ID` and `FIREBASE_SERVICE_ACCOUNT_PATH` (JSON outside git,
   readable by the queue worker) must point at the Firebase project that holds
   **both** the Android app `tech.propertylab.agent` and the iOS app
   `tech.propertylab.salesagent`, with an APNs auth key uploaded for the iOS app.
   Without them, jobs log `WhatsApp push skipped because Firebase is not
   configured.` and send nothing; in-app polling keeps working.
3. Restart queue workers (`supervisorctl restart`, not only `queue:restart`).
4. Verify with an approved internal test Lead only — never a real customer.

### Mobile attachment uploads

- Conversations advertise `supports_media=true`. `POST
  /agent-api/whatsapp/conversations/{uuid}/media` accepts multipart `file`,
  `request_uuid` and optional `caption`. The mobile request extends the web
  `SendMediaRequest`: JPEG/PNG up to 5 MB, supported audio/video up to 16 MB,
  supported documents up to 25 MB. Audio captions, arbitrary voice flags and
  reply-to fields are rejected. Audio files are ordinary audio attachments,
  not microphone-recorded OGG/Opus push-to-talk messages.
- The server hashes bytes, sniffed MIME, sanitized display filename and caption
  into the send identity. A UUID replay must match the conversation, actor and
  digest; changed payloads, cross-text/media reuse and deleted rows return 409.
  An already accepted upload can be acknowledged even after the reply window
  closes; a new send still requires an open window and current access.
- `MediaService` stores the binary before the transaction, using a server-derived
  extension. `AgentMediaReplyRepository` rechecks authorization under a conversation
  lock, delegates message/attachment creation to `WhatsappRepository`, and links
  the stored media atomically. Only then is `SendAgentWhatsAppReply` dispatched,
  retaining its dispatch-time access checks and existing provider delivery path.
- A confirmed concurrent replay cleans up only its unused new upload. An uncertain
  commit/storage failure can leave an orphan; it never deletes a potentially linked
  file. Cleanup errors log only a media ID, not content, filenames or credentials.
- Flutter keeps the pending UUID/caption/hash in account-scoped secure storage and
  a private non-backed-up cache copy of the selected file. Retry preserves the exact
  identity across reopening. OS cache eviction requires reselecting the identical
  file (hash checked); it does not silently start a new send. Progress distinguishes
  bytes uploaded from server acknowledgement/delivery.

### Microphone voice notes (disabled until deployment validation)

`supports_voice` is advertised only when `AGENT_APP_VOICE_NOTES_ENABLED=true`
and the configured ffmpeg executable is available. The default is **false**.
`AGENT_APP_VOICE_FFMPEG_BINARY` defaults to `ffmpeg`; an absolute executable
path is supported. Before enabling, verify **libopus** is installed under the
PHP-FPM user's environment, not just an interactive shell. The availability
field checks executable presence, not codec support.

The same media endpoint additionally accepts
`voice_format=pcm_s16le_16000_mono`, without a caption. This is raw signed
16-bit little-endian PCM, 16 kHz, mono: 32,000 bytes/second, 1–300 seconds,
at most 9,600,000 bytes. Odd byte counts, unsupported formats, captions and
oversize input return 422. It is not an endpoint for CAF/AAC/WAV files.

`AgentVoiceEncoder` forces the raw input format and a file/pipe protocol
allowlist; it does not probe playlists or user-supplied URLs. It uses one
encoding thread and a 60-second process timeout, producing mono 48 kHz
OGG/Opus at 32 kbps. Conversion failure stores no message and sends nothing.
The private stored media is `voice-note.ogg` / `audio/ogg`; attachment
`meta.voice=true` reaches the existing provider adapters. The original PCM
hash and format are part of retry identity; ordinary attachment digests are
unchanged. Accepted replays do not require conversion or an enabled voice flag.

Deployment checklist (not executed on production):

1. Deploy the backend adapter. Install/verify ffmpeg and libopus, set the binary
   path if needed, and allow the PHP process to execute it.
2. Ensure PHP upload/post limits and proxy request limits accept a 9.6 MB file
   plus multipart overhead; allow conversion time in request timeouts. Retain
   the existing authenticated send throttling and private media storage.
3. Enable the voice flag and rebuild Laravel config cache through the normal
   deployment workflow. No schema changes or new WhatsApp credentials.
   Run `php artisan agent-app:check-voice-notes` as the same OS user/container
   running PHP. It must exit 0 and print PASS. This converts one second of generated
   silence through the actual encoder, verifies OGG/Opus, removes the temporary
   probe, and sends no message. A passing CLI check does not verify proxy limits,
   PHP-FPM configuration, queue health or recipient playback.
4. Use approved test recipients to check actual iOS and Android recordings,
   Cloud API and QR/Bridge playback, permission denial, interruption, expiry,
   and uncertain-network retry. Automated tests do not prove provider delivery.

The app asks for microphone access only when Record voice note is tapped.
It cancels on backgrounding/interruption, checks Badge/reply access, and requires
a separate Send action. In-progress audio is bounded in memory; stopped audio
uses the account-scoped upload cache. If the OS evicts an **unconfirmed voice**
payload, it cannot be reconstructed by reselecting a user file. Keep its send
identity and query the original send result; do not automatically create a
replacement message. There is no microphone background mode, recording/body
logging, or automatic customer send.

### Send-result recovery (September 15)

`GET /agent-api/whatsapp/conversations/{uuid}/sends/{requestUuid}` is a read-only,
no-store lookup. Conversation responses advertise `supports_send_lookup: true`.
The caller must still be able to view the conversation, and the result must
belong to that conversation **and the original sending user**. A colleague's
send, even to a shared visible customer, is not a matching result.

Response: `data: {found: bool, removed: bool, message: object|null}`. A saved live
message uses the ordinary safe presenter (UUID, delivery status, attachments);
a soft-deleted send returns `found: true, removed: true, message: null`, with no
deleted body/attachments. No result is `found: false, removed: false, message: null`.
This never enqueues, re-encodes, re-sends or needs the phone's lost payload.
It still works after the Cloud API reply window closes or voice is disabled.

The App uses this when restoring pending text and opening/retrying a pending
attachment. `found` means persisted, **not delivered**: show the returned delivery
status. `found: false` can race a still-running original send and does not prove
non-delivery. Retain the same UUID/payload and allow checking again or an explicit
identical retry; never automatically issue a replacement UUID. If voice bytes
are gone and no server result exists, recovery must remain unresolved rather
than inventing audio or silently sending another voice note.

Voice checkpoint: the full Agent API suite passed 239 tests / 1,510 assertions;
the nine media tests passed again after process exception hardening. This includes
real ffmpeg OGG/Opus conversion, mono header and stored-size checks, replay after
flag/window changes, invalid inputs and conversion failure. The existing PHPUnit
XML schema deprecation remains. No live provider messages were sent.

Attachment validation: the full Agent API suite passes 236 tests / 1,480 assertions
on the isolated test DB. Six attachment tests cover replay, window/access checks,
MIME/size validation, deleted/cross-type UUIDs, image typing, and storage failure.

Validation on September 11: all 230 Agent API feature tests (1,446 assertions)
passed against an isolated scratch database with PHP 8.4 and a 512 MB process
memory limit. The existing PHPUnit XML schema produces a deprecation notice;
the default 128 MB limit was insufficient for the full recording-upload suite.
The new request/query classes pass Pint. No production data or provider calls
were used, and nothing has been deployed.


## Related files

- [Controller](/app/Http/Controllers/AgentApi/AgentWhatsappController.php)
- [Channels endpoint controller](/app/Http/Controllers/AgentApi/AgentChannelsController.php), [rule](/src/AgentApp/Support/AgentChannels.php) and [tests](/tests/Feature/AgentApi/AgentChannelsTest.php)
- [Validation](/app/Http/Requests/AgentApi/ListAgentWhatsappRequest.php)
- [Query](/app/Http/Requests/AgentApi/AgentWhatsappQuery.php)
- [Scope and presenter](/src/Whatsapp/Support/AgentInbox.php)
- [Queued reply](/app/Jobs/Whatsapp/SendAgentWhatsAppReply.php)
- [Media controller](/app/Http/Controllers/AgentApi/AgentWhatsappMediaController.php)
- [Media validation](/app/Http/Requests/AgentApi/SendAgentWhatsappMediaRequest.php)
- [Media send transaction](/src/Whatsapp/Repositories/AgentMediaReplyRepository.php)
- [Media tests](/tests/Feature/AgentApi/AgentWhatsappMediaTest.php)
- [Voice encoder](/src/Whatsapp/Services/AgentVoiceEncoder.php)
- [API regression tests](/tests/Feature/AgentApi/AgentWhatsappTest.php)
- [Open / call a Lead from a chat tests](/tests/Feature/AgentApi/AgentLeadOpenFromChatTest.php)
- [Inbox state predicates](/src/Whatsapp/Support/InboxState.php)
- [Push notifier](/src/AgentApp/Services/WhatsappInboundPushNotifier.php)
- [Push job](/app/Jobs/AgentApp/SendWhatsappInboundPushNotifications.php)
- [FCM client](/src/AgentApp/Services/FirebaseCloudMessaging.php)
- [Delivery ledger](/src/AgentApp/AgentAppPushDelivery.php) and [repository](/src/AgentApp/Repositories/AgentAppPushDeliveryRepository.php)
- [Installations](/src/AgentApp/Repositories/AgentAppInstallationRepository.php) and [controller](/app/Http/Controllers/AgentApi/AgentAppInstallationsController.php)
- [Inbox state tests](/tests/Feature/AgentApi/AgentWhatsappInboxStateTest.php), [push tests](/tests/Feature/AgentApi/WhatsappInboundPushTest.php), [FCM payload tests](/tests/Unit/AgentApp/FirebaseCloudMessagingTest.php), [FCM APM redaction tests](/tests/Unit/AgentApp/FcmInspectorRedactionTest.php), [token tests](/tests/Feature/AgentApi/AgentAppPushRegistrationTest.php)
- [Parent WhatsApp module](/docs/modules_handbook/manage/messages/whatsapp/readMe.md)
- Flutter repository: `lib/features/whatsapp/` and `test/features/whatsapp/`.
