Skip to main content

README.md

# ExLine

An Elixir client for the [LINE](https://developers.line.biz/) platform — Messaging
API today, LIFF / LINE Login planned.

> Unofficial. Not affiliated with or endorsed by LY Corporation.

## Design

- **Credentials are values, never global.** Build an `ExLine.Client` and pass it per
  call. Multi-channel / multi-tenant apps are first-class — there is no global
  registry to fight.
- **HTTP is swappable.** Requests go through the `ExLine.Client.Adapter` behaviour
  (default: `ExLine.Client.Req`), so you can mock the network in tests.
- **Webhooks verify and route.** `ExLine.Webhook.Signature` / `ExLine.Webhook.Plug`
  verify the `x-line-signature`; `ExLine.EventRouter` dispatches events to handlers.

## Installation

```elixir
def deps do
  [
    {:ex_line, "~> 0.1.0"}
  ]
end
```

`:plug` is an optional dependency, only needed if you use `ExLine.Webhook.Plug` /
`ExLine.Webhook.BodyReader`.

## Configuration

You need credentials from the [LINE Developers Console](https://developers.line.biz/console/):

| Credential | Where it's used | From |
| --- | --- | --- |
| **Channel access token** | sending messages (`ExLine.Client`) | Messaging API channel |
| **Channel secret** | webhook signature (`ExLine.Webhook`) | Messaging API channel |

ExLine never holds global credential state — you pass what each call needs. There
are three ways to supply them, pick per use case:

**1. Per-call value (default; multi-channel / multi-tenant friendly).** Build a
client from wherever you store the token (DB row, etc.) and pass it in:

```elixir
client = ExLine.Client.new(access_token: channel.access_token)
ExLine.Api.Messaging.push(client, user_id, message)
```

**2. From application config (single-channel convenience).**

```elixir
# config/runtime.exs
config :ex_line,
  access_token: System.fetch_env!("LINE_CHANNEL_ACCESS_TOKEN"),
  channel_id: System.get_env("LINE_CHANNEL_ID")
```

```elixir
client = ExLine.Client.from_env()
```

**3. Webhook secret via a resolver (kept separate from the client).** The channel
secret belongs to a different trust boundary, so it is passed directly — as a
static value, or a `fn conn -> secret end` resolver that picks the right channel
at request time (see [Receiving webhooks](#receiving-webhooks)):

```elixir
plug ExLine.Webhook.Plug, secret: System.fetch_env!("LINE_CHANNEL_SECRET")
# or, multi-channel:
plug ExLine.Webhook.Plug, secret: fn conn -> MyApp.secret_for(conn) end
```

> Never commit tokens or secrets — load them from the environment.

## Sending messages

```elixir
client = ExLine.Client.new(access_token: "CHANNEL_ACCESS_TOKEN")

# push
ExLine.Api.Messaging.push(client, "U123...", ExLine.Message.text("hello"))

# reply (using a webhook replyToken)
ExLine.Api.Messaging.reply(client, reply_token, [
  ExLine.Message.text("hi"),
  ExLine.Message.Template.buttons("Pick one", [
    ExLine.Message.Action.message("A", "a"),
    ExLine.Message.Action.postback("B", "action=b")
  ])
])
```

Push supports idempotent retries via `X-Line-Retry-Key`:

```elixir
ExLine.Api.Messaging.push(client, "U123...", msg, retry_key: "a-uuid")
```

Errors come back as `{:error, %ExLine.Error{kind: kind}}` where `kind` is one of
`:transient`, `:quota_exceeded`, `:permanent`, or `:network` (see
`ExLine.Error.retryable?/1`).

## Receiving webhooks

Verify the signature (works with or without Plug):

```elixir
ExLine.Webhook.Signature.valid?(raw_body, signature, channel_secret)
```

With Plug, preserve the raw body in your parser, then verify in the pipeline. The
`:secret` option takes a static binary or a `fn conn -> secret end` resolver so you
can pick the right channel per request:

```elixir
plug Plug.Parsers,
  parsers: [:json],
  body_reader: {ExLine.Webhook.BodyReader, :read_body, []},
  json_decoder: Jason

plug ExLine.Webhook.Plug, secret: &MyApp.line_secret/1
```

## Routing events

```elixir
defmodule MyApp.LineRouter do
  use ExLine.EventRouter

  text "hello", MyApp.HelpHandler, :hello
  postback "buy", MyApp.ShopHandler, :buy
  follow MyApp.OnboardHandler, :welcome
  default MyApp.FallbackHandler, :unknown

  @impl true
  def before_action(event, assigns), do: {event, Map.put(assigns, :client, MyApp.client())}
end

defmodule MyApp.HelpHandler do
  use ExLine.EventHandler

  @impl true
  def handle_event(:hello, %{"replyToken" => token}, %{client: client}) do
    ExLine.Api.Messaging.reply(client, token, text("Need help?"))
    :ok
  end
end

# in your webhook controller, for each event:
MyApp.LineRouter.call(event, %{})
```

## Testing

Mock the adapter to assert outbound requests without hitting the network:

```elixir
# test_helper.exs
Mox.defmock(MyApp.LineAdapterMock, for: ExLine.Client.Adapter)

# in a test
client = ExLine.Client.new(access_token: "tok", adapter: MyApp.LineAdapterMock)

Mox.expect(MyApp.LineAdapterMock, :request, fn req ->
  assert req.url == "https://api.line.me/v2/bot/message/push"
  {:ok, %{status: 200, body: %{}}}
end)

ExLine.Api.Messaging.push(client, "U1", ExLine.Message.text("hi"))
```

## Status

Early. Implemented: client + adapter, message builders (text / sticker / buttons /
confirm + actions), `Messaging.reply` / `push`, webhook signature verification +
Plug, and the event routing DSL. Broader Messaging coverage (multicast / broadcast /
rich menu / content) and LIFF support are planned — see `notes/`.