#1270·whatsmeow

Status broadcast read receipts are dispatched as delivery receipts

Author: felixbk98Created Sep 20, 2026Updated Sep 20, 2026

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

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

  1. The message ID is in message_id on <participants>, not in key.
  2. 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:

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

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