# LINE (Manage · `Src\Line`)

**Context:** Manage portal · **Nav:** Messages → Inbox (no LINE nav entry of its own); accounts are added on Messages → Settings → **Channels** → *Add channel* → **LINE** · **Routes:** `manage.line.*` (connect + thread actions) + the public `webhooks.line.channel` (`POST /webhooks/line/{channel uuid}`) · **Permissions:** `view-line` (inbox), `view-line-settings` / `manage-line` (connect, verify, rename, disconnect) · **Hub:** [manage/messages](/docs/modules_handbook/manage/messages/readMe.md)

A two-way **LINE Official Account ↔ inbox**, shown in the **unified Messages inbox** next to WhatsApp and Messenger (built 2026-09-18). Agents receive and answer the people who added a LINE Official Account as a friend without leaving the inbox. Built by mirroring the [Messenger](/docs/modules_handbook/manage/messages/messenger/readMe.md) module (own tables, own webhook, own send path, merged only on the read side). Human-operated — no AI auto-reply, flows or broadcasts yet (see *Deferred*). Every LINE friend who writes becomes (or is linked to) a **lead** (2026-09-19), so chats reach the agent who owns it — in the web inbox, on the lead page and in the **Sales Agent app** (`/agent-api/line/*`, see [Agent App](#agent-app-agent-apiline)).

## What it does
- **Connect an account.** On Channels → *Add channel* the Type selector offers **LINE** (to anyone holding `manage-line`). The admin pastes two things from LINE Developers: the **channel secret** (Basic settings) and the **long-lived channel access token** (Messaging API tab). On save we check the token with LINE, **set the account's webhook URL on LINE for them**, and ask LINE to **test** it. Connected accounts are listed in their own **LINE Official Accounts** card under the numbers table, with the webhook's health, its URL (copy button), **Verify webhook**, rename and disconnect.
- **Receive.** Text, image, video, audio, file, location and sticker messages; *follow* (added / unblocked the account), *unfollow* (blocked it), *postback* (a tapped button) and *unsend* (the person took a message back → the bubble shows "This message was deleted"). Media is copied into private storage; stickers render straight from LINE's public sticker CDN.
- **Send.** Text (≤ 5,000 chars) and pictures (JPEG/PNG ≤ 10 MB; a caption goes as a follow-up text). A reply within ~50 s of the customer's last message goes out as a **free reply**; anything later is a **push**, which counts against the account's monthly message quota.

## How it works
- **One webhook per account.** Every channel has its own URL, `/webhooks/line/{uuid}` (`LineChannel::webhookUrl()`). The uuid names the channel, so the receiver (`Webhooks\LineChannelWebhookController`) knows which **channel secret** to check `X-Line-Signature` against before trusting any byte of the body — no "find the channel from the payload" step. It answers 200 fast and queues `ProcessInboundLineWebhook`; a signed delivery with no events (LINE's *Verify* button, our connect-time test) is simply acknowledged. `last_webhook_at` is stamped at most once a minute.
- **The connect test proves the secret.** LINE signs its test event with the channel secret and the receiver only answers 200 to a valid signature, so `POST /v2/bot/channel/webhook/test` succeeding means *reachable AND right secret*. A 403 from the test is reported as "the channel secret is wrong". The outcome is stored (`webhook_verified_at` / `webhook_error`) and shown on the card. **Setting the endpoint does not switch "Use webhook" on** — that toggle, and turning off LINE's own auto-reply / greeting messages, stay manual (the modal and the success flash say so).
- **Channel model.** `LineChannel` (`line_channels`): our `name`, the account's own LINE user id `bot_user_id` (the webhook `destination`; unique — reconnecting the same account **restores** the soft-deleted row and its history), `basic_id` (@id), `display_name`, `picture_url`, and `provider_config` (`encrypted:array` + `$hidden`: `channel_secret`, `channel_access_token`). An account connected before by a team this admin cannot see is refused rather than moved.
- **Identity.** `LineContact` is deduped by `UNIQUE(channel_id, line_user_id)` — a LINE user id is scoped to the account's LINE *provider*, so never dedupe on it alone. LINE gives a bot **no phone or email**, so the CRM link (`user_id`) is made by `LineContactLinker` instead of a phone match — see *Linking a LINE friend to a lead* below. The profile (display name, picture, status message, language) is fetched the first time we meet someone; LINE only serves it for a current friend. `is_following` flips to false on *unfollow* and back on *follow*.
- **No messaging window — following is what matters.** Unlike WhatsApp/Messenger there is no 24-hour window: a push is allowed any time. What stops delivery is the person having blocked the account, so the inbox's `can_send_freeform` for LINE is `contact.is_following`, the composer locks with that reason, and both `MessagesController` and `SendLineMessage` refuse up front.
- **Reply token vs push (cost).** Each customer event's `replyToken` is stored on the conversation (`reply_token`, `reply_token_at`, `$hidden`). `SendLineMessage` uses it when it is younger than `line.reply_token_ttl` (50 s) — claimed with a conditional UPDATE (`LineRepository::consumeReplyToken`) so two quick sends never both spend one single-use token. If LINE refuses the token (400 "Invalid reply token") the send falls back to a push. A push carries the **message uuid as `X-Line-Retry-Key`**, so a job retry after a push that actually landed is a no-op at LINE (409), not a duplicate on the customer's phone. `meta.send_mode` records `reply` / `push`.
- **Status ladder.** LINE sends a bot **no delivery or read receipts**, so an outbound message goes QUEUED → SENT or FAILED and stops (constants numbered to match WhatsApp/Messenger so the inbox's ticks render). Opening a thread marks it read for the team only; the customer sees nothing.
- **Idempotency.** `LineRepository::recordInbound()` dedupes on `line_message_id` (messages) or `webhook_event_id` (follow / unfollow / postback rows) behind `Cache::lock("line:inbound:{channel}:{key}")` + two unique indexes — LINE redelivers webhooks.
- **Media.** `DownloadLineMedia` fetches `api-data.line.me/v2/bot/message/{id}/content` (or the external URL when `contentProvider.type = external`) and stores it with `MediaService` (collection `line`, owner = the `LineMessage`). A video/audio still transcoding answers **202** → the job `release(30)`s and tries again (6 tries). The inbox's *Download* button re-queues it (`POST manage/line/conversations/{id}/messages/{uuid}/fetch-media`). Outbound pictures are handed to LINE as a **7-day signed URL** (`line.outbound_media_url_minutes`) because LINE fetches them from our private bucket later.
- **Linking a LINE friend to a lead (2026-09-19).** [`Src\Line\Services\LineContactLinker`](/src/Line/Services/LineContactLinker.php), called by the inbound job on every customer **message or postback** (never on a follow), is the LINE twin of WhatsApp's `ContactLinker`. WhatsApp matches the phone; LINE has none, so:
  1. **Invite code.** A lead's **"Chat on LINE"** link (`line_invites`, one reusable per account + lead, `LineInvite::url()` = `https://line.me/R/oaMessage/{@id}/?{text}`) opens the account's chat with a first message pre-typed — `config('line.invite_text')`, default *"Hi! I'd like to chat. (ref: K7Q2MX)"*. When a message carries a code (`LineInvite::codeIn()`, 6 chars without 0/O/1/I/L), the sender is linked to that lead. If the contact is **already** linked to another lead (they wrote before using the link), nothing moves: a **merge request** is filed (`VerifiedIdentityPair::SOURCE_LINE_INVITE`, the invited lead proposed as survivor) and an admin decides in Setting → Merge Requests — anyone holding the link could have sent it.
  2. **Otherwise, a new lead** — the WhatsApp policy for an unknown number (2026-07-13): a thin account (LINE display name, no email / phone, Non-Member role) + an unassigned lead stamped with the **channel's `group_id`**, attributed `LeadFunnel::SOURCE_OTHER`. It then flows through assignment / Lead Distribution like any inbound lead. A duplicate of an existing lead is fixed with the Leads page's **Merge duplicate** (the existing merge — `line_contacts.user_id` is repointed by it). There is deliberately no LINE-specific merge path.
  - Why not on follow: an invite-link friend usually follows first and then sends the code; minting a lead on the follow would leave a throwaway duplicate every time.
  - A per-contact `Cache::lock` stops two racing deliveries minting two leads. Linking is best-effort in the job — a failure is logged and never loses the message.
  - **Where agents get the link:** the lead page (Channel → **LINE** tab: the threads + a *Get invite link* box) — `POST manage/line/invites {lead, channel?}` (`Manage\Line\InvitesController`, `view-line` + `LeadVisibility::allows`) — and the app (`POST agent-api/line/invites`). Accounts offered: platform ones (`group_id` NULL) plus the agent's and the lead's group.
- **Where it renders.** No LINE inbox page: `Manage\Whatsapp\InboxController` merges LINE threads into the list (own top window, merged by `last_activity_at`), opens a LINE thread when the uuid is neither WhatsApp nor Messenger, adds LINE accounts to the channel filter (optgroup **LINE**) and serves the preview modal. **[`Src\Line\Support\InboxPresenter`](/src/Line/Support/InboxPresenter.php)** maps rows/messages into the WhatsApp inbox shape with `platform: 'line'` and translates the message type (`TYPE_MAP`). **It mirrors `InboxController::transformConversation()`; when a key is added there, add it here too.**
  - Filters: *tag*, *needs review* and *groups* are WhatsApp concepts and **exclude** LINE rows; *unread* and *unreplied* apply (unreplied = no outbound since `last_inbound_at`).
  - WhatsApp-only affordances are hidden on a LINE thread (`supportsWhatsappActions` is now `platform === 'whatsapp'`): tags, flows, AI drafts/handoff, templates, voice notes, reactions, quote-reply, location, edit, click-to-call. The attach menu offers **Photo** only.
  - Realtime: `NewLineMessage` / `LineMessageStatusUpdated` (also carries `revoked`) / `LineConversationRead` on `line.inbox` + `line.conversation.{uuid}`, gated by `broadcastWhen() => config('line.realtime')` (`LINE_REALTIME`, default off → the inbox's 6 s poll). `NewLineMessage` broadcasts the **InboxPresenter** shape, identical to the send XHR's response.
- **Visibility (RBAC).** Threads follow the lead, exactly like WhatsApp/Messenger: `LeadVisibility::applyToConversations()` on the list, the deep-linked thread and the preview; `allowsConversation()` / `allowsContact()` (403) on every thread action. A LINE contact is linked to a lead on its first message (below), so an agent sees the LINE chats of their own leads; a contact that has only followed (never written) is unlinked and visible at the "all" level only. The **accounts** themselves (Channels card, the channel filter) are scoped by `AccountVisibility::applyLineChannels()`, which reuses the Channels page's own permission set (`view-whatsapp-channels-all/group/team/own`) — LINE accounts are added on that page — with `allowsWhatsappChannel()` as the object check on verify / rename / disconnect.
- **Privacy purge & merge.** `line_contacts.user_id` is in `IdentityChildMap` (merge re-points; purge resolves contacts → conversations → messages → attachments, deleting their media, in `LeadRepository`), so a linked person's LINE history goes with their account once linking exists.

## Agent App (`/agent-api/line/*`)
The Sales Agent app's LINE inbox — the [WhatsApp adapter](/docs/modules_handbook/manage/messages/whatsapp/agent-app/readMe.md)'s contract, route for route, with **the same request and response shapes** plus `platform: 'line'` on every conversation, so the app can reuse its WhatsApp screens. Also mounted under `/api/line/*` (like WhatsApp). `auth:api` + `agent.staff`, throttle lane `agent-line` / `agent-line-send`.

| Endpoint | Notes |
|---|---|
| `GET line/conversations` | `limit`, `search` (LINE + CRM name), `unread=1`, `needs_reply=1`, `assigned_to=me\|<uuid>`; `meta.counts`, `meta.assignees`. No `channel_type` filter (one kind of account). |
| `GET line/conversations/{uuid}` | 50 messages, `before` cursor. |
| `POST line/conversations/{uuid}/read` | Team-global read; LINE shows the customer nothing. |
| `POST line/conversations/{uuid}/messages` | `{request_uuid, body ≤ 5000}` — idempotent: the request uuid becomes the message uuid; a replay returns it, a different body on the same uuid is 409. |
| `POST line/conversations/{uuid}/media` | `{request_uuid, file (JPEG/PNG ≤ 10 MB), caption?}` — picture + caption go out in ONE LINE request. |
| `GET line/conversations/{uuid}/sends/{requestUuid}` | Did a send reach us? Read-only. |
| `POST line/invites` | `{lead, channel?}` → the lead's "Chat on LINE" link, to send over WhatsApp etc. |

- **Calling from a LINE thread.** A LINE conversation's `lead_uuid` opens the Lead and starts a recorded call exactly like a WhatsApp one — `AgentLineInbox::showsLead()` is half of the rule in the [WhatsApp adapter](/docs/modules_handbook/manage/messages/whatsapp/agent-app/readMe.md) (*A listed `lead_uuid` opens and can be called*).
- Shape differences from WhatsApp: `phone` null, `channel_type: 'line'`, `channel_phone` = the account's @id, `reply_expires_at` null (no window), `supports_voice` false; `can_reply` false with a reason when the person blocked the account or the contact is blocked. [`Src\Line\Support\AgentLineInbox`](/src/Line/Support/AgentLineInbox.php) is the twin of `AgentInbox` (scope = `view-line` + `LeadVisibility`; `unread_count` / `needs_reply` computed like `InboxState`, a *sent* outbound at/after the latest inbound answering it).
- **Send idempotency under a race, not just a sequential retry.** [`LineRepository::sendText()` / `sendMedia()`](/src/Line/Repositories/LineRepository.php) — the LINE twin of `AgentMediaReplyRepository::create()` — lock the conversation (`lockForUpdate()`) for the whole check-then-create, so a retry that races the original request blocks on the lock, then finds and returns the winner's already-created message instead of attempting a second insert; the controller methods are thin wrappers. A raw MySQL 1062 on `line_messages.uuid` can still happen despite the lock: `AgentLineInbox::conversations()` runs a plain SELECT (spatie's `checkPermissionTo()`) *before* `lockForUpdate()`, and under REPEATABLE READ that earlier SELECT fixes the transaction's snapshot for every later plain read — so the in-lock recheck can itself miss a winner that committed in between, and the insert collides. Rather than surface a 409 to an agent who really did just send it, both methods re-read the uuid in a fresh, unsnapshotted query outside the rolled-back transaction: a match in the same conversation replays (event + `SendLineMessage` dispatch skipped, same as a normal replay) and, for media, deletes this request's own now-unused upload; no match (or a match in a *different* conversation — the residual case a lock cannot serialize at all: the same `request_uuid` reused elsewhere) still 409s. `sendMedia()` never deletes an upload once its writes are confirmed to have gone through (a `$committed` flag set as the transaction closure's last statement) — a failure surfacing during commit acknowledgement must not delete the file a just-committed message now points to.
- **A replay never re-dispatches `SendLineMessage`, on purpose.** WhatsApp's `SendAgentWhatsAppReply` is a *unique* job (`ShouldBeUnique`), so `AgentWhatsappController::store()` can safely re-dispatch on every request whose message ended up `QUEUED`, replay or not — the queue itself collapses a duplicate. `SendLineMessage` carries no such uniqueness, and its free reply-token path has no equivalent of the push path's `X-Line-Retry-Key` de-duplication, so dispatching it twice for one message risks a real second delivery to the customer, not a redundant one. Both `store()` and `storeMedia()` therefore gate the dispatch on `created` / `attached` — true only for the request that actually wrote the row — never on the message's status. **Known gap:** because of this, a message stuck `QUEUED` after a dispatch that failed to enqueue (e.g. the queue connection was briefly down) has no sweeper to recover it — nothing re-dispatches on a later replay, unlike WhatsApp's unique-job recovery.
- **Attachments are flattened to `{name, url, mime}`** — the app has no map view and no LINE-specific bubble, so a sticker or location needs to already look like an image or a link before it reaches the phone: a **sticker** gets its public LINE CDN image (`LineAttachment::stickerImageUrl()`) and `mime: image/png` so the app shows it inline (null `url` when the sticker id is missing, which the app renders as "unavailable"); a **location** gets a Google Maps link (`LineAttachment::mapsUrl()`, by coordinates or else by address) instead of raw `latitude`/`longitude` — the web inbox gets those fields directly and draws its own link client-side (`Components/Conversation/MessageAttachment.vue`). Both helpers live on `LineAttachment` so the web presenter and the app can't drift on the sticker CDN URL; the maps link is app-only since the web never needed a backend-built one.
- **Push:** a customer message/postback queues `SendLineInboundPushNotifications` → [`Src\AgentApp\Services\LineInboundPushNotifier`](/src/AgentApp/Services/LineInboundPushNotifier.php) — same audience rule as WhatsApp (the lead's account manager + project role holders who can open the thread), once per device via `agent_app_push_deliveries` kind **`line_inbound`**. FCM data `{type: 'line_inbound', conversation_uuid}`. The alert names the sender and nothing else: title = the name the LINE inbox lists (`AgentLineInbox::displayName()` — profile name, else LINE display name; filtered by `FirebaseCloudMessaging::safeSenderName()`), body "New LINE message"; with no real name (the inbox's "LINE User <id tail>" placeholder counts as none) it is the generic "New LINE message" / "Open the app to view the message". Never the message, the LINE user id or a URL, and never the name in `data` or the logs — same rule as WhatsApp, see the [agent-app handbook](/docs/modules_handbook/manage/messages/whatsapp/agent-app/readMe.md). Sent on the app's existing `whatsapp_messages` Android channel (an installed build has no other), which is `VISIBILITY_PRIVATE` — Android hides the text on a secure lock screen. **The app must route `line_inbound` to its LINE thread** — until it does, a tap opens nothing specific.
- **Which tab shows at all:** `GET /agent-api/agent-channels` (documented in the [WhatsApp Agent App handbook](/docs/modules_handbook/manage/messages/whatsapp/agent-app/readMe.md)) tells the app whether this company has WhatsApp, LINE, or both linked — this LINE inbox only appears when `view-line` is permitted AND an active LINE account exists.

## Setup (operational)
1. **Migrate:** `php artisan migrate` (five `line_*` tables + `2026_09_18_100006_grant_line_permissions`, which creates `view-line` / `view-line-settings` / `manage-line` and grants them to super-admin + admin). Like Messenger, **no agency role gets LINE by default** — grant it on the Roles page.
2. **Seeing the Channels page** also needs one of the `view-whatsapp-channels-*` permissions (it is the same page).
3. **In LINE:** the Official Account must have the Messaging API enabled (LINE Official Account Manager → Settings → Messaging API). In LINE Developers, copy the channel secret and issue/copy the long-lived channel access token.
4. **Connect** on Channels → Add channel → LINE. Then in LINE Developers → Messaging API: **Use webhook = on**; in LINE Official Account Manager → Response settings: **auto-response and greeting messages off** (otherwise the customer gets LINE's canned answer as well as ours).
5. The webhook URL is built from **`LINE_WEBHOOK_BASE_URL`**, else **`APP_URL`** (never the host the admin is browsing on), and must be public HTTPS. To test locally, share the site (`valet share` / ngrok), set `LINE_WEBHOOK_BASE_URL` to the tunnel's https address (APP_URL stays the local domain), `php artisan config:clear`, and press **Verify webhook** — the account is pointed at the tunnel. Use the local domain for the UI itself: through the tunnel the page stays blank, because the Vite dev server (Vite 6) refuses module requests from a non-localhost origin.
6. Optional env: `LINE_WEBHOOK_BASE_URL` (tunnel testing only), `LINE_REALTIME=true` where Reverb runs; `LINE_REPLY_TOKEN_TTL`, `LINE_OUTBOUND_MEDIA_URL_MINUTES`, `LINE_CA_BUNDLE`.

> The older **`/webhooks/line`** route (`Webhooks\LineWebhookController`, env `LINE_CHANNEL_*`) is a separate single-account auto-reply proof from 2026-08-12. It is untouched; an account connected through Channels has its webhook pointed at its own `/webhooks/line/{uuid}` URL instead.

## Deferred (documented follow-ups)
- **Agent App screens** — the API below is built; the Flutter app needs its LINE list/thread (it can reuse the WhatsApp screens) and a `line_inbound` push route.
- **Staff detection** — WhatsApp links a staff phone to the admin instead of minting a lead; LINE cannot tell a colleague testing the account from a customer, so a colleague's message becomes a lead too.
- **AI auto-reply / flows / broadcasts** (LINE multicast/narrowcast + quota awareness) — the `Src\Ai` layer is channel-agnostic.
- **Group / room chats** — events whose `source.type` is `group` / `room` are logged and skipped.
- **Outbound video / audio** (needs a preview image and duration), LINE emoji rendering (`$` placeholders in text), mark-as-read (needs `markAsReadMode = manual`), the loading indicator, and the Messages **Dashboard** (counts WhatsApp + Messenger only).

## Related files
**Models** — [`src/Line/LineChannel.php`](/src/Line/LineChannel.php) · `LineContact` · `LineConversation` · `LineMessage` · `LineAttachment` · `LineInvite`
**Linking** — [`src/Line/Services/LineContactLinker.php`](/src/Line/Services/LineContactLinker.php) · lead page: [`src/Line/Support/LeadConversationPresenter.php`](/src/Line/Support/LeadConversationPresenter.php) + `Pages/Manage/Leads/Partials/Tabs/Channel/LineTab.vue` (Channel → LINE; `useLeadTabs.js`, `lineThreads` / `lineThreadCount` / `lineInviteChannels` props)
**Push** — `src/AgentApp/Services/LineInboundPushNotifier.php` · `app/Jobs/AgentApp/SendLineInboundPushNotifications.php` · `FirebaseCloudMessaging::sendLineInbound()`
**Repository** — [`src/Line/Repositories/LineRepository.php`](/src/Line/Repositories/LineRepository.php) (constructor-injected, no facade — the Messenger convention). Writes take nested arrays keyed `line_channel` / `line_contact` / `line_conversation` / `line_message` / `line_attachments`.
**Service** — [`src/Line/Services/LineClient.php`](/src/Line/Services/LineClient.php) (bot info, webhook endpoint + test, profile, push, reply, content)
**Presenters** — [`src/Line/Support/InboxPresenter.php`](/src/Line/Support/InboxPresenter.php) · [`src/Line/Support/MessagePresenter.php`](/src/Line/Support/MessagePresenter.php)
**Webhook** — [`app/Http/Controllers/Webhooks/LineChannelWebhookController.php`](/app/Http/Controllers/Webhooks/LineChannelWebhookController.php); route `webhooks.line.channel` in [`routes/main.php`](/routes/main.php) (CSRF-exempt via `webhooks/*`)
**Jobs** — [`app/Jobs/Line/ProcessInboundLineWebhook.php`](/app/Jobs/Line/ProcessInboundLineWebhook.php) · `SendLineMessage.php` · `DownloadLineMedia.php`
**Events** — `app/Events/Line/{NewLineMessage,LineMessageStatusUpdated,LineConversationRead}.php`; channel auth in [`routes/channels.php`](/routes/channels.php)
**Controllers** — `app/Http/Controllers/Manage/Line/{Channels,Inbox,Messages,Contacts,Invites}Controller.php` · Agent App: [`app/Http/Controllers/AgentApi/AgentLineController.php`](/app/Http/Controllers/AgentApi/AgentLineController.php) (+ `Requests/AgentApi/{ListAgentLineRequest,AgentLineQuery,SendAgentLineMediaRequest}.php`, routes in [`routes/agent-api.php`](/routes/agent-api.php)) · the merge in [`Manage/Whatsapp/InboxController.php`](/app/Http/Controllers/Manage/Whatsapp/InboxController.php) · the `lineChannels` prop in `Manage/Whatsapp/ChannelsController.php`
**Requests** — `app/Http/Requests/Manage/Line/Channels/{Store,Update}Request.php` · `Messages/{Store,StoreMedia}Request.php`
**Frontend** — [`Pages/Manage/Messages/Inbox.vue`](/resources/js/Pages/Manage/Messages/Inbox.vue) (`LINE_BASE`, `platformBase()`, `isLineThread`) · [`Pages/Manage/Messages/Channels/Index.vue`](/resources/js/Pages/Manage/Messages/Channels/Index.vue) (LINE card) · [`Channels/Partials/ChannelFormModal.vue`](/resources/js/Pages/Manage/Messages/Channels/Partials/ChannelFormModal.vue) (the LINE type) · `Components/Messages/MessagesTabs.vue` + `Layouts/ManageLayout.vue` (`view-line` gates)
**Visibility** — `Src\Auth\Support\AccountVisibility::applyLineChannels()` · `LeadVisibility` (unchanged, duck-typed)
**Config** — [`config/line.php`](/config/line.php)
**Migrations** — `database/migrations/2026_09_18_100001..100005_create_line_*` · `2026_09_18_100006_grant_line_permissions.php` · `2026_09_19_100001_create_line_invites_table.php`
**Tests** — `tests/Feature/Line/{LineChannelWebhookTest,LineChannelsTest,SendLineMessageTest,InboxVisibilityTest,LineLeadLinkingTest}.php` · `tests/Feature/AgentApi/AgentLineTest.php` · `tests/Feature/AgentApi/LineInboundPushTest.php`

**See also:** [Messages hub](/docs/modules_handbook/manage/messages/readMe.md) · [Messenger](/docs/modules_handbook/manage/messages/messenger/readMe.md) (the module this mirrors) · [shared/media](/docs/modules_handbook/shared/media/readMe.md)
