Amarula usage rules

View Source

Amarula is a WhatsApp Web client for Elixir — an idiomatic OTP port of Baileys. You connect the way the web/desktop app does: pair once by scanning a QR code (or with a phone-number link code), then send and receive messages from Elixir.

These rules describe how to use the Amarula.* public API correctly. They are for agents writing consumer code against the library, not for working on the library itself.

Core mental model

  • There is no global connection. Every call takes a conn handle (first argument). You can run many WhatsApp accounts side by side; each is an independent supervision tree. Do not reach for an implicit/singleton connection — there isn't one.
  • A conn is a pid, a registered name, or a :via tuple. Get one from connect/2; resolve a profile back to a live handle with Amarula.whereis/1 or get a restart-safe handle with Amarula.via/1.
  • A :profile is the account's identity + storage scope. Naming a profile is what lets the next run reconnect without a fresh QR. Pass %{profile: :name} to new/1.
  • Amarula is NOT a message store. It keeps only what the protocol needs (creds, Signal sessions, device/LID maps). It does NOT keep a chat list, contact list, inbox, or scrollback. Incoming messages are delivered once via an event, then forgotten. If your app needs history or an inbox, persist it yourself from the events.

Connecting

The builder is Req-style: new/1 builds a %Amarula.Conn{} value, then connect/2 starts it. Events go to :parent_pid (default: the caller).

{:ok, conn} =
  Amarula.new(%{profile: :me})
  |> Amarula.connect(parent_pid: self())

new/1 fills in all protocol defaults; you usually pass only :profile (plus optional :storage and overrides). Only one connection per profile may run at a time — connecting an already-live profile returns {:error, {:already_running, pid}}; use Amarula.whereis(profile) to get the existing one.

Lifecycle:

  • disconnect/1 — close the websocket; keep the tree up (pair with reconnect/1).
  • stop/1 — take the whole tree down and free the profile slot (accepts a pid or a profile name).
  • wipe_credentials/1 — destructive: unlink on WhatsApp's side, wipe all local storage, disconnect. Requires re-pairing.

List stored accounts without connecting via Amarula.list_profiles/1 / list_profiles_with_metadata/1, passing a storage spec (e.g. storage: "./auth").

Pairing (first run)

Events arrive as {:whatsapp, type, data}. On first run you receive a QR code to render:

receive do
  {:whatsapp, :connection_update, %{qr: qr}} when is_binary(qr) ->
    qr |> QRCode.create() |> QRCode.render(:png) |> QRCode.save("qr.png")
end
  • The qr is a plain string — YOU render it (terminal, PNG, <img>). There is no built-in renderer. Render it as-is; do not reformat it.
  • The ref inside rotates ~every 20s, so each rotation emits a fresh :connection_update with a new qr. Re-render on each one.
  • After the user scans: :pairing_success, then an automatic 515 restart, then :connection_update with connection: :open. Wait for :open before sending.

Phone-number (link-code) pairing instead of QR: during the QR window (on the first qr event), call Amarula.request_pairing_code(conn, phone, opts). Returns {:ok, code} — an 8-char code the user types into WhatsApp → Linked Devices → "Link with phone number". Also delivered as a :pairing_code event.

Addressing

A send target (jid) is either:

WhatsApp multi-device uses both LID (<n>@lid) and phone-number (<n>@s.whatsapp.net) addresses for the same person. Canonicalize an inbound jid to its phone-number form with Amarula.canonical_jid(conn, jid) (safe to call on any jid; it returns non-LID/unmapped jids unchanged).

Sending

All sends return {:ok, msg_id} or {:error, reason} (e.g. :not_on_whatsapp). Sends are synchronous (they block until the send pipeline finishes); sends to different recipients complete independently.

Amarula.send_text(conn, jid, "hello")
Amarula.send_media(conn, :image, jid, image_bytes, caption: "hi")   # :image|:video|:audio|:document|:sticker
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 everyone
Amarula.send_location(conn, jid, lat, lng, name: "...")
Amarula.send_contact(conn, jid, display_name, vcard)

{:ok, msg_id, secret} = Amarula.send_poll(conn, jid, "Q?", ["A", "B"], selectable: 1)
# Keep `secret` to tally votes (Amarula.Protocol.Messages.Poll).

A message_key (for reactions/edits/deletes) is the key field of a message you received — that is how you point at a specific message.

Presence/typing: set_presence/2 (:available/:unavailable), send_chatstate/3 (:composing/:recording/:paused), presence_subscribe/2, mark_read/4.

Receiving

Incoming events arrive at parent_pid as {:whatsapp, type, data}. The main one is :messages_upsert, whose data.messages is [%Amarula.Msg{}] — the consumer-friendly view (type + content), never the raw protobuf. Match on msg.type.

def handle_info({:whatsapp, :messages_upsert, %{messages: messages}}, state) do
  for msg <- messages, do: handle_message(msg)
  {:noreply, state}
end

Event types (see Amarula.event/0 for the full list): :connection_update, :messages_upsert, :chats_update, :contacts_update, :group_update, :receipt_update, :presence_update, :blocklist_update, :pairing_code, :pairing_success, :history_sync, :error.

There is no :creds_update — Amarula persists credentials itself, scoped to the profile. Do not write credential-saving code; name a profile and it reloads on connect.

Media

Inbound media carries only metadata (directPath/mediaKey), not the bytes. Fetch them lazily:

%Amarula.Msg{type: :media} = msg
{:ok, bytes} = Amarula.download_media(msg)   # {:error, :bad_mac} on integrity failure

Avoiding self-send feedback loops

To ignore messages this app/device itself sent (e.g. an agent in a self-chat), read your own device id once and compare per message:

own_device = Amarula.own_address(conn).device   # constant after login; read once
# per message:
if msg.from_me and msg.from.device == own_device, do: :ignore

Replies / quoted messages

Amarula.resolve_quoted(conn, msg) resolves the message a reply quotes: returns {:ok, %Amarula.Msg{}} if WhatsApp shipped the inline copy, else {:requested, id} (the original re-arrives async via :messages_upsert), or {:error, :not_a_reply}.

History sync

:history_sync events deliver WhatsApp's own history (chats/contacts/messages) as events to store — not a queryable archive Amarula maintains. Request older history on demand with Amarula.fetch_history(conn, oldest_key, oldest_ts, count); it arrives asynchronously via a later :history_sync event.

Groups

Read: group_metadata(conn, group), list_groups(conn). Group jids are "<id>@g.us".

Manage (all return :ok/{:ok, ...}/{:error, {:group_op_failed, code, text}}): group_create/3, group_leave/2, group_update_subject/3, group_update_description/3, group_participants/4 (:add/:remove/:promote/ :demote), group_setting/3 (:announcement/:locked …), group_member_add_mode/3, group_join_approval_mode/3, group_ephemeral/3, group_invite_code/2, group_revoke_invite/2, group_accept_invite/2, group_invite_info/2, group_requests/2, group_request_update/4.

Contacts & profile

on_whatsapp(conn, phones), fetch_status(conn, jids), resolve_lid(conn, phones), profile_picture_url(conn, jid, type), update_profile_status/2, update_profile_picture/3, remove_profile_picture/2.

Testing your bot

To test message-handling logic — "when a message like X arrives, does my bot reply with Y?" — use Amarula.Testing, not Mox. Mox mocks behaviours your code calls out to; the bot's input is an event in its mailbox and its reply is a call into Amarula, so there is nothing for Mox to attach to. Instead, run an offline sandbox connection: inbound messages are the ones you deliver, and outbound sends short-circuit to {:ok, msg_id} without touching any network.

{:ok, conn} = Amarula.Testing.start_offline(profile: :test)

# Feed an inbound message (runs the REAL decode/classify pipeline → a true %Msg{}).
Amarula.Testing.deliver_text(conn, from: "15551234567@s.whatsapp.net", text: "ping")

# Your bot receives :messages_upsert and replies. In sandbox mode the reply
# returns {:ok, id} and sends nothing — no encrypt, no frame, no real message.
assert_receive {:whatsapp, :messages_upsert, %{messages: [%Amarula.Msg{}]}}
  • start_offline/1 returns the same conn handle as connect/2; pass it to send_text/3 etc. Events go to :parent_pid (default: the caller), so assert_receive works in the calling test.
  • Equivalent to a normal connection built with Amarula.new(%{profile: x, offline: true})offline: is a real connection property, not test-only magic.
  • deliver/2 takes any %Amarula.Protocol.Proto.Message{} for media/reactions/etc.
  • send_media/5 is the one send that does NOT work offline (it uploads media, which needs a live socket).

Common mistakes to avoid

  • Calling a send before connection: :open — wait for the open event.
  • Expecting a "list my chats/conversations" call — there is none; build it from events.
  • Treating :messages_upsert as queryable/replayable — it fires once per message.
  • Writing credential-persistence code — Amarula owns creds; just name a profile.
  • Reformatting the QR string, or expecting a built-in QR image — render the raw string yourself.
  • Reaching for a global/singleton connection — every call takes an explicit conn.
  • Assuming inbound media includes bytes — call download_media/1.
  • Reaching for Mox to test your bot — use Amarula.Testing (offline sandbox) instead.