Amarula.Msg (amarula v0.4.3)

View Source

A 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.

typecontent
:textthe 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{}
:list / :buttons / :template / :interactive%Amarula.Content.Options{} (a presented set of choices)
:protocol%Amarula.Content.Protocol{} (control frame) — arrives on :protocol_update
:othernil (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 typesend with
:textAmarula.send_text/4
:mediaAmarula.send_media/5
:reactionAmarula.send_reaction/3 (pass content.key)
:edit / :revokeAmarula.send_edit/3 / send_revoke/2
:pin / :keepAmarula.pin_message/2 / keep_message/2
:locationAmarula.send_location/5
:pollAmarula.send_poll/5
:poll_voteAmarula.send_poll_vote/5
:contact / :contactsAmarula.send_contact/4 / send_contacts/4
:eventAmarula.send_event/4
:group_inviteAmarula.send_group_invite/5
:member_tagAmarula.update_member_tag/3 (group is msg.channel)

Receive-only (no originating send): :product, :order, 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), and the interactive prompts (:list/:buttons/:template/:interactive — you receive a presented set of choices but can't send one). Event RSVP responses are not yet supported either.

Addressing — channel, from, to

Every message carries three address roles, each an Amarula.Address:

rolemeaning1:1 DMgroupself-chat
channelthe room it was published to — the reply handlethe peerthe groupme
fromwho wrote it (carries the sending device)the peer / methe participantme (+device)
towho it was addressed to — the real recipientthe peer / methe groupme

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
end

own_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.

forwarded

forwarded is true when the message was forwarded from another chat (ContextInfo.isForwarded on the wire), else false. The forward score (how many hops — WhatsApp shows "forwarded many times" at ≥ 5) isn't surfaced here; read ContextInfo.forwardingScore off msg.raw if you need it.

preview

preview is the link-preview card a :text message carries for a URL it contains — an %Amarula.Content.LinkPreview{} with url/title/description/ thumbnail/type, or nil when the message has no preview. It rides alongside the text (the type stays :text and content stays the body string); a plain text message, or a reply/mention with no link, has nil. Amarula surfaces previews it receives; sending them isn't supported yet.

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.

t()

Functions

Build a %Msg{} from a decrypted proto and its envelope.

Types

media_kind()

@type media_kind() :: :image | :video | :audio | :document | :sticker

quoted()

@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.

t()

@type t() :: %Amarula.Msg{
  channel: Amarula.Address.t() | nil,
  content: term(),
  forwarded: boolean(),
  from: Amarula.Address.t() | nil,
  from_me: boolean(),
  id: String.t() | nil,
  mentions: [Amarula.Address.t()],
  preview: Amarula.Content.LinkPreview.t() | nil,
  pushname: String.t() | nil,
  quoted: quoted() | nil,
  raw: Amarula.Protocol.Proto.Message.t(),
  timestamp: integer() | nil,
  to: Amarula.Address.t() | nil,
  type: atom()
}

Functions

from_proto(proto, meta)

@spec from_proto(Amarula.Protocol.Proto.Message.t(), map()) :: t()

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.