Amarula.Msg (amarula v0.3.0)
View SourceA received message in consumer terms — the friendly view of a decrypted
%Proto.Message{}. Delivered in {:amarula, :messages_upsert, %{messages: [%Msg{}]}}, so consumers never pattern-match the large WA protobuf.
type + content are derived from the message body.
The rule: content is an Amarula.Content.* struct (never a raw protobuf),
except :text (a String.t()) and :other (nil). The raw %Proto.Message{}
is always on raw. Any key/poll_key is a {jid, msg_id} reference — the same
form the send API takes, so you can pass a received reaction's key straight to
Amarula.send_reaction/3.
type | content |
|---|---|
:text | the text String.t() |
:media | %Amarula.Content.Media{} |
:reaction | %Amarula.Content.Reaction{} (emoji: "" = removed) |
:edit | %Amarula.Content.Edit{} |
:revoke | %Amarula.Content.Revoke{} |
:pin | %Amarula.Content.Pin{} |
:keep | %Amarula.Content.Keep{} |
:member_tag | %Amarula.Content.MemberTag{} |
:contact | %Amarula.Content.Contact{} |
:contacts | %Amarula.Content.Contacts{} |
:location | %Amarula.Content.Location{} |
:poll | %Amarula.Content.Poll{} |
:poll_vote | %Amarula.Content.PollVote{} |
:event | %Amarula.Content.Event{} |
:group_invite | %Amarula.Content.GroupInvite{} |
:product | %Amarula.Content.Product{} (minimal — detail on raw) |
:order | %Amarula.Content.Order{} (minimal — detail on raw) |
:button_response / :list_response / :template_reply / :interactive_response | %Amarula.Content.Response{} |
:protocol | %Amarula.Content.Protocol{} (control frame) — arrives on :protocol_update |
:other | nil (read raw) |
Pure Signal-protocol plumbing (a bare senderKeyDistributionMessage) is applied
internally and never emitted as a %Msg{} — consumers do not see it.
Replying in kind
Most received types have a matching send_* to reproduce or respond:
received type | send with |
|---|---|
:text | Amarula.send_text/4 |
:media | Amarula.send_media/5 |
:reaction | Amarula.send_reaction/3 (pass content.key) |
:edit / :revoke | Amarula.send_edit/3 / send_revoke/2 |
:pin / :keep | Amarula.pin_message/2 / keep_message/2 |
:location | Amarula.send_location/5 |
:poll | Amarula.send_poll/5 |
:poll_vote | Amarula.send_poll_vote/5 |
:contact / :contacts | Amarula.send_contact/4 / send_contacts/4 |
:event | Amarula.send_event/4 |
:group_invite | Amarula.send_group_invite/5 |
:member_tag | Amarula.update_member_tag/3 (group is msg.channel) |
Receive-only (no originating send): :product, :order, and the interactive
replies (:button_response/:list_response/:template_reply/:interactive_response
— you receive a user's choice, but originating the buttons/list isn't supported).
Event RSVP responses are not yet supported either.
Addressing — channel, from, to
Every message carries three address roles, each an Amarula.Address:
| role | meaning | 1:1 DM | group | self-chat |
|---|---|---|---|---|
channel | the room it was published to — the reply handle | the peer | the group | me |
from | who wrote it (carries the sending device) | the peer / me | the participant | me (+device) |
to | who it was addressed to — the real recipient | the peer / me | the group | me |
To reply, put msg.channel straight into a send's target — it routes back to the
same conversation. In a 1:1, from == channel; in a group, from (the
participant) ≠ channel (the group).
from_me and the real recipient
WhatsApp's multi-device model fans every message you send out to your linked
devices as a from_me message. The stanza's from is then your own account, not
the peer — so for a from_me message the receive path derives channel and to from
the stanza's recipient (the actual other party), not from from. This means to is
the real recipient: it tells "I messaged myself" apart from "I messaged someone
else", which from/channel alone cannot (both collapse to your account on a linked
device).
So a self-chat command channel — talking to an agent by messaging yourself — is
Amarula.own_chat?/2 (no device comparison: the sending device isn't recoverable for
own messages — WhatsApp strips it from the writer jid):
if Amarula.own_chat?(conn, msg) do
handle_self_command(msg) # the user messaged themselves → drive the agent
endown_chat?/2 handles the LID/PN duality (the self chat may be addressed by either our
PN or our LID) by matching to against both of our own identities.
No echo on a single connection. WhatsApp delivers a message only to the devices it
was encrypted for, and the send path excludes our own sending device from that set.
So a reply this connection sends to the self chat is delivered to our other devices
(phone, other companions) but is not delivered back to us — there is no feedback
loop, and you do not need to dedupe your own sends. (The only exception is running
two connections on the same account: each then receives the other's sends, since
they're different devices — there, dedupe cross-connection by the msg_id you got from
the send.)
channel/from/to are typed Address.t() | nil because from_proto/2 never
fails on a missing field — it copies meta verbatim, which a directly-constructed
%Msg{} may leave nil. In
practice every top-level %Msg{} emitted on :messages_upsert has a non-nil
channel, from, and to — the receive path derives them from the stanza and our
creds. The one exception is a nested quoted message (quoted.message): it carries
channel/from but to: nil (a quote isn't independently addressed to you).
pushname
pushname is the sender's display name as it rides on the inbound stanza (the
notify attr WhatsApp ships alongside participant/from). It lets a consumer name
a contact the moment they message — no re-pairing, no separate contact fetch — even
for someone WhatsApp only addresses by LID/number. It's nil for our own
(from_me) messages and any stanza without the attr.
Summary
Types
A quoted message a reply points at. id/participant identify the original;
message is the partial copy WhatsApp inlines (a nested %Amarula.Msg{}),
enough to show the quote without a lookup. Use Amarula.resolve_quoted/2 to
fetch the FULL original (cache → server) when the inline copy isn't enough.
Functions
Build a %Msg{} from a decrypted proto and its envelope.
Types
@type media_kind() :: :image | :video | :audio | :document | :sticker
@type quoted() :: %{ id: String.t(), from: Amarula.Address.t() | nil, channel: Amarula.Address.t() | nil, message: t() | nil }
A quoted message a reply points at. id/participant identify the original;
message is the partial copy WhatsApp inlines (a nested %Amarula.Msg{}),
enough to show the quote without a lookup. Use Amarula.resolve_quoted/2 to
fetch the FULL original (cache → server) when the inline copy isn't enough.
@type t() :: %Amarula.Msg{ channel: Amarula.Address.t() | nil, content: term(), from: Amarula.Address.t() | nil, from_me: boolean(), id: String.t() | nil, mentions: [Amarula.Address.t()], pushname: String.t() | nil, quoted: quoted() | nil, raw: Amarula.Protocol.Proto.Message.t(), timestamp: integer() | nil, to: Amarula.Address.t() | nil, type: atom() }
Functions
Build a %Msg{} from a decrypted proto and its envelope.
meta carries the stanza fields: :id, :channel (the room Address), :from
(the writer Address — participant in a group, else the channel), :to (the
addressed identity Address), :from_me, :pushname (the sender's display name
off the stanza, nil when absent), :timestamp.