Skip to main content

lib/ex_line/webhook.ex

defmodule ExLine.Webhook do
  @moduledoc """
  Parse incoming LINE webhook payloads into event structs.

  `parse/1` is **total — it never raises**. This is required for forward
  compatibility: LINE adds new event/message types, enum values, and fields without
  notice, and the server must keep working
  ([dev guidelines](https://developers.line.biz/en/docs/messaging-api/development-guidelines/)).
  Accordingly:

    * an unrecognized event/message `type` degrades to `ExLine.Webhook.Event.Unknown` /
      `ExLine.Webhook.Message.Unknown` (mirroring the official SDKs),
    * unknown fields are ignored (the original payload is kept in each struct's `raw`),
    * a single malformed event degrades to `Event.Unknown` rather than failing the batch.

  Verify the signature first with `ExLine.Webhook.Signature` / `ExLine.Webhook.Plug`.

  ## Example

      body |> Jason.decode!() |> ExLine.Webhook.parse() |> Enum.each(&MyRouter.call/1)

  Ref: https://developers.line.biz/en/reference/messaging-api/#webhook-event-objects
  """

  alias ExLine.Webhook.{Event, Message, Source}

  @type event ::
          Event.Message.t()
          | Event.Postback.t()
          | Event.Follow.t()
          | Event.Unfollow.t()
          | Event.Join.t()
          | Event.Leave.t()
          | Event.MemberJoined.t()
          | Event.MemberLeft.t()
          | Event.Unsend.t()
          | Event.VideoPlayComplete.t()
          | Event.Beacon.t()
          | Event.AccountLink.t()
          | Event.Membership.t()
          | Event.Activated.t()
          | Event.Deactivated.t()
          | Event.BotSuspended.t()
          | Event.BotResumed.t()
          | Event.Module.t()
          | Event.PnpDeliveryCompletion.t()
          | Event.Unknown.t()

  @doc """
  Parses a decoded webhook request (`%{"events" => [...]}`) or a list of raw events
  into a list of event structs.
  """
  @spec parse(map() | [map()]) :: [event()]
  def parse(%{"events" => events}) when is_list(events), do: Enum.map(events, &parse_event/1)
  def parse(events) when is_list(events), do: Enum.map(events, &parse_event/1)
  def parse(_), do: []

  @doc """
  Parses a single raw event map into an event struct. Never raises — anything it
  can't model becomes an `Event.Unknown`.
  """
  @spec parse_event(map()) :: event()
  def parse_event(raw) when is_map(raw) do
    do_parse_event(raw)
  rescue
    _ -> %Event.Unknown{type: Map.get(raw, "type"), raw: raw}
  end

  def parse_event(raw), do: %Event.Unknown{type: nil, raw: raw}

  defp do_parse_event(%{"type" => "message"} = raw) do
    struct(Event.Message, [
      {:reply_token, raw["replyToken"]},
      {:message, Message.parse(raw["message"])} | common(raw)
    ])
  end

  defp do_parse_event(%{"type" => "postback"} = raw) do
    struct(Event.Postback, [
      {:reply_token, raw["replyToken"]},
      {:postback, postback(raw["postback"])} | common(raw)
    ])
  end

  defp do_parse_event(%{"type" => "follow"} = raw) do
    struct(Event.Follow, [
      {:reply_token, raw["replyToken"]},
      {:follow, raw["follow"]} | common(raw)
    ])
  end

  defp do_parse_event(%{"type" => "unfollow"} = raw), do: struct(Event.Unfollow, common(raw))

  defp do_parse_event(%{"type" => "join"} = raw) do
    struct(Event.Join, [{:reply_token, raw["replyToken"]} | common(raw)])
  end

  defp do_parse_event(%{"type" => "leave"} = raw), do: struct(Event.Leave, common(raw))

  defp do_parse_event(%{"type" => "memberJoined"} = raw) do
    struct(Event.MemberJoined, [
      {:reply_token, raw["replyToken"]},
      {:joined, members(raw["joined"])} | common(raw)
    ])
  end

  defp do_parse_event(%{"type" => "memberLeft"} = raw) do
    struct(Event.MemberLeft, [{:left, members(raw["left"])} | common(raw)])
  end

  defp do_parse_event(%{"type" => "unsend"} = raw) do
    struct(Event.Unsend, [{:unsend, raw["unsend"]} | common(raw)])
  end

  defp do_parse_event(%{"type" => "videoPlayComplete"} = raw) do
    struct(Event.VideoPlayComplete, [
      {:reply_token, raw["replyToken"]},
      {:video_play_complete, raw["videoPlayComplete"]} | common(raw)
    ])
  end

  defp do_parse_event(%{"type" => "beacon"} = raw) do
    struct(Event.Beacon, [
      {:reply_token, raw["replyToken"]},
      {:beacon, raw["beacon"]} | common(raw)
    ])
  end

  defp do_parse_event(%{"type" => "accountLink"} = raw) do
    struct(Event.AccountLink, [
      {:reply_token, raw["replyToken"]},
      {:link, raw["link"]} | common(raw)
    ])
  end

  defp do_parse_event(%{"type" => "membership"} = raw) do
    struct(Event.Membership, [
      {:reply_token, raw["replyToken"]},
      {:membership, raw["membership"]} | common(raw)
    ])
  end

  defp do_parse_event(%{"type" => "activated"} = raw) do
    struct(Event.Activated, [{:chat_control, raw["chatControl"]} | common(raw)])
  end

  defp do_parse_event(%{"type" => "deactivated"} = raw),
    do: struct(Event.Deactivated, common(raw))

  defp do_parse_event(%{"type" => "botSuspended"} = raw),
    do: struct(Event.BotSuspended, common(raw))

  defp do_parse_event(%{"type" => "botResumed"} = raw),
    do: struct(Event.BotResumed, common(raw))

  defp do_parse_event(%{"type" => "module"} = raw) do
    struct(Event.Module, [{:module, raw["module"]} | common(raw)])
  end

  defp do_parse_event(%{"type" => "delivery"} = raw) do
    struct(Event.PnpDeliveryCompletion, [{:delivery, raw["delivery"]} | common(raw)])
  end

  defp do_parse_event(%{"type" => type} = raw),
    do: struct(Event.Unknown, [{:type, type} | common(raw)])

  defp do_parse_event(raw), do: %Event.Unknown{type: nil, raw: raw}

  # Common envelope shared by every event. `type` is included so concrete structs
  # carry it too.
  defp common(raw) do
    [
      type: raw["type"],
      mode: raw["mode"],
      timestamp: raw["timestamp"],
      source: Source.parse(raw["source"]),
      webhook_event_id: raw["webhookEventId"],
      delivery_context: raw["deliveryContext"],
      raw: raw
    ]
  end

  defp postback(%{"data" => data} = pb), do: %{data: data, params: pb["params"]}
  defp postback(pb), do: pb

  defp members(%{"members" => members}) when is_list(members) do
    %{members: Enum.map(members, &Source.parse/1)}
  end

  defp members(other), do: other
end