Environment
- WAHA
devlikeapro/waha:gows-2026.8.2 → gows-plus v1.0.46 (c831525), whatsmeow fork v0.0.0-20260831053551-1ba57cc32ea6
- SQLite message storage, one session, one group with LID addressing (
addressing_mode="lid")
- Also present on 2026.7.1 and 2026.8.1
Summary
A group message whose stanza carries a sender-key distribution (<enc type="pkmsg"> + <enc type="skmsg">) produces two events.Message with the same Info.ID: one whose Message holds only senderKeyDistributionMessage + messageContextInfo, and one with the real content. gows-plus upserts both under the same primary key, from separate goroutines, with last-writer-wins. When the sender-key-only event lands last, the decrypted content is replaced by the wrapper and is gone from storage — even though decryption succeeded and WAHA had already downloaded the media.
Where
- whatsmeow
message.go decryptMessages: iterates the <enc> children and calls handleDecryptedMessage once per child, so the pkmsg child (SKDM only) and the skmsg child (content) are dispatched as two events with identical Info.
src/gows/gows.go:
func (gows *GoWS) handleEvent(event interface{}) {
go gows.reissueEvent(event)
go gows.storageEventHandler.handleEvent(event)
}
src/gows/storage_event_handler.go handleSaveMessage → UpsertOneMessage for every event, no check whether the event has content.
src/storage/sqlstorage/tables.go MessageTable: OnConflict: ["id"], UpdateOnConflict: ["timestamp", "data"] → ON CONFLICT (id) DO UPDATE SET timestamp=…, data=… — a full replace of data.
Both goroutines race; the stanza order does not determine the storage order.
Evidence (production store, six weeks, one group)
- 56 of 199 group messages are stored with
Message = {senderKeyDistributionMessage, messageContextInfo} only, while Info.Type/Info.MediaType say text or document.
- For the document ones, WAHA's
MediaManager logged The message <id> has media, downloading it... → The media from '<id>' has been saved. at arrival — the content child decrypted and the media downloaded fine. The stored row lost it afterwards.
- The
is_real column (written only on INSERT in v1.0.46) vs. the blob's IsReal shows both orders happen:
SELECT id, is_real, json_extract(data,'$.IsReal') AS blob_is_real,
(SELECT group_concat(key) FROM json_each(json_extract(data,'$.Message'))) AS message_keys
FROM gows_messages WHERE jid = '<group>@g.us'
AND json_extract(data,'$.Message.senderKeyDistributionMessage') IS NOT NULL;
- Trigger: a member's first message after being quiet for a while (WhatsApp attaches the SKDM). Any such message has roughly a coin-flip chance of being lost from storage.
Consequence in WAHA
GET /api/{session}/chats/{chat}/messages/{id} → getChatMessage → processIncomingMessage → shouldProcessIncomingMessage returns falsy for an SKDM-only message → NotFoundException('Message not found'). Anything that later needs the message by id — media re-download, quoting, listing — fails. In our case 14 documents became unrecoverable this way.
Relation to existing work
Suggested fix
Never let an event without content replace a row that has content. For example, in handleSaveMessage:
- skip the upsert when
!isRealMessage(event) and a row with that id already exists; or
- guard the conflict update:
ON CONFLICT (id) DO UPDATE SET … WHERE excluded.is_real = 1 OR gows_messages.is_real = 0.
Arguably the SKDM-only event should not be stored at all — WAHA drops it downstream in shouldProcessIncomingMessage anyway.
Happy to test a build against the affected store.
Environment
devlikeapro/waha:gows-2026.8.2→ gows-plus v1.0.46 (c831525), whatsmeow forkv0.0.0-20260831053551-1ba57cc32ea6addressing_mode="lid")Summary
A group message whose stanza carries a sender-key distribution (
<enc type="pkmsg">+<enc type="skmsg">) produces twoevents.Messagewith the sameInfo.ID: one whoseMessageholds onlysenderKeyDistributionMessage+messageContextInfo, and one with the real content. gows-plus upserts both under the same primary key, from separate goroutines, with last-writer-wins. When the sender-key-only event lands last, the decrypted content is replaced by the wrapper and is gone from storage — even though decryption succeeded and WAHA had already downloaded the media.Where
message.godecryptMessages: iterates the<enc>children and callshandleDecryptedMessageonce per child, so the pkmsg child (SKDM only) and the skmsg child (content) are dispatched as two events with identicalInfo.src/gows/gows.go:src/gows/storage_event_handler.gohandleSaveMessage→UpsertOneMessagefor every event, no check whether the event has content.src/storage/sqlstorage/tables.goMessageTable:OnConflict: ["id"],UpdateOnConflict: ["timestamp", "data"]→ON CONFLICT (id) DO UPDATE SET timestamp=…, data=…— a full replace ofdata.Both goroutines race; the stanza order does not determine the storage order.
Evidence (production store, six weeks, one group)
Message = {senderKeyDistributionMessage, messageContextInfo}only, whileInfo.Type/Info.MediaTypesaytextordocument.MediaManagerloggedThe message <id> has media, downloading it...→The media from '<id>' has been saved.at arrival — the content child decrypted and the media downloaded fine. The stored row lost it afterwards.is_realcolumn (written only on INSERT in v1.0.46) vs. the blob'sIsRealshows both orders happen:is_real=1,blob_is_real=false, keys = SKDM only → content inserted first, overwritten by the wrapper (the lossy order).is_real=0,blob_is_real=true, keys =documentMessage,…→ wrapper first, content won (the order PR Refresh is_real on upsert so late-decrypted messages stay listable #8 addresses).Consequence in WAHA
GET /api/{session}/chats/{chat}/messages/{id}→getChatMessage→processIncomingMessage→shouldProcessIncomingMessagereturns falsy for an SKDM-only message →NotFoundException('Message not found'). Anything that later needs the message by id — media re-download, quoting, listing — fails. In our case 14 documents became unrecoverable this way.Relation to existing work
dev) addsis_realtoUpdateOnConflict. That fixes the wrapper-first order, but in the content-first order it will now also setis_real=falseon the overwrite, so the message additionally disappears fromis_reallistings. The overwrite itself is the bug.Suggested fix
Never let an event without content replace a row that has content. For example, in
handleSaveMessage:!isRealMessage(event)and a row with that id already exists; orON CONFLICT (id) DO UPDATE SET … WHERE excluded.is_real = 1 OR gows_messages.is_real = 0.Arguably the SKDM-only event should not be stored at all — WAHA drops it downstream in
shouldProcessIncomingMessageanyway.Happy to test a build against the affected store.