Status broadcast read receipts are dispatched as delivery receipts
Summary
For status broadcasts, handleGroupedReceipt never emits a read receipt: every viewer is
reported as a delivery receipt, and the message ID is always empty. Downstream this means a
bridge cannot tell who viewed a status.
Version: v0.0.0-20260816113502-fb386f152837 (as vendored by mautrix-whatsapp v0.2608.0).
What WhatsApp sends
When someone views my own status, the server sends (identifiers redacted):
<receipt class="status" from="status@broadcast" id="SAGR…" t="1789901940">
<participants message_id="3EB0…">
<user jid="…@lid" t="1789901940" type="delivery"/>
<user jid="…@lid" t="1789901942" type="read"/>
</participants>
</receipt>Two details matter here:
- The message ID is in
message_idon<participants>, not inkey. - The receipt type is on each
<user>, not on the<receipt>element.
What happens today
parseReceipt sees IsGroup with an empty sender and hands the node to
handleGroupedReceipt, which does:
pag := participants.AttrGetter()
partialReceipt.MessageIDs = []types.MessageID{pag.String("key")}
…
receipt.Timestamp = ag.UnixTime("t")
receipt.MessageSource.Sender = ag.JID("jid")So MessageIDs ends up as [""], and Type keeps whatever parseReceipt read from the
<receipt> element - which for class="status" is nothing. Since ReceiptTypeDelivered
is the empty string, both the type="delivery" and the type="read" child are dispatched
as delivery receipts.
Observed in production: a class="status" receipt containing both a delivery and a read
entry produces two *events.Receipt with Type == ReceiptTypeDelivered and no read
receipt at all. In mautrix-whatsapp this means handleWAReceipt never reaches
RemoteEventReadReceipt, so no read marker is bridged and clients show "viewed by 0".
Suggested fix
Read message_id when key is absent, and map the per-user type. Mapping rather than
copying matters: a raw "delivery" would not match any ReceiptType constant (the
delivered constant is ""), so the delivery receipts would then be dropped entirely by
consumers that switch on the type.
--- a/receipt.go
+++ b/receipt.go
@@ -43,7 +43,12 @@
func (cli *Client) handleGroupedReceipt(partialReceipt events.Receipt, participants *waBinary.Node) (cancelled bool) {
pag := participants.AttrGetter()
- partialReceipt.MessageIDs = []types.MessageID{pag.String("key")}
+ messageID := pag.OptionalString("key")
+ if messageID == "" {
+ // Status broadcast receipts (class="status") carry the ID as message_id.
+ messageID = pag.OptionalString("message_id")
+ }
+ partialReceipt.MessageIDs = []types.MessageID{messageID}
for _, child := range participants.GetChildren() {
if child.Tag != "user" {
cli.Log.Warnf("Unexpected node in grouped receipt participants: %s", &child)
@@ -53,6 +58,17 @@
receipt := partialReceipt
receipt.Timestamp = ag.UnixTime("t")
receipt.MessageSource.Sender = ag.JID("jid")
+ // In status broadcast receipts the type is on the <user>, not on the <receipt>.
+ // Map it instead of copying: ReceiptTypeDelivered is the empty string, so a raw
+ // "delivery" would not match any constant downstream.
+ switch ag.OptionalString("type") {
+ case "read":
+ receipt.Type = types.ReceiptTypeRead
+ case "delivery":
+ receipt.Type = types.ReceiptTypeDelivered
+ case "played":
+ receipt.Type = types.ReceiptTypePlayed
+ }
if !ag.OK() {
cli.Log.Warnf("Failed to parse user node %s in grouped receipt: %v", &child, ag.Error())
continue
}This is additive for ordinary group receipts: there key is present and the <user>
children carry no type, so neither branch changes behaviour.
Tested by building mautrix-whatsapp v0.2608.0 with this patch applied. Before: no read marker appears for a viewed status. After: each viewer produces a read receipt and the bridged read marker appears as expected, while normal one-to-one and group chats are unaffected.
Happy to open a merge request on mau.dev if that is preferred.
Source: tulir/whatsmeow