Amarula (amarula v0.1.0)
View SourceAmarula — a WhatsApp Web client for Elixir.
Connect to WhatsApp the way the web/desktop app does: pair once by scanning a
QR code with your phone, then send and receive messages from Elixir. Every call
takes a conn handle, so you can run many accounts at once — there is no global
connection.
Quick start
Pair a device (first run), then send a message:
# 1. Start a connection. Events (the QR code, incoming messages) are sent
# to `parent_pid` — here, the current process.
{:ok, conn} =
Amarula.new(%{profile: :me})
|> Amarula.connect(parent_pid: self())
# 2. On the first run you get a QR code to scan with your phone:
# WhatsApp → Settings → Linked Devices → Link a device.
receive do
{:whatsapp, :connection_update, %{qr: qr}} when is_binary(qr) ->
IO.puts(qr) # render this string as a QR code
end
# 3. Once linked you get an :open update — now you can send.
receive do
{:whatsapp, :connection_update, %{connection: :open}} -> :ready
end
Amarula.send_text(conn, "5511999999999@s.whatsapp.net", "hello from Elixir!"):profile names this account's stored credentials, so the next run reconnects
without a new QR. Amarula.new/1 fills in all protocol defaults; you usually
pass only :profile. For a ready-made supervised wrapper, see
Amarula.Examples.Connection.
Testing your bot
To test your message-handling logic without a real WhatsApp connection, use
Amarula.Testing — it starts an offline connection and lets you deliver
synthetic inbound messages that flow through the real receive pipeline.
The QR code
The qr in a :connection_update is a plain string — you turn it into a
scannable image, with whatever you like (a terminal renderer, a qr_code
PNG, an HTML <img>, …). There is no built-in renderer and no login-phase
plugin hook: rendering is entirely the consumer's, via this event. (The
handshake/pairing crypto is deliberately closed to plugins — the send/receive
pipelines run only after a message is decrypted.)
The string is four comma-separated fields:
"<ref>,<noiseKeyB64>,<identityKeyB64>,<advSecretKeyB64>"ref— a server-issued pairing reference (from the<pair-device>IQ); it rotates every ~20s, so each rotation emits a fresh:connection_updatewith a newqr— re-render on each.noiseKeyB64— our Noise static public key (base64).identityKeyB64— our signed identity public key (base64).advSecretKeyB64— the companion adv secret key (base64).
The phone reads our public keys + ref from the image to link the device. Render
it as-is — don't reformat the string. Example with qr_code (note its API is
Result-tuple based — create/1 returns {:ok, qr} and render/2 takes that
tuple, so you pipe straight through):
{:whatsapp, :connection_update, %{qr: qr}} when is_binary(qr) ->
qr |> QRCode.create() |> QRCode.render(:png) |> QRCode.save("qr.png")Addressing
A send target is a wire jid string — "<number>@s.whatsapp.net" for a person,
"<id>@g.us" for a group — or an Amarula.Address (use Amarula.Address.pn/1
to build one from a bare number).
Sending
All sends return {:ok, msg_id} or {:error, reason}:
Amarula.send_text(conn, jid, "hello")
Amarula.send_media(conn, :image, jid, image_bytes, caption: "hi")
Amarula.send_reaction(conn, message_key, "👍") # "" removes the reaction
Amarula.send_edit(conn, message_key, "fixed typo")
Amarula.send_revoke(conn, message_key) # delete for everyoneA message_key is the key field of a message you received (see below) —
that's how you point a reaction/edit/delete at a specific message.
Receiving
Incoming events arrive at parent_pid as {:whatsapp, type, data} tuples (the
full list is in event/0). The main one is :messages_upsert, whose data
carries [%Amarula.Msg{}] — the consumer-friendly message view (type +
content), never the raw protobuf. Match on msg.type; for media, fetch the
bytes lazily with download_media/1.
What Amarula does NOT store
Amarula keeps only what the protocol needs (credentials, Signal sessions,
device/LID mappings). It is not a message archive and keeps no chat or
DM list: there is no "list my conversations" call, and incoming messages are
delivered once via :messages_upsert and then forgotten. If your app needs an
inbox, a contact list, or scrollback, persist it yourself from the events —
Amarula won't do it for you.
History sync (:history_sync events, and fetch_history/4 to pull more on
demand) delivers WhatsApp's own history to you the same way — as events to
store, not a queryable archive Amarula maintains. resolve_quoted/2 is the one
read-back helper, and only for a reply's quoted message you still hold.
Summary
Types
Affected participant in a group op: %{jid, status} (status "200" = ok).
Events delivered to parent_pid as {:whatsapp, type, data}
A send target: an Amarula.Address or a wire jid string ("<n>@s.whatsapp.net" / "<id>@g.us").
A media kind handled by send_media/5.
The key of a specific message (for reactions/edits/deletes).
A connection's profile name (its identity + storage scope).
Result of a send: the assigned message id, or an error.
Functions
Canonicalize jid to its phone-number identity.
Start a built Amarula.Conn and begin connecting. Returns the running conn
handle (a pid). Pair with new/1
Current connection state (e.g. :disconnected, :connecting, :connected).
Close the connection's websocket without taking the supervision tree down.
Pair with reconnect/1 to bring it back up; use stop/1 to release the
profile entirely. Returns :ok | {:error, reason}.
Download + decrypt an incoming media file. Inbound messages carry only media
metadata (directPath/mediaKey), not the bytes — call this to fetch them
lazily, passing a %Amarula.Msg{type: :media} (or its content.media struct
Ask the phone for older history of a chat (a PEER_DATA_OPERATION on-demand
request). oldest_key is the oldest message you already have, oldest_ts its
millisecond timestamp, and count how many older messages to request. The
history arrives asynchronously later via the normal :history_sync event.
Returns {:ok, request_msg_id} or {:error, :not_authenticated}.
Fetch users' status/bio text. See Amarula.Contacts.fetch_status/2.
Join a group by invite code. Returns the joined group's jid.
Create a group named subject with the given participant jids. Returns the new
group's metadata.
Toggle disappearing messages. 0 = off; otherwise seconds of expiration.
Fetch the group's invite code.
Look up group metadata from an invite code without joining.
Turn join-approval (admin approves joiners) :on/:off.
Leave a group.
Who may add members: :admin_add (admins only) or :all_member_add.
Fetch a group's metadata (%Amarula.Group{}). group is an Address or jid.
Add/remove/promote/demote participants. action is :add/:remove/:promote/
:demote. Returns the affected participants with per-jid status.
Approve/reject pending join requests for participants. action is :approve/:reject.
List pending join-approval requests (a list of attr maps).
Revoke + regenerate the group's invite code. Returns the new code.
Change a group setting: :announcement/:not_announcement (only admins post),
:locked/:unlocked (only admins edit info).
Set (or clear, with nil/"") a group's description.
Change a group's subject (title).
List all groups we participate in ([%Amarula.Group{}]).
List every profile that has stored credentials in storage.
Like list_profiles/1, but each entry carries the logged-in identity read from
that profile's stored creds — for building a friendlier account picker
Send a read receipt for message_ids in chat jid (pass participant for a
group sender). Marks those messages read on the sender's side.
Build a connection value (Amarula.Conn) from config, without starting it.
Check which phone numbers are on WhatsApp. See Amarula.Contacts.on_whatsapp/2.
This connection's own identity as an Amarula.Address — our phone-number address,
carrying our companion device id (creds.me.id).
Whether msg is in our own chat (the "Message Yourself" chat): from_me and
addressed to our own account. The check a self-chat command channel needs — drive
an agent by messaging yourself.
Subscribe to a contact's presence updates.
Fetch a profile-picture URL. See Amarula.Profile.picture_url/3.
(Re)open the connection's websocket on an already-started connection — the
inverse of disconnect/1. Runs the handshake and logs in again with the
profile's stored credentials. Returns :ok | {:error, reason}.
Remove a profile picture. See Amarula.Profile.remove_picture/2.
Request a link-code (phone-number) pairing code for phone (E.164 digits;
any +, spaces, or dashes are stripped).
Ask the phone to re-deliver a message by key (a PEER_DATA_OPERATION
placeholder-resend). The message arrives asynchronously later via the normal
:messages_upsert event. Returns {:ok, request_msg_id}.
Resolve + persist a contact's LID↔PN mapping. See Amarula.Contacts.resolve_lid/2.
Resolve the original message a reply quotes.
Send a typing indicator to jid: :composing, :recording, or :paused.
Send a contact (display_name + vCard string) to jid.
Send multiple contacts to jid: pairs is [{display_name, vcard}, ...].
Edit a message we sent, replacing its text.
Send a location to jid. opts: :name, :address, :url, :is_live.
Send media of type (:image/:video/:audio/:document/:sticker).
opts may carry :mimetype, :caption, :width, :height, :seconds,
:ptt, :file_name, :title.
Send a pre-built %Proto.Message{} to jid.
Send a poll to jid. Returns {:ok, msg_id, message_secret} — keep the
message_secret to tally incoming votes (Amarula.Protocol.Messages.Poll).
opts: :selectable (max picks, default 1), :announcement, :message_secret.
React to a message with emoji (empty string removes the reaction).
Delete a message for everyone (revoke).
Send a 1:1/group text message to jid.
Set your global presence: :available (online) or :unavailable. Needs a profile name.
Stop a connection entirely — the whole supervision tree — and release its profile so it can be started again (here or, with a cluster registry, on another node).
Set a profile picture from JPEG bytes. See Amarula.Profile.update_picture/3.
Set your profile status/bio. See Amarula.Profile.update_status/2.
A :via handle for profile — usable anywhere a conn() is accepted, so calls
route to whatever pid currently holds the profile (restart-safe). Assumes the
default registry; for a custom one, build it from the Conn via
Amarula.ProfileRegistry.via/2.
The live connection pid for profile, or nil. A restart-safe handle: the pid
changes if the connection restarts, but the profile resolves to the current one.
As whereis/1, but resolves through conn_or_config's :registry.
Destructively forget this connection's profile: unlink the companion on WhatsApp's side (the phone drops the device), wipe all local storage for it (creds, sessions, keys, mappings), then disconnect. After this the profile must be re-paired to use again.
Types
Affected participant in a group op: %{jid, status} (status "200" = ok).
@type conn() :: GenServer.server()
A connection handle: the pid from connect/2, a registered name, or the :via
tuple from via/1 (resolve a profile to a restart-safe handle with whereis/1).
@type event() ::
:connection_update
| :messages_upsert
| :chats_update
| :contacts_update
| :group_update
| :receipt_update
| :presence_update
| :blocklist_update
| :pairing_code
| :pairing_success
| :history_sync
| :error
Events delivered to parent_pid as {:whatsapp, type, data}:
:connection_update—%{connection: state, qr: qr | nil, ...}(partial map):messages_upsert—%{from: jid, id: id, messages: [%Amarula.Msg{}]}:chats_update—[%Amarula.Chat{}](from history/app-state sync):contacts_update—[%Amarula.Contact{}]:group_update—%{group: Address, author: Address | nil, action: ..}a group membership/metadata change (participant add/remove/promote/demote, subject, announce, lock — seeAmarula.Protocol.Groups.Notification):receipt_update—%{message_ids, from, participant, status, timestamp}a message we sent was delivered/read/played (Amarula.Protocol.Messages.Receipt):presence_update—%{jid: Address, participant: Address, presence, last_seen}a contact/group member's presence (:available/:unavailable) or typing state (:composing/:recording) —Amarula.Protocol.Presence:blocklist_update—[%{jid, action}]block/unblock changes:pairing_code—%{code: code}the 8-char link-code (phone-number) pairing code to display (fromrequest_pairing_code/3):pairing_success—%{jid, lid, platform}(QR) or%{via: :link_code}(phone-number pairing):history_sync— a batch of synced history (chats/contacts/messages) delivered asynchronously after connect (Amarula.Protocol.Messages.HistorySync):error— a connection error term
Credentials are persisted by Amarula itself (scoped to the connection's
:profile), so there is no :creds_update to handle — name a profile and the
next connect reloads its creds automatically.
@type jid() :: String.t() | Amarula.Address.t()
A send target: an Amarula.Address or a wire jid string ("<n>@s.whatsapp.net" / "<id>@g.us").
@type media_type() :: :image | :video | :audio | :document | :sticker
A media kind handled by send_media/5.
@type message_key() :: Amarula.Protocol.Proto.MessageKey.t()
The key of a specific message (for reactions/edits/deletes).
A connection's profile name (its identity + storage scope).
Result of a send: the assigned message id, or an error.
Functions
Canonicalize jid to its phone-number identity.
WhatsApp's multi-device addresses a contact by either a LID (<n>@lid) or a
phone number (<n>@s.whatsapp.net); the same person can appear under both.
Amarula tracks the mapping internally — this is the public way to read it.
If jid is a LID with a known PN mapping, returns the equivalent
<pn>@s.whatsapp.net (preserving any device suffix). Any other jid — an
already-canonical PN, a group, or a LID we have no mapping for — is returned
unchanged, so it's safe to call on every inbound jid.
Amarula.canonical_jid(conn, "111111111111111@lid")
#=> "5511999999999@s.whatsapp.net" # if mapped
@spec connect( Amarula.Conn.t(), keyword() ) :: {:ok, conn()} | {:error, {:already_running, pid()}} | {:error, term()}
Start a built Amarula.Conn and begin connecting. Returns the running conn
handle (a pid). Pair with new/1:
{:ok, pid} = Amarula.new(config) |> Amarula.connect()Only one connection per profile may run at a time (within the registry's reach —
one per node by default; see Amarula.ProfileRegistry). Connecting a profile
that's already live returns {:error, {:already_running, pid}} — use whereis/1
to get the existing one.
opts:
:parent_pid— process to receive{:whatsapp, ..}events (default: caller):name— optional registered name for the connection
Current connection state (e.g. :disconnected, :connecting, :connected).
Close the connection's websocket without taking the supervision tree down.
Pair with reconnect/1 to bring it back up; use stop/1 to release the
profile entirely. Returns :ok | {:error, reason}.
@spec download_media(Amarula.Msg.t()) :: {:ok, binary()} | {:error, term()}
Download + decrypt an incoming media file. Inbound messages carry only media
metadata (directPath/mediaKey), not the bytes — call this to fetch them
lazily, passing a %Amarula.Msg{type: :media} (or its content.media struct
kind). Returns
{:ok, bytes}or{:error, reason}(:bad_macon a failed integrity check).%Amarula.Msg{type: :media} = msg {:ok, bytes} = Amarula.download_media(msg)
@spec download_media(map(), media_type()) :: {:ok, binary()} | {:error, term()}
@spec fetch_history(conn(), message_key(), integer(), non_neg_integer()) :: send_result()
Ask the phone for older history of a chat (a PEER_DATA_OPERATION on-demand
request). oldest_key is the oldest message you already have, oldest_ts its
millisecond timestamp, and count how many older messages to request. The
history arrives asynchronously later via the normal :history_sync event.
Returns {:ok, request_msg_id} or {:error, :not_authenticated}.
Fetch users' status/bio text. See Amarula.Contacts.fetch_status/2.
Join a group by invite code. Returns the joined group's jid.
@spec group_create(conn(), String.t(), [String.t()]) :: {:ok, Amarula.Group.t()} | {:error, term()}
Create a group named subject with the given participant jids. Returns the new
group's metadata.
@spec group_ephemeral(conn(), String.t(), non_neg_integer()) :: :ok | {:error, term()}
Toggle disappearing messages. 0 = off; otherwise seconds of expiration.
Fetch the group's invite code.
@spec group_invite_info(conn(), String.t()) :: {:ok, Amarula.Group.t()} | {:error, term()}
Look up group metadata from an invite code without joining.
Turn join-approval (admin approves joiners) :on/:off.
Leave a group.
@spec group_member_add_mode(conn(), String.t(), :admin_add | :all_member_add) :: :ok | {:error, term()}
Who may add members: :admin_add (admins only) or :all_member_add.
@spec group_metadata(conn(), jid()) :: {:ok, Amarula.Group.t()} | {:error, term()}
Fetch a group's metadata (%Amarula.Group{}). group is an Address or jid.
@spec group_participants( conn(), String.t(), [String.t()], Amarula.Protocol.Groups.Ops.action() ) :: {:ok, [affected()]} | {:error, term()}
Add/remove/promote/demote participants. action is :add/:remove/:promote/
:demote. Returns the affected participants with per-jid status.
@spec group_request_update(conn(), String.t(), [String.t()], :approve | :reject) :: {:ok, [affected()]} | {:error, term()}
Approve/reject pending join requests for participants. action is :approve/:reject.
List pending join-approval requests (a list of attr maps).
Revoke + regenerate the group's invite code. Returns the new code.
@spec group_setting(conn(), String.t(), Amarula.Protocol.Groups.Ops.setting()) :: :ok | {:error, term()}
Change a group setting: :announcement/:not_announcement (only admins post),
:locked/:unlocked (only admins edit info).
Set (or clear, with nil/"") a group's description.
Change a group's subject (title).
@spec list_groups(conn()) :: {:ok, [Amarula.Group.t()]} | {:error, term()}
List all groups we participate in ([%Amarula.Group{}]).
@spec list_profiles( Amarula.Storage.Scope.t() | Amarula.Conn.t() | {module(), keyword()} | keyword() ) :: {:ok, [Amarula.Storage.profile()]} | {:error, term()}
List every profile that has stored credentials in storage.
Takes a storage source rather than a live connection: a Amarula.Storage.Scope.t/0,
a built %Amarula.Conn{} (use its scope), or a {adapter, opts} / bare-opts
storage spec (the same value new/1 accepts as :storage). Returns the profile
names that have a :creds entry — what you'd pass as :profile to reconnect.
Amarula.list_profiles(root: "./amarula_data")
#=> {:ok, [:primary, "work"]}{:error, :not_supported} if the storage adapter can't enumerate profiles.
@spec list_profiles_with_metadata( Amarula.Storage.Scope.t() | Amarula.Conn.t() | {module(), keyword()} | keyword() ) :: {:ok, [Amarula.Storage.profile_info()]} | {:error, term()}
Like list_profiles/1, but each entry carries the logged-in identity read from
that profile's stored creds — for building a friendlier account picker:
Amarula.list_profiles_with_metadata(root: "./amarula_data")
#=> {:ok, [%{profile: :primary, jid: "5511...@s.whatsapp.net",
# lid: "12345@lid", name: "Alice"}]}Costs one extra storage read per profile. name/jid/lid are nil for a
profile that hasn't finished pairing. Accepts the same storage sources as
list_profiles/1.
Send a read receipt for message_ids in chat jid (pass participant for a
group sender). Marks those messages read on the sender's side.
@spec new(map()) :: Amarula.Conn.t()
Build a connection value (Amarula.Conn) from config, without starting it.
This is the start of the Req-style builder: attach plugins, then connect/2.
Amarula.new(%{profile: :primary})
|> MyPlugin.attach(opts)
|> Amarula.connect()Connection/protocol defaults are filled in (see Amarula.Config), so config
need only carry :profile (+ :auth and any overrides).
Offline (sandbox) mode
offline: true runs the connection with no socket: it never reaches WhatsApp,
and every send_* short-circuits to {:ok, msg_id} without encrypting or
emitting a frame. Combined with Amarula.Testing.deliver_* (which feeds
synthetic inbound messages), this lets you run your bot end to end — receive a
message, reply to it — with no real-world effect. See Amarula.Testing.
Check which phone numbers are on WhatsApp. See Amarula.Contacts.on_whatsapp/2.
@spec own_address(conn()) :: Amarula.Address.t()
This connection's own identity as an Amarula.Address — our phone-number address,
carrying our companion device id (creds.me.id).
Total: before login (no identity yet) it returns Amarula.Address.empty/0, so you
never have to nil-guard. own_address(conn).device is nil for the primary
device / phone, or the linked-device number (e.g. 29) for a companion like this app.
Use it to detect messages this app/device itself sent — e.g. to ignore the agent's own self-chat sends and avoid a feedback loop. This does a call into the connection, and our own device is constant after login, so read it once and reuse the device:
own_device = Amarula.own_address(conn).device
# then, per received message:
if msg.from_me and msg.from.device == own_device do
:ignore # this device sent it
end
@spec own_chat?(conn(), Amarula.Msg.t()) :: boolean()
Whether msg is in our own chat (the "Message Yourself" chat): from_me and
addressed to our own account. The check a self-chat command channel needs — drive
an agent by messaging yourself.
Handles the LID/PN duality: the self chat may be addressed by our PN or our LID, so it
matches msg.to against both of our own identities (see Amarula.Connection.own_chat?/2).
On a single connection there's no feedback loop — a reply this connection sends to the
self chat is not delivered back to it (the sender's own device is excluded from
delivery), so you don't need to filter your own sends. Dedupe by msg_id only when
running two connections on the same account.
Subscribe to a contact's presence updates.
Fetch a profile-picture URL. See Amarula.Profile.picture_url/3.
(Re)open the connection's websocket on an already-started connection — the
inverse of disconnect/1. Runs the handshake and logs in again with the
profile's stored credentials. Returns :ok | {:error, reason}.
Remove a profile picture. See Amarula.Profile.remove_picture/2.
Request a link-code (phone-number) pairing code for phone (E.164 digits;
any +, spaces, or dashes are stripped).
Call this during the QR window while unregistered — on the first
:connection_update carrying a qr. Returns {:ok, code} with an 8-char
code the user types into WhatsApp → Linked Devices → "Link with phone number".
Amarula finishes the handshake internally; the usual 515 restart then logs in
(watch for :pairing_success then connection: :open). The same code is also
delivered as a :pairing_code event.
opts: :custom_code — a fixed 8-char code to use instead of a random one.
@spec request_resend(conn(), message_key()) :: send_result()
Ask the phone to re-deliver a message by key (a PEER_DATA_OPERATION
placeholder-resend). The message arrives asynchronously later via the normal
:messages_upsert event. Returns {:ok, request_msg_id}.
Resolve + persist a contact's LID↔PN mapping. See Amarula.Contacts.resolve_lid/2.
@spec resolve_quoted(conn(), Amarula.Msg.t()) :: {:ok, Amarula.Msg.t()} | {:requested, String.t()} | {:error, term()}
Resolve the original message a reply quotes.
- If the reply carries the inline copy WhatsApp ships (
msg.quoted.message), return it immediately —{:ok, %Amarula.Msg{}}. - Otherwise ask the server to re-deliver the original —
{:requested, id}; it re-arrives async via:messages_upsert.
{:error, :not_a_reply} if msg doesn't quote anything.
Amarula does not keep an inbound-message store — delivery ends at
:messages_upsert(the message, its mentions, and the inline quote are all there). If you want to resolve a quote from your own history, look it up in whatever store you keep and skip this; this function only handles the inline copy and the server round-trip.
Send a typing indicator to jid: :composing, :recording, or :paused.
@spec send_contact(conn(), jid(), String.t(), String.t()) :: send_result()
Send a contact (display_name + vCard string) to jid.
Send multiple contacts to jid: pairs is [{display_name, vcard}, ...].
@spec send_edit(conn(), message_key(), String.t()) :: send_result()
Edit a message we sent, replacing its text.
Send a location to jid. opts: :name, :address, :url, :is_live.
@spec send_media(conn(), media_type(), jid(), binary(), keyword()) :: send_result()
Send media of type (:image/:video/:audio/:document/:sticker).
opts may carry :mimetype, :caption, :width, :height, :seconds,
:ptt, :file_name, :title.
@spec send_message(conn(), jid(), Amarula.Protocol.Proto.Message.t()) :: send_result()
Send a pre-built %Proto.Message{} to jid.
@spec send_poll(conn(), jid(), String.t(), [String.t(), ...], keyword()) :: {:ok, String.t(), binary()} | {:error, term()}
Send a poll to jid. Returns {:ok, msg_id, message_secret} — keep the
message_secret to tally incoming votes (Amarula.Protocol.Messages.Poll).
opts: :selectable (max picks, default 1), :announcement, :message_secret.
@spec send_reaction(conn(), message_key(), String.t()) :: send_result()
React to a message with emoji (empty string removes the reaction).
@spec send_revoke(conn(), message_key()) :: send_result()
Delete a message for everyone (revoke).
@spec send_text(conn(), jid(), String.t()) :: send_result()
Send a 1:1/group text message to jid.
Set your global presence: :available (online) or :unavailable. Needs a profile name.
Stop a connection entirely — the whole supervision tree — and release its profile so it can be started again (here or, with a cluster registry, on another node).
Unlike disconnect/1 (which only closes the websocket; the supervised tree stays
up and may reconnect), stop/1 takes the tree down and frees the profile slot.
Accepts a connection pid or a profile (resolved via the default registry).
Returns :ok | {:error, :not_found}.
Set a profile picture from JPEG bytes. See Amarula.Profile.update_picture/3.
Set your profile status/bio. See Amarula.Profile.update_status/2.
A :via handle for profile — usable anywhere a conn() is accepted, so calls
route to whatever pid currently holds the profile (restart-safe). Assumes the
default registry; for a custom one, build it from the Conn via
Amarula.ProfileRegistry.via/2.
The live connection pid for profile, or nil. A restart-safe handle: the pid
changes if the connection restarts, but the profile resolves to the current one.
Assumes the default Amarula.ProfileRegistry. With a custom :registry config,
pass the Conn (or config) as the first arg: whereis(conn, profile).
@spec whereis(Amarula.Conn.t() | map(), profile()) :: pid() | nil
As whereis/1, but resolves through conn_or_config's :registry.
Destructively forget this connection's profile: unlink the companion on WhatsApp's side (the phone drops the device), wipe all local storage for it (creds, sessions, keys, mappings), then disconnect. After this the profile must be re-paired to use again.
For a non-destructive teardown that keeps the credentials, use disconnect/1
(websocket only) or stop/1 (the whole tree, freeing the profile slot).