# Messenger (Manage · `Src\Messenger`)

**Context:** Manage portal · **Nav:** Messages → Inbox (there is no Messenger nav entry of its own) · **Routes:** `manage.messenger.*` (thread actions) + the public `flg.webhook.verify` / `flg.webhook.receive` (`GET`/`POST /webhooks/flg-meta` — the one Meta page-object webhook, shared with FLG lead ads) · **Permissions:** `view-messenger` (inbox), `view-messenger-settings` / `manage-messenger` (connect Pages + CTA links)

A two-way **Facebook Page ↔ Messenger inbox**, presented inside the **unified Messages inbox** alongside WhatsApp (2026-07-27): agents receive and reply to the people who message a connected Page, in real time, without switching pages. Built by mirroring the WhatsApp inbox engine, but PSID-centric (no phone number). **v1 is human-operated** — no AI auto-reply and no Flows (both deferred). It also attributes inbound conversations to **m.me ref links** and **Click-to-Messenger (CTM) ads**, linking a page-scoped person to a CRM lead when a deterministic signal arrives.

## What it does
- **Connect a Page for messaging.** A Page already connected through the Facebook OAuth module (`Src\Facebook`) is enabled for Messenger from the **Facebook Connections** page (a per-Page *Enable Messenger* toggle). Enabling subscribes the Page to the messaging webhook fields (as a union with `leadgen`, so lead ads keep working) and creates a `messenger_channels` row, copying the Page access token in **encrypted**.
- **Receive.** Meta POSTs page events to the one webhook (`/webhooks/flg-meta`); messaging events are queued to `ProcessInboundMessengerWebhook`, which parses each element, dedupes by `mid`, downloads media to private GCS via `MediaService`, backfills the contact's public profile, records first-touch attribution, and broadcasts for the live inbox. Echoes (Page-Inbox / other-app sends) are recorded but never trigger automation.
- **Send.** The composer writes a `QUEUED` message (instant optimistic echo), broadcasts it, and dispatches `SendMessengerMessage`, which delivers via the Send API — as a `RESPONSE` inside the 24-hour window, or a human-authored `HUMAN_AGENT`-tagged message outside it.
- **Attribute.** An inbound `referral.ref` (m.me link) or `referral.ad_id` (CTM ad) is recorded in `messenger_captures` (first-touch, one per contact). A **per-lead** m.me link (`{ref_token}~{lead_uuid}`) also links the PSID to that CRM lead and stamps a `LeadFunnel` row (`SOURCE_OTHER` since the 2026-07 source consolidation retired the Messenger bucket — the ad ids still carry the detail). Touches show on the Lead → Attribution tab.

## How it works
- **Where it renders (2026-07-27).** There is **no Messenger inbox page**. `Manage\Whatsapp\InboxController` renders one list across both platforms: it fetches each side's top window, merges them by `last_activity_at`, and tags every row and channel option with a **`platform`** key. **`Src\Messenger\Support\InboxPresenter`** maps a `MessengerConversation` / `MessengerMessage` into the WhatsApp inbox shape (including a message-type translation — the two `TYPE_*` enums agree up to `STICKER` and diverge after it), returning WhatsApp-only concepts as their empty value so the Vue side needs no guards to *read* a row. `GET /manage/messenger` 301s to `/manage/messages`; the thread endpoints below still serve it.
  - **Kept separate on purpose:** each platform keeps its own tables, webhooks, send path, Echo channel (`messenger.conversation.{uuid}` with `.NewMessengerMessage` / `.MessengerMessageStatusUpdated`) and permissions. Only the read side is merged.
  - **WhatsApp-only affordances are hidden on a Messenger thread** — tags, flows, AI drafts / handoff, templates, groups, voice notes, reactions, quote-reply, click-to-call. Filters that are WhatsApp concepts (tag, needs-review, unreplied, groups) **exclude** Messenger rows rather than silently ignoring the filter. Bulk mark-read covers both; bulk tag is WhatsApp-only.
  - **Closed 24h window** locks the composer with an explanation instead of letting an agent type a reply the Send API will reject.
  - **Permissions:** the inbox route accepts `view-whatsapp` **or** `view-messenger`, and the controller scopes each platform's payload independently — a Messenger-only admin sees only Pages, a WhatsApp-only admin sees no Messenger rows at all.
- **Channel model.** `MessengerChannel` (`messenger_channels`, one row per Page) holds `page_id` (the inbound routing key) and `provider_config` (`encrypted:array` + `$hidden`, holds `page_access_token`). `STATUS_DISCONNECTED / CONNECTED`. `pageAccessToken()` decrypts the Send API credential.
- **Identity.** `MessengerContact` is deduped by **`UNIQUE(channel_id, psid)`** — a PSID is page-scoped, NOT globally unique (the single biggest divergence from `whatsapp_contacts.phone_e164`). `user_id` (CRM link) stays NULL until an identity signal links it; a cold contact has no Lead by design. The Graph User Profile API gives only name/picture (review-gated), never email/phone.
- **24-hour window.** `MessengerConversation::canSendFreeform()` = `last_inbound_at !== null && diffInHours(now()) < 24`. `last_inbound_at` advances only on a genuine customer inbound. Outside the window, sends use the `HUMAN_AGENT` tag (needs the Human Agent App-Review feature).
- **One webhook, one Meta app.** A Meta app allows exactly ONE callback URL per `page` object, so **`/webhooks/flg-meta`** (the FLG `WebhookController`) is the single endpoint for the whole object: it verifies the GET handshake + `X-Hub-Signature-256` against `services.meta.*` (`META_APP_SECRET` / `META_WEBHOOK_VERIFY_TOKEN`), then fans out `entry[].changes[].leadgen` → the existing lead-ads ingestion and `entry[].messaging[]` → `ProcessInboundMessengerWebhook`. It lives in `routes/main.php` (always registered — NOT gated behind the Projects UI flag — so Meta can always reach it). Messenger's Send API `appsecret_proof` uses the same `META_APP_SECRET` (`config('messenger.app_secret')`). **There are no Messenger-specific webhook creds** — local and prod both point the Meta app's Page webhook at `/webhooks/flg-meta` and set `META_APP_SECRET` / `META_WEBHOOK_VERIFY_TOKEN` (local dev uses a separate dev Meta app's values).
- **Idempotency.** `MessengerRepository::recordInbound()` dedupes behind `Cache::lock("mgr:inbound:{channel}:{mid}")` + the `UNIQUE(channel_id, mid)` index. Our own API sends already hold their `mid`, so the echo of a self-send naturally no-ops.
- **Send client.** `MessengerClient` (Send API + User Profile + media fetch) — every call carries the Page token + an `appsecret_proof`, a timeout, and a retry. Graph version is pinned in `config/messenger.php` (`v25.0`) — do NOT inherit `services.meta` (v19.0) or `services.facebook` (v21.0).
- **Realtime.** `NewMessengerMessage` / `MessengerMessageStatusUpdated` / `MessengerConversationRead` broadcast on private channels `messenger.inbox` + `messenger.conversation.{uuid}`, each gated by `broadcastWhen() => config('messenger.realtime')` (off → the inbox falls back to a 6s poll; no failed broadcast jobs pile up). `Src\Messenger\Support\MessagePresenter::inbox()` is the single shape feeding the broadcast payload; **`InboxPresenter::message()`** wraps it (adding `platform` + the type translation) for everything the unified inbox consumes — Inertia props, the send response and the older-messages page — so an Echo push and an XHR response stay interchangeable.
- **Media.** Inbound + outbound media go through the shared `Src\Common\Services\MediaService` (collection `messenger`, the `whatsapp_attachments.media_id` pattern) — private GCS, signed temporary URLs; the expiring provider URL is never surfaced. See [`shared/media/readMe.md`](/docs/modules_handbook/shared/media/readMe.md).
- **Visibility (RBAC).** `view-messenger` alone never exposes another agent's leads. Every conversation read/write funnels through **`LeadVisibility`** — `applyToConversations()` on the inbox list + the deep-linked thread, `allowsConversation()` (403) on `InboxController::messages/markRead` and every `MessagesController` send, and `allowsContact()` (403) on block/unblock. The `channels` prop is scoped by **`AccountVisibility::applyMessengerChannels()`**, which reuses the **Meta** permission set (`view-meta-all/group/team/own`) because a Messenger channel *is* a Page under a connected Meta account — the same rule `ChannelsController` enforces on enable/disable. This mirrors the WhatsApp inbox exactly, where the RBAC gate is likewise `contact.user_id → user → lead`.

## Setup (operational)
1. **Enable the scope.** `pages_messaging` is added to `services.facebook.scopes`; existing integrations must reconnect. If prod uses `FACEBOOK_CONFIGURATION_ID`, add `pages_messaging` to that Login configuration in the Meta dashboard instead.
2. **Add the Messenger product** to the Meta app; configure the Page webhook → callback `https://<APP_URL>/webhooks/flg-meta`, verify token `META_WEBHOOK_VERIFY_TOKEN`, fields `messages,messaging_postbacks,messaging_referrals,message_deliveries,message_reads,message_reactions,message_echoes` (+ `leadgen`).
3. **Seed permissions:** `php artisan db:seed --class='\RolesSeeder' --force` (adds `view-messenger` / `view-messenger-settings` / `manage-messenger`).
4. **Grant the inbox — Messenger is admin-only by default.** Unlike WhatsApp (whose `view-whatsapp` sits in `SeedCommonRolesAction::defaults()`'s `$shared`, so every sales role gets the inbox), **no agency role is granted any Messenger permission** — only Super Admin and the legacy Admin role can open `/manage/messenger`; a sales agent / leader / group super admin gets a **403**. This is deliberate while Messenger beds in. To let a role in, grant `view-messenger` from the **Roles** page (or add it to `$shared` / `$oversight` in `SeedCommonRolesAction` to make it a default). The inbox is already lead-scoped (see *Visibility* above), so widening the permission does **not** widen which leads an agent can see.
5. **Enable Messenger** on a Page from Facebook Connections. Dev mode (Standard Access) works end-to-end with your own Page + app testers; messaging real customers needs `pages_messaging` Advanced Access + Business Verification (and the Human Agent + Business-Asset-Profile features).

## Deferred (documented follow-ups)
- **AI auto-reply** — a `GenerateMessengerAiReply extends App\Jobs\Ai\AiJob` mirroring `GenerateAiReply`, plus a `messenger_assistant` prompt key. The whole `Src\Ai` layer is channel-agnostic.
- **Flows** — fork `messenger_flows/_runs` (or generalize to `automation_flows`); remember the reaper.
- **In-app CTM ad launcher** — CTM *attribution* is done (inbound `referral.ad_id` is captured). Launching CTM ads *from our UI* means extending `MetaAdLauncherService` with a Messenger-destination path (`objective OUTCOME_ENGAGEMENT`, `destination_type MESSENGER`, `MESSAGE_PAGE` CTA + m.me link, `publisher_platforms += messenger`, skip the lead form), adding `MESSAGE_PAGE` to `ImageCreative::CTA_TYPES`, relaxing `LaunchRequest`, indexing `flg_campaigns.meta_ad_id`, and adding `onsite_conversion.messaging_conversation_started_7d` to `FacebookAdsService::LEAD_ACTION_TYPES`. Until then, launch CTM ads in Ads Manager — the inbox attributes them.
- **Handover Protocol** — `standby` + `messaging_handovers` are intentionally NOT subscribed (v1 is the direct primary receiver). Add them to hand a thread to the native Page Inbox.
- **Phone/email in-chat capture + lead-merge** — the `user_phone_number`/`user_email` quick reply → `LeadRepository::firstOrCreateForPhone/Email`; needs a lead-merge routine (none exists) before eager linking is safe.

## Related files
**Models** — [`src/Messenger/MessengerChannel.php`](/src/Messenger/MessengerChannel.php) · `MessengerContact` · `MessengerConversation` · `MessengerMessage` · `MessengerAttachment` · `MessengerCtaLink` · `MessengerCapture`
**Repositories** — [`src/Messenger/Repositories/MessengerRepository.php`](/src/Messenger/Repositories/MessengerRepository.php) (channel + inbound/outbound writes) · `MessengerCtaLinkRepository` (links + capture ledger). Both are **constructor-injected** into the controllers (the WhatsApp convention this module mirrors) — there are no Messenger facades. Every write method takes a **nested array keyed by model name** (`messenger_channel` / `messenger_contact` / `messenger_message` / `messenger_attachments` / `messenger_cta_link`), `data_only()`-filtered before the `DB::transaction` (GUIDELINES §2).
**Service** — [`src/Messenger/Services/MessengerClient.php`](/src/Messenger/Services/MessengerClient.php) (Send API) · [`src/Messenger/Support/MessagePresenter.php`](/src/Messenger/Support/MessagePresenter.php)

### The lead's Messenger threads — RETIRED (2026-09-17)

**Messenger is no longer a tab on the Lead page.** The owner asked for it to be removed from Lead → Channel on 2026-09-17, so the strip is now WhatsApp · Zoom · Phone Call · AI Caller · Showroom, and `Channel/MessengerTab.vue`, the `messengerThreads` / `messengerThreadCount` props and `src/Messenger/Support/LeadConversationPresenter.php` (`forLead()` / `countForLead()`, which existed only for that tab) went with it. Nothing else changed: the **inbox** on Manage → Messages still shows every Messenger thread, and a lead's Messenger **captures** still ride the `messengerCaptures` prop onto the lead's **Attribution** tab — that is where a Facebook capture belongs, since it is attribution rather than a conversation.

Two things worth keeping in mind if a lead-side Messenger view is ever wanted again (they are why the old tab so often looked empty, and are unchanged facts about the data):
- **Empty was the NORMAL case.** A `MessengerContact` is keyed by PSID and only gets a `user_id` when an m.me `ref` / CTM referral identified the person, so most leads legitimately had nothing to show.
- A PSID is **Page-scoped**, so one person messaging two Pages is two contacts → two threads.
**Webhook** — [`app/Http/Controllers/Manage/FacebookLeadGenerator/WebhookController.php`](/app/Http/Controllers/Manage/FacebookLeadGenerator/WebhookController.php) (the single `/webhooks/flg-meta` — leadgen + messaging fan-out); routes `flg.webhook.verify` / `flg.webhook.receive` in [`routes/main.php`](/routes/main.php)
**Jobs** — [`app/Jobs/Messenger/ProcessInboundMessengerWebhook.php`](/app/Jobs/Messenger/ProcessInboundMessengerWebhook.php) · `SendMessengerMessage.php`
**Events** — `app/Events/Messenger/{NewMessengerMessage,MessengerMessageStatusUpdated,MessengerConversationRead}.php`
**Controllers** — `app/Http/Controllers/Manage/Messenger/{Inbox,Messages,Contacts,Channels,CtaLinks}Controller.php`
**Frontend** — [`resources/js/Pages/Manage/Messages/Inbox.vue`](/resources/js/Pages/Manage/Messages/Inbox.vue) · `CtaLinks/Index.vue` · `Components/Messenger/MessageAttachment.vue` · nav in `Layouts/ManageLayout.vue` (`messengerGroup`)
**Image gallery** — a Messenger thread's pictures browse through the shared [`ImageLightbox`](/docs/modules_handbook/shared/image-lightbox/readMe.md) + `useMessageGallery`, same as WhatsApp's (mounted by `Components/Conversation/ConversationThread.vue` on the lead page and by the inbox)
**Config** — [`config/messenger.php`](/config/messenger.php) (graph version, realtime, app secret / verify token, subscribe fields)
**Migrations** — `database/migrations/2026_07_12_210001..210007_*` (channels, contacts, conversations, messages, attachments, cta_links, captures)
**CRM link** — the per-lead-ref `LeadFunnel` row stamps `SOURCE_OTHER` (the dedicated `SOURCE_META_MESSENGER=7` bucket was retired in the 2026-07 consolidation); Lead → Attribution tab shows Messenger touches (`LeadsController::show` → `AttributionTab.vue`)

**See also:** [Messages hub](/docs/modules_handbook/manage/messages/readMe.md) (the shared IA + unified inbox) · [WhatsApp](/docs/modules_handbook/manage/messages/whatsapp/readMe.md) (the mirrored engine) · [shared/media](/docs/modules_handbook/shared/media/readMe.md) · [shared/ai](/docs/modules_handbook/shared/ai/readMe.md) (for the deferred AI phase)
