Skip to main content

lib/amarula/protocol/usync/protocols.ex

defmodule Amarula.Protocol.USync.Protocols do
  @moduledoc """
  USync protocol definitions.

  Port of `src/WAUSync/Protocols/`. Each protocol contributes three things to
  a USync query:

    * `query_element/1` — the node placed inside `<query>` (what to fetch)
    * `user_element/2`  — the per-user node placed inside `<user>` (may be `nil`)
    * `parse/2`         — turns a result child node into a parsed value

  Protocols are addressed by atom (`:devices`, `:contact`, `:status`,
  `:disappearing_mode`, `:bot_profile`, `:lid`, `:username`). `parse/2` is also
  keyed by the wire tag string so the result parser can dispatch directly.
  """

  alias Amarula.Protocol.Binary.Node
  alias Amarula.Protocol.Binary.NodeUtils

  # --- query elements (inside <query>) ---

  @spec query_element(atom()) :: Node.t()
  def query_element(:devices),
    do: %Node{tag: "devices", attrs: %{"version" => "2"}, content: nil}

  def query_element(:contact), do: %Node{tag: "contact", attrs: %{}, content: nil}
  def query_element(:status), do: %Node{tag: "status", attrs: %{}, content: nil}

  def query_element(:disappearing_mode),
    do: %Node{tag: "disappearing_mode", attrs: %{}, content: nil}

  def query_element(:bot_profile), do: %Node{tag: "bot_profile", attrs: %{}, content: nil}
  def query_element(:lid), do: %Node{tag: "lid", attrs: %{}, content: nil}
  def query_element(:username), do: %Node{tag: "username", attrs: %{}, content: nil}

  # --- user elements (inside <user>); nil means "omit for this user" ---

  @spec user_element(atom(), map()) :: Node.t() | nil
  def user_element(:devices, _user), do: nil
  def user_element(:status, _user), do: nil
  def user_element(:disappearing_mode, _user), do: nil
  def user_element(:bot_profile, _user), do: nil

  def user_element(:lid, %{lid: lid}) when is_binary(lid),
    do: %Node{tag: "lid", attrs: %{"jid" => lid}, content: nil}

  def user_element(:lid, _user), do: nil

  def user_element(:contact, %{phone: phone}) when is_binary(phone),
    do: %Node{tag: "contact", attrs: %{}, content: phone}

  def user_element(:contact, %{username: username} = user) when is_binary(username) do
    attrs =
      %{"username" => username}
      |> maybe_put("pin", Map.get(user, :username_key))
      |> maybe_put("lid", Map.get(user, :lid))

    %Node{tag: "contact", attrs: attrs, content: nil}
  end

  def user_element(:contact, %{type: type}) when is_binary(type),
    do: %Node{tag: "contact", attrs: %{"type" => type}, content: nil}

  def user_element(:contact, _user), do: %Node{tag: "contact", attrs: %{}, content: nil}

  def user_element(:username, _user), do: nil

  # --- result parsing (keyed by wire tag) ---

  @doc """
  Parse a result child node by its wire tag. Returns `nil` when the value
  should be dropped from the result map (matches Baileys' `null` filtering).
  """
  @spec parse(String.t(), Node.t()) :: any()
  def parse("devices", %Node{} = node), do: parse_devices(node)
  def parse("contact", %Node{} = node), do: NodeUtils.get_attr(node, "type") == "in"
  def parse("status", %Node{} = node), do: parse_status(node)
  def parse("lid", %Node{} = node), do: NodeUtils.get_attr(node, "val")
  def parse(_tag, _node), do: nil

  # devices → %{device_list: [...], key_index: %{...} | nil}
  defp parse_devices(node) do
    device_list_node = NodeUtils.get_binary_node_child(node, "device-list")
    key_index_node = NodeUtils.get_binary_node_child(node, "key-index-list")

    %{
      device_list: parse_device_list(device_list_node),
      key_index: parse_key_index(key_index_node)
    }
  end

  defp parse_device_list(%Node{content: content}) when is_list(content) do
    content
    |> Enum.filter(&match?(%Node{tag: "device"}, &1))
    |> Enum.map(fn %Node{attrs: _} = device ->
      %{
        id: device |> NodeUtils.get_attr("id") |> to_int(),
        key_index: device |> NodeUtils.get_attr("key-index") |> to_int(),
        is_hosted: NodeUtils.get_attr(device, "is_hosted") == "true"
      }
    end)
  end

  defp parse_device_list(_), do: []

  defp parse_key_index(%Node{tag: "key-index-list", attrs: _, content: content} = node) do
    %{
      timestamp: node |> NodeUtils.get_attr("ts") |> to_int(),
      signed_key_index: if(is_binary(content), do: content),
      expected_timestamp: node |> NodeUtils.get_attr("expected_ts") |> to_int_or_nil()
    }
  end

  defp parse_key_index(_), do: nil

  # status → %{status: string | nil, set_at: DateTime}
  defp parse_status(node) do
    raw = status_content(node)
    code = node |> NodeUtils.get_attr("code") |> to_int_or_nil()

    status =
      cond do
        is_binary(raw) and raw != "" -> raw
        code == 401 -> ""
        true -> nil
      end

    set_at =
      node
      |> NodeUtils.get_attr("t")
      |> to_int()
      |> DateTime.from_unix!()

    %{status: status, set_at: set_at}
  end

  defp status_content(%Node{content: content}) when is_binary(content), do: content
  defp status_content(_), do: nil

  # --- helpers ---

  defp maybe_put(map, _key, nil), do: map
  defp maybe_put(map, key, value), do: Map.put(map, key, value)

  defp to_int(nil), do: 0
  defp to_int(value) when is_integer(value), do: value

  defp to_int(value) when is_binary(value) do
    case Integer.parse(value) do
      {int, _} -> int
      :error -> 0
    end
  end

  defp to_int_or_nil(nil), do: nil

  defp to_int_or_nil(value) when is_binary(value) do
    case Integer.parse(value) do
      {int, _} -> int
      :error -> nil
    end
  end
end