Changelog
View SourceAll notable changes to this project are documented here.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
[0.2.2] - 2026-06-20
A bug-fix plus new outgoing/incoming message types (Tier 1 + Tier 2 of the proto coverage review). One small breaking change to how you reference an existing message (see Changed (breaking)).
Fixed
Storage.Fileno longer discards a validcreds.termas "corrupt" on a cold start (#1). Decoding used:erlang.binary_to_term/2with[:safe], which refuses to mint atoms that aren't loaded yet. A persisted creds term legitimately carries generated proto-struct atoms (e.g.Amarula.Protocol.Proto.ADVSignedDeviceIdentity); if creds were read before that module loaded,[:safe]raised and the entry was swallowed as a miss — silently logging the session out and forcing a re-pair, intermittently and load-order dependent. Decode now falls back to an unsafebinary_to_term/1on the[:safe]-specific rejection (these files are self-written and trusted), so a valid creds file is always recovered; only a genuinely undecodable file is treated as a miss.
Added
- More inbound message types are now classified instead of collapsing to
{:other}: view-once media (unwrapped to its inner media), PTV round notes (as media:video), pin/keep updates, group invites, events, and the receive-only WhatsApp Business / interactive types (product, order, button / list / template / interactive responses). Surfaced on%Amarula.Msg{}via itstype+content. - Send albums (grouped media).
Amarula.send_album/3takes a list of{type, data, opts}image/video items; it sends the album parent, then each item referencing it (viamessageContextInfo.messageAssociation, MEDIA_ALBUM). - Create events.
Amarula.send_event/4sends an event (name + optional description, location, join link, start/end time, extra-guests flag). Responding (RSVP) is not yet supported — it's an encrypted response, a separate seam like poll votes. - Send a group invite as a chat message.
Amarula.send_group_invite/5wraps a group's invitecode(fromAmarula.Group.invite_code/2) into a tap-to-join card sent to a chat.opts::group_name,:caption,:expiration. - Cast a poll vote.
Amarula.send_poll_vote/5encrypts and sends a vote on an existing poll (the inverse of the tally/decrypt path). Pass the poll'smessage_ref, itsmessage_secret, and the chosen option names. - Pin / unpin and keep / unkeep messages.
Amarula.pin_message/2,unpin_message/2(pin for everyone),keep_message/2,unkeep_message/2(exempt a message from a disappearing chat). Each takes amessage_ref. - View-once media and PTV (round video notes).
send_media/5gains:view_once(wrap as view-once) and:ptv(for:video, send as a round video note).
Changed (breaking)
- Unified how you reference an existing message.
send_reaction/3,send_edit/3,send_revoke/2(and the newsend_poll_vote) now take amessage_ref— a%Amarula.Msg{}(the message you received) or a{jid, msg_id}tuple — instead of a raw%Proto.MessageKey{}. This removes the last proto type from the public API and gives one consistent currency: pass back the struct you got, or the chat jid + id. Migration: replace%Proto.MessageKey{remoteJid: jid, id: id}with{jid, id}, or pass the%Amarula.Msg{}directly.
Added
- Reply (quoted) and mentions on outgoing messages.
Amarula.send_text/4andAmarula.send_media/5now take:quoted(an%Amarula.Msg{}to reply to) and:mentions(a list of jids/%Amarula.Address{}) opts. A text with either is sent as anextendedTextMessagecarrying thecontextInfo; media attaches thecontextInfoto the media submessage. Backward compatible — existing 3-argsend_textcalls are unchanged.
[0.2.1] - 2026-06-20
Internal refactor only — no API changes. Every public Amarula function
keeps its exact signature, arity, defaults, and return shape (verified by diff);
consumer event shapes are unchanged. No action needed to upgrade.
Changed (internal)
- Decomposed the ~3.9k-line
Amarula.Connectiongod-module: the pure bodies of its callbacks moved into eight focused submodules underAmarula.Connection.*—SendOps,GroupOps,PreKeyOps,Pairing,Notifications,Receive,AppStateOps, and the sharedAckLifecycleseam.Connectionremains the single per-connection process and dispatcher; anything bound to the live socket, cipher, IQ correlation, or Storage stayed put. Each new module has direct unit tests (100% line coverage). - The
Amarulafacade now calls the connection process directly (GenServer.call) instead ofdefdelegate-ing throughConnection's client wrappers, removing one indirection hop. Public signatures are untouched.
[0.2.0] - 2026-06-19
A consumer-API cleanup pass: leaner, more consistent Amarula facade with no
protocol types leaked. All breaking changes are mechanical — rename the call
or swap two arguments; behaviour is unchanged.
Changed (breaking)
Namespaced the group / profile / contact families — these moved off the flat
Amarula facade onto dedicated modules, shrinking the top-level surface and
dropping the prefix-stutter. Every operation is unchanged; only the module differs:
| Before | After |
|---|---|
Amarula.group_create/3, group_leave/2, … (all group_*) | Amarula.Group.create/3, Amarula.Group.leave/2, … (drop the group_ prefix) |
Amarula.group_metadata/2, Amarula.list_groups/1 | Amarula.Group.metadata/2, Amarula.Group.list/1 |
Amarula.on_whatsapp/2, fetch_profile_status/2 | Amarula.Contacts.on_whatsapp/2, Amarula.Contacts.fetch_status/2 |
Amarula.profile_picture_url/3, update_profile_*, remove_profile_picture/2 | Amarula.Profile.picture_url/3, update_picture/3, update_status/2, remove_picture/2 |
Event tag — consumer events are now tagged :amarula, not :whatsapp, so the
origin is clear and the tag won't collide with other libraries:
# before
{:whatsapp, :messages_upsert, data}
# after
{:amarula, :messages_upsert, data}Update every receive/handle_info clause that matches the event tuple.
Renamed functions (same behaviour, clearer name):
| Before | After |
|---|---|
Amarula.Address.to_wire/1, to_wire!/1 | Amarula.Address.to_jid/1, to_jid!/1 (now accept a string or an Address) |
Amarula.fetch_status/2 | Amarula.fetch_profile_status/2 |
Amarula.presence_subscribe/2 | Amarula.subscribe_presence/2 |
Amarula.group_setting/3 | Amarula.group_update_setting/3 |
Amarula.group_ephemeral/3 | Amarula.group_toggle_ephemeral/3 |
Argument order — jid now always comes right after conn, matching every
other send:
Amarula.mark_read(conn, message_ids, jid, participant)→Amarula.mark_read(conn, jid, message_ids, participant)Amarula.send_media(conn, type, jid, data, opts)→Amarula.send_media(conn, jid, type, data, opts)
Return shape — Amarula.group_requests/2 now returns clean
[%{jid: Amarula.Address.t(), requested_at: integer | nil}] instead of raw
string-keyed wire attributes.
Removed (breaking)
Removed from the Amarula facade. These were either a protocol leak or internal
plumbing the library handles for you; the underlying function still exists for
power users:
| Removed | Use instead |
|---|---|
Amarula.send_message/3 (took a raw %Proto.Message{}) | Amarula.send_text/3 & friends, or Amarula.Connection.send_message/3 |
Amarula.request_resend/2 | Amarula.Connection.request_resend/2 |
Amarula.resolve_lid/2 | Amarula.Contacts.resolve_lid/2 (addressing resolves automatically on send) |
Amarula.normalize_jid/2 (was canonical_jid) | Amarula.Connection.canonical_jid/2 |
Documentation
- Dropped internal jargon ("wire jid", "boundary", "total function", "canonical") from the consumer-facing docs in favour of plain language.
[0.1.0] - 2026-06-19
First public release.
Added
- Offline sandbox +
Amarula.Testing— test your bot's receive→reply logic with no WhatsApp connection.Amarula.new(%{profile: x, offline: true})runs a connection with no socket whosesend_*calls short-circuit to{:ok, msg_id}(no encrypt, no frame, no real-world effect).Amarula.Testing.start_offline/1starts one;deliver_text/2anddeliver/2feed synthetic inbound messages through the real decode/classify pipeline, so your bot receives a true%Amarula.Msg{}.send_media/5is unsupported offline (needs a live socket). Amarula.list_profiles/1— list the profiles that have stored credentials in a given storage source (aConn, aStorage.Scope, or a{adapter, opts}/ bare-opts storage spec). Returns the names you'd pass as:profileto reconnect.Amarula.list_profiles_with_metadata/1— likelist_profiles/1, but each entry carries the logged-in identity read from that profile's creds (%{profile, jid, lid, name}), for building account pickers.Amarula.Storagegains an optionallist_profiles/1behaviour callback, implemented by theFileandDETSadapters. Adapters that don't implement it report{:error, :not_supported}.
Changed
- Teardown API reworked.
wipe_credentials/1is now the single destructive path: it unlinks the companion server-side (remove-companion-device, the phone drops the device), wipes all local storage for the profile, then disconnects. After it, the profile must be re-paired.
Removed
Amarula.logout/1removed. For a non-destructive teardown that keeps credentials, usedisconnect/1(closes the websocket only) orstop/1(takes the supervision tree down and frees the profile slot). The server-side device-unlink now lives only inwipe_credentials/1.