Amarula.Msg (amarula v0.1.0)
View SourceA received message, in consumer terms — the friendly view of a decrypted
%Proto.Message{}. This is what {:whatsapp, :messages_upsert, %{messages: [%Msg{}]}} carries, so a consumer never has to pattern-match the (large) WA
protobuf.
type + content are derived from the message body:
type | content |
|---|---|
:text | the text String.t() |
| :media | %{kind: :image|:video|:audio|:document|:sticker, media: struct} — pass to Amarula.download_media/2 |
| :reaction | %{key: MessageKey, emoji: String.t()} ("" = removed) |
| :edit | %{key: MessageKey, text: String.t()} |
| :revoke | %{key: MessageKey} |
| :contact | the contact message struct |
| :location | the location message struct |
| :poll | the poll-creation struct |
| :poll_vote | the poll-update struct |
| :protocol | %{type: atom, message: struct} (app-state keys, …) |
| :other | nil |
raw is always the underlying %Proto.Message{} — the escape hatch for
anything not surfaced here.
Pure Signal-protocol plumbing (a bare senderKeyDistributionMessage) is applied
internally and never emitted as a %Msg{} — consumers do not see it.
Addressing — channel, from, to
Every message carries three address roles, each an Amarula.Address (terms chosen
to read across chat / PubSub / generic messaging, not just WhatsApp):
| 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 is total
(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.