Amarula.Msg (amarula v0.1.0)

View Source

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

typecontent
:textthe 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):

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

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(),
  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

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.