Current section

Files

Jump to
amarula lib amarula protocol usync.ex
Raw

lib/amarula/protocol/usync.ex

defmodule Amarula.Protocol.USync do
@moduledoc """
USync (user sync) query builder and result parser.
Port of Baileys' `WAUSync` (`src/WAUSync/`). A USync query bundles one or
more *protocols* (devices, contact, status, lid, ...) and a list of *users*
to look up, then is serialized into a single `iq` get with `xmlns="usync"`.
This module is pure: it builds the request `Node` and parses the response
`Node`. The socket layer is responsible for sending the IQ and correlating
the reply (see `Amarula.Connection`'s tracked-IQ
machinery).
## Usage
USync.new()
|> USync.with_context("message")
|> USync.with_protocol(:devices)
|> USync.with_protocol(:lid)
|> USync.with_user(%{id: "1234@s.whatsapp.net"})
|> USync.build_iq()
Parse the result of the matching `iq` reply with `parse_result/2`:
USync.parse_result(query, reply_node)
#=> %{list: [%{"devices" => ..., id: "1234@s.whatsapp.net"}], side_list: []}
"""
alias Amarula.Protocol.Binary.Node
alias Amarula.Protocol.Binary.NodeUtils
alias Amarula.Protocol.Crypto.Constants
alias Amarula.Protocol.USync.Protocols
@type protocol ::
:devices | :contact | :status | :disappearing_mode | :bot_profile | :lid | :username
@type user :: %{
optional(:id) => String.t(),
optional(:lid) => String.t(),
optional(:phone) => String.t(),
optional(:username) => String.t(),
optional(:username_key) => String.t(),
optional(:type) => String.t(),
optional(:persona_id) => String.t()
}
@type t :: %__MODULE__{
protocols: [protocol()],
users: [user()],
context: String.t(),
mode: String.t()
}
defstruct protocols: [], users: [], context: "interactive", mode: "query"
@type result_entry :: %{required(:id) => String.t(), optional(String.t()) => any()}
@type result :: %{list: [result_entry()], side_list: [result_entry()]}
@doc "Start a new, empty USync query."
@spec new() :: t()
def new, do: %__MODULE__{}
@doc "Set the query mode (default `\"query\"`)."
@spec with_mode(t(), String.t()) :: t()
def with_mode(%__MODULE__{} = q, mode), do: %{q | mode: mode}
@doc "Set the query context (default `\"interactive\"`)."
@spec with_context(t(), String.t()) :: t()
def with_context(%__MODULE__{} = q, context), do: %{q | context: context}
@doc """
Append a protocol to the query. Order is preserved to match the request
element order Baileys produces.
"""
@spec with_protocol(t(), protocol()) :: t()
def with_protocol(%__MODULE__{protocols: ps} = q, protocol)
when protocol in [
:devices,
:contact,
:status,
:disappearing_mode,
:bot_profile,
:lid,
:username
] do
%{q | protocols: ps ++ [protocol]}
end
@doc "Append a user to look up."
@spec with_user(t(), user()) :: t()
def with_user(%__MODULE__{users: us} = q, user) when is_map(user) do
%{q | users: us ++ [user]}
end
@doc """
Build the `iq` request node for this query.
Mirrors Baileys' `executeUSyncQuery`. The caller supplies the `sid`
(Baileys uses a fresh message tag); `id` is left unset so the socket layer
can stamp the correlation id when it sends the IQ.
Returns `{:error, :no_protocols}` if no protocol was added.
"""
@spec build_iq(t(), String.t() | nil) :: {:ok, Node.t()} | {:error, :no_protocols}
def build_iq(query, sid \\ nil)
def build_iq(%__MODULE__{protocols: []}, _sid), do: {:error, :no_protocols}
def build_iq(%__MODULE__{} = q, sid) do
sid = sid || generate_sid()
user_nodes =
Enum.map(q.users, fn user ->
%Node{
tag: "user",
attrs: user_attrs(user),
content:
q.protocols
|> Enum.map(&Protocols.user_element(&1, user))
|> Enum.reject(&is_nil/1)
}
end)
list_node = %Node{tag: "list", attrs: %{}, content: user_nodes}
query_node = %Node{
tag: "query",
attrs: %{},
content: Enum.map(q.protocols, &Protocols.query_element/1)
}
usync_node = %Node{
tag: "usync",
attrs: %{
"context" => q.context,
"mode" => q.mode,
"sid" => sid,
"last" => "true",
"index" => "0"
},
content: [query_node, list_node]
}
iq = %Node{
tag: "iq",
attrs: %{
"to" => Constants.s_whatsapp_net(),
"type" => "get",
"xmlns" => "usync"
},
content: [usync_node]
}
{:ok, iq}
end
@doc """
Parse a USync `iq` reply for the given query.
Returns `%{list: [...], side_list: [...]}` where each list entry is a map of
`protocol_name => parsed_value` plus an `:id` key (the user's jid).
Returns `nil` if the reply is not a `type="result"` IQ.
Side-list parsing is not yet implemented (matches the Baileys TODO).
"""
@spec parse_result(t(), Node.t() | nil) :: result() | nil
def parse_result(%__MODULE__{} = _query, %Node{attrs: %{"type" => "result"}} = reply) do
usync = NodeUtils.get_binary_node_child(reply, "usync")
list_node = usync && NodeUtils.get_binary_node_child(usync, "list")
%{list: parse_list(list_node), side_list: []}
end
def parse_result(_query, _reply), do: nil
# --- internals ---
defp parse_list(%Node{content: content}) when is_list(content),
do: Enum.flat_map(content, &parse_user/1)
defp parse_list(_), do: []
# Inbound nodes always carry a map of attrs (the decoder builds them with
# Map.put), so we can match the jid straight from the head. A user entry without
# a jid is skipped.
defp parse_user(%Node{content: content, attrs: %{"jid" => id}}),
do: [Map.put(parse_user_data(content), :id, id)]
defp parse_user(_), do: []
defp parse_user_data(content) when is_list(content) do
content
|> Enum.map(fn %Node{tag: tag} = child -> {tag, Protocols.parse(tag, child)} end)
|> Enum.reject(fn {_tag, value} -> is_nil(value) end)
|> Map.new()
end
defp parse_user_data(_), do: %{}
# The `jid` attr is set only when looking up by id/lid; a phone lookup omits
# it (Baileys: `jid: !user.phone ? user.id : undefined`).
defp user_attrs(%{phone: phone}) when is_binary(phone), do: %{}
defp user_attrs(%{id: id}) when is_binary(id), do: %{"jid" => id}
defp user_attrs(_), do: %{}
defp generate_sid do
:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower)
end
end