# Messaging channels

How a customer conversation reaches an agent, and what an operator has to
configure before it can.

Written alongside the channel work on this branch. Everything described here is
implemented; where something is deliberately not built, it says so.

## The two tiers

Credentials live at two different levels, and confusing them is the main way
this goes wrong.

**Platform credentials** belong to CoreDeskAI, the seller. One Meta App, one
Slack app, one Azure Bot registration serve every customer. They live in the
`platformChannelConfig` table, are edited in Platform Admin → Channels, and no
customer ever sees them.

**Tenant connections** belong to a customer. Their Facebook Page, their
Instagram account, their WhatsApp number, their Slack workspace. They live in
`socialChannels`, one row per connected account, scoped by `organizationId`.
The access token is never stored in that row: it goes to AWS Secrets Manager
(or, without AWS credentials, the `orgSecrets` dev fallback) and the row keeps
only the reference.

`lib/channelConfig.ts` resolves platform credentials database-first and falls
back to environment variables, so a deployment still running on env vars keeps
working with no row present. A row that exists but is disabled does **not** fall
back: switching a provider off means off, not resurrected by a leftover
variable.

## Platform Admin → Channels

### Meta App (WhatsApp · Instagram · Messenger)

One card, because it is one app. A single Meta App carries all three channels
and they share one webhook verify token; splitting them would invite an operator
to type the same App ID three times and let the copies drift.

| Field | Where it comes from | Required |
|---|---|---|
| Meta App ID | App Dashboard → Settings → Basic | yes |
| Meta App Secret | App Dashboard → Settings → Basic | yes |
| Instagram App ID | App Dashboard → Instagram → API setup with Instagram login | only for Instagram-login DMs |
| Instagram App Secret | same screen | only for Instagram-login DMs |
| Facebook Login for Business configuration ID | App Dashboard → Facebook Login for Business → Configurations | yes, if the app uses Login for Business |
| Webhook Verify Token | any string you choose | yes |

The Instagram credentials are a genuinely separate product ("Instagram API with
Instagram Login") inside the same app: it issues its own app ID and signs its
webhooks with its own secret. The Meta webhook handler therefore accepts a
payload signed with **either** secret, because the payload does not say which
app signed it.

The login configuration ID matters more than it looks. Classic Facebook Login
takes a `scope` list; Login for Business takes a `config_id` naming a
dashboard-defined configuration, and that configuration is what grants the
permissions. An app set up for Login for Business and sent a bare scope list
will drop permissions or refuse the dialog. When the field is set, the authorize
URL carries `config_id` and no scopes; when it is empty, the scope list is used.

A separate WhatsApp app is supported for the rare deployment that needs one, and
is collapsed behind a disclosure. Left empty, WhatsApp reads the Meta row.

### Slack

Three distinct values, not two. The client ID and client secret drive each
workspace's install; a **separate** signing secret verifies every inbound event.
Conflating them breaks either installs or delivery, silently.

The card prints two URLs to register: the Request URL under Event Subscriptions
(subscribe to `message.channels`, `message.im`, `app_mention`) and the OAuth
Redirect URL under OAuth & Permissions.

### Microsoft Teams

The Microsoft App ID and client secret from your Azure Bot registration. Teams
has no per-customer login, so these credentials send every reply for every
customer. The card prints the messaging endpoint to set on the Azure Bot
resource.

## Creating the Meta app

What follows produces every value the Meta card asks for. The permissions and
webhook fields listed here are the ones the code actually requests, from
`app/api/oauth/meta/start/route.ts` and `public/metaOAuth.ts`; if you change
them there, change them here.

### 1. Create the app

developers.facebook.com -> My Apps -> Create App, type **Business**, attached to
your Business Portfolio. WhatsApp and Instagram both need a verified business.

From **Settings -> Basic**, copy the **App ID** and **App Secret** into the
console's *Meta App ID* and *Meta App Secret*.

### 2. Add the products

Add **Facebook Login for Business**, **Messenger**, **Instagram** and
**WhatsApp**.

### 3. Create the login configuration

**Facebook Login for Business -> Configurations -> Create configuration**, with:

| For | Permissions |
|---|---|
| Messenger | `pages_show_list`, `pages_messaging`, `pages_read_engagement`, `pages_manage_metadata` |
| Instagram | those four plus `instagram_basic`, `instagram_manage_messages`, `business_management` |
| WhatsApp | `whatsapp_business_management`, `whatsapp_business_messaging`, `business_management` |

One configuration covering all three is simplest. Its **Configuration ID** goes
in the console's *Facebook Login for Business configuration ID*. Without it the
app falls back to sending a raw scope list, which an app set up for Login for
Business will not honour.

### 4. Register the redirect URI

**Facebook Login for Business -> Settings -> Valid OAuth Redirect URIs:**

```
https://YOUR-DOMAIN/api/oauth/meta/callback
```

Meta rejects an unregistered domain with "the domain of this URL is not
registered in the app's domains", and it generally refuses plain
`http://localhost`, so use a tunnel domain for local work and register that.

### 5. Set the webhooks

Choose any string as the verify token, save it in the console's *Webhook Verify
Token* **first**, then register these. The console prints both URLs with copy
buttons.

| Product | Callback URL | Subscribe to |
|---|---|---|
| Messenger | `https://YOUR-CONVEX.convex.site/webhooks/meta` | `messages`, `messaging_postbacks`, `message_deliveries`, `message_reads` |
| Instagram | the same URL | `messages`, `messaging_postbacks`, `message_reactions` |
| WhatsApp | `https://YOUR-CONVEX.convex.site/webhooks/whatsapp` | `messages` |

`.convex.site`, not `.convex.cloud`. Saving the token before clicking Verify
matters: the handshake reads it from the database, and an unconfigured token is
refused rather than treated as a match.

### 6. Instagram credentials, only for Instagram-login DMs

**Instagram -> API setup with Instagram login** issues a second app ID and
secret, distinct from step 1. They go in *Instagram App ID* and *Instagram App
Secret*. Skip this if you only run WhatsApp and Messenger.

### 7. App Review, then Live

Submit the step 3 permissions for review, then switch the app to Live. In
Development mode only people holding a role on the app can connect, which is
enough for testing.

### Reusing an existing app

An app that already has these permissions approved can be pointed at this
platform by doing steps 4 and 5 alone, since our URLs will not be registered on
it. The consent screen then shows that app's name and logo to your customers,
which is fine while testing and wrong in production.

## How each channel connects

| Channel | Flow | Identity stored |
|---|---|---|
| Messenger | OAuth redirect, then Page discovery | Page ID |
| Instagram | OAuth redirect, then the Page's linked IG Business account | IG Business account ID |
| WhatsApp | OAuth redirect, then WhatsApp Business Account discovery | Phone number ID |
| Telegram | Bot token, entered by the customer | Bot ID |
| Slack | OAuth v2 install into the customer's workspace | Team ID |
| Teams | Customer registers their Microsoft 365 tenant ID | Azure AD tenant ID |

Meta channels all take the same redirect. The exchange, account discovery,
webhook subscription and persistence happen in `public/metaOAuth.ts` inside
Convex, so the app secret never reaches the web tier. The Next.js route's only
job is verifying the signed `state` against the live Clerk session.

Slack's `oauth.v2.access` returns the workspace bot token directly, so there is
no long-lived swap step the way Meta needs.

Teams is claimed rather than authorised: the bot is installed from the
customer's own Teams catalogue and starts posting activities, each naming the
Azure AD tenant it came from, and the customer registers that tenant ID from
their dashboard. Claiming is exclusive, so one organization cannot capture
another's traffic.

WhatsApp discovery reads **both** the `owned_` and `client_` WhatsApp account
edges. A number a partner shared with the business appears only on the second,
so reading one edge silently loses it. Each account is subscribed to the webhook
before its numbers are stored, because a number saved but unsubscribed looks
connected and receives nothing.

**Not built:** WhatsApp Embedded Signup. It exists to create a WhatsApp Business
Account for a customer who has none, which is a signup flow. Connecting an
account the customer already owns is a sign-in, and that is all this product
needs.

## Webhooks

| Path | Verified by |
|---|---|
| `/webhooks/meta` | `X-Hub-Signature-256`, Meta **or** Instagram app secret |
| `/webhooks/whatsapp` | `X-Hub-Signature-256` |
| `/webhooks/slack` | `X-Slack-Signature` over `v0:{timestamp}:{body}` |
| `/webhooks/teams` | Azure-issued RS256 JWT |
| `/webhooks/telegram` | per-channel secret token header |

Convex serves HTTP actions from a different origin than the API: `.convex.site`
instead of `.convex.cloud` when deployed, and the next port up locally (API on
3210 means webhooks on 3211). `lib/convexUrl.ts` exports `CONVEX_SITE_URL` for
this; do not hand-roll the swap.

Three details worth keeping:

- **Empty verify tokens must not verify.** Unconfigured, the stored token is
  `""`, and a caller sending `hub.verify_token=` would otherwise match it.
- **Slack's timestamp is a replay guard.** It is part of the signed material for
  exactly that reason; anything older than five minutes is refused even with a
  valid signature.
- **Teams authentication must not fail open.** The handler acks with 200 so
  Teams does not retry a message already refused, so token verification runs in
  its own try/catch that returns 401 on any error. Microsoft's signing keys are
  fetched from its OpenID configuration, cached for an hour, and refetched once
  on an unknown key ID, which is how a rotation presents.

## Environment variables

Neither of these is in the repo, and without them OAuth throws at request time
rather than failing gracefully. Both are required in every deployed environment.

| Variable | Purpose |
|---|---|
| `OAUTH_STATE_SECRET` | HMAC key signing the OAuth `state` parameter |
| `OAUTH_CALLBACK_TOKEN` | Shared secret proving a callback came from our own route; must match on the Convex deployment and the web app |

Platform credentials can still come from environment variables
(`META_APP_ID`, `META_APP_SECRET`, `SLACK_SIGNING_SECRET`, and so on) but the
admin console is the intended source: it allows a key rotation without a
redeploy, and it shows which provider is actually configured.

## Reading secrets back

Never. `admin/channelConfig.list` masks every secret to first four and last four
characters. The App ID and login configuration ID are returned in full because
they are public values that appear in the login URL. A blank input on save means
"keep the stored value"; clearing is a separate, explicit action.
