Skip to content

Commit 2cda0b0

Browse files
committed
feat(chatstorage): persist WhatsApp ContextInfo and expose in /chat/:jid/messages
Reply context (whatsmeow.ContextInfo.StanzaID) is available in real-time webhook payloads (replied_to_id, quoted_body) but is not persisted. Consumers that process messages asynchronously — notably anything reacting to a prior message — have no way to know that message was a reply, because GET /chat/:jid/messages doesn't expose that information. Add a single context_metadata TEXT column (migration 17) storing a JSON blob derived from ContextInfo. Shape is open-ended so fields like mentioned_jids or is_forwarded can be added later without another migration. Pattern matches call_metadata (migration 15) and referral_metadata (migration 16). Current scope captures only replied_to_id, which is enough to unblock reply resolution in async consumers: {"replied_to_id":"..."} - infrastructure/chatstorage/sqlite_repository.go: migration 17, INSERT/UPDATE/SELECT/scanMessage include the new column. A private buildContextMetadata helper marshals ContextInfo into the JSON blob and is shared by both CreateMessage (inbound) and StoreSentMessageWithContext (outgoing) so bot-authored replies are also recoverable. - domains/chatstorage.Message: new ContextMetadata field. - domains/chat.MessageInfo: new context_metadata field with omitempty so responses for non-reply messages stay byte-identical. - usecase/chat.go: mapper copies the field through. Webhook payloads are untouched — replied_to_id and quoted_body still appear as flat top-level fields (infrastructure/whatsapp/event_message.go). Migration is additive; pre-existing messages get empty context_metadata, which is omitted from responses. Signed-off-by: Lucas Lorentz Lara <lucaslorentzlara@hotmail.com>
1 parent ae4bd12 commit 2cda0b0

4 files changed

Lines changed: 95 additions & 46 deletions

File tree

src/domains/chat/chat.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,15 @@ type MessageInfo struct {
6565
MediaType string `json:"media_type"`
6666
// CallMetadata is JSON when media_type is "call" (incoming call log).
6767
CallMetadata string `json:"call_metadata,omitempty"`
68-
Filename string `json:"filename"`
69-
URL string `json:"url"`
70-
FileLength uint64 `json:"file_length"`
71-
CreatedAt string `json:"created_at"`
72-
UpdatedAt string `json:"updated_at"`
68+
// ContextMetadata is a JSON blob of whatsmeow ContextInfo-derived fields
69+
// (replied_to_id, and room to extend with mentions, forwards, etc.).
70+
// Empty when the message has no meaningful context.
71+
ContextMetadata string `json:"context_metadata,omitempty"`
72+
Filename string `json:"filename"`
73+
URL string `json:"url"`
74+
FileLength uint64 `json:"file_length"`
75+
CreatedAt string `json:"created_at"`
76+
UpdatedAt string `json:"updated_at"`
7377
}
7478

7579
type PaginationResponse struct {

src/domains/chatstorage/chatstorage.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type Message struct {
3232
FileEncSHA256 []byte `db:"file_enc_sha256"`
3333
FileLength uint64 `db:"file_length"`
3434
ReferralMetadata string `db:"referral_metadata"`
35+
ContextMetadata string `db:"context_metadata"`
3536
CreatedAt time.Time `db:"created_at"`
3637
UpdatedAt time.Time `db:"updated_at"`
3738
}

src/infrastructure/chatstorage/sqlite_repository.go

Lines changed: 71 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,28 @@ func NewStorageRepository(db *sql.DB) domainChatStorage.IChatStorageRepository {
2727
return &SQLiteRepository{db: db}
2828
}
2929

30+
// buildContextMetadata marshals whatsmeow ContextInfo-derived fields into a
31+
// JSON blob for the messages.context_metadata column. Returns "" when the
32+
// message has no meaningful context. Shape is open so future fields
33+
// (mentioned_jids, is_forwarded, ...) can be added without a migration.
34+
func buildContextMetadata(ci *waE2E.ContextInfo) string {
35+
if ci == nil {
36+
return ""
37+
}
38+
meta := map[string]any{}
39+
if stanzaID := ci.GetStanzaID(); stanzaID != "" {
40+
meta["replied_to_id"] = stanzaID
41+
}
42+
if len(meta) == 0 {
43+
return ""
44+
}
45+
jsonBytes, err := json.Marshal(meta)
46+
if err != nil {
47+
return ""
48+
}
49+
return string(jsonBytes)
50+
}
51+
3052
// StoreChat creates or updates a chat
3153
func (r *SQLiteRepository) StoreChat(chat *domainChatStorage.Chat) error {
3254
now := time.Now()
@@ -89,7 +111,7 @@ func (r *SQLiteRepository) GetMessageByID(id string) (*domainChatStorage.Message
89111
query := `
90112
SELECT id, chat_jid, device_id, sender, content, timestamp, is_from_me,
91113
media_type, call_metadata, filename, url, media_key, file_sha256,
92-
file_enc_sha256, file_length, referral_metadata, created_at, updated_at
114+
file_enc_sha256, file_length, referral_metadata, context_metadata, created_at, updated_at
93115
FROM messages
94116
WHERE id = ?
95117
LIMIT 1
@@ -242,11 +264,11 @@ func (r *SQLiteRepository) StoreMessage(message *domainChatStorage.Message) erro
242264
result, err := r.db.Exec(`
243265
UPDATE messages SET sender = ?, content = ?, timestamp = ?, is_from_me = ?,
244266
media_type = ?, call_metadata = ?, filename = ?, url = ?, media_key = ?, file_sha256 = ?,
245-
file_enc_sha256 = ?, file_length = ?, referral_metadata = ?, updated_at = ?
267+
file_enc_sha256 = ?, file_length = ?, referral_metadata = ?, context_metadata = ?, updated_at = ?
246268
WHERE id = ? AND chat_jid = ? AND device_id = ?
247269
`, message.Sender, message.Content, message.Timestamp, message.IsFromMe,
248270
message.MediaType, message.CallMetadata, message.Filename, message.URL, message.MediaKey, message.FileSHA256,
249-
message.FileEncSHA256, message.FileLength, message.ReferralMetadata, message.UpdatedAt,
271+
message.FileEncSHA256, message.FileLength, message.ReferralMetadata, message.ContextMetadata, message.UpdatedAt,
250272
message.ID, message.ChatJID, message.DeviceID)
251273
if err != nil {
252274
return err
@@ -258,12 +280,12 @@ func (r *SQLiteRepository) StoreMessage(message *domainChatStorage.Message) erro
258280
INSERT INTO messages (
259281
id, chat_jid, device_id, sender, content, timestamp, is_from_me,
260282
media_type, call_metadata, filename, url, media_key, file_sha256,
261-
file_enc_sha256, file_length, referral_metadata, created_at, updated_at
262-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
283+
file_enc_sha256, file_length, referral_metadata, context_metadata, created_at, updated_at
284+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
263285
`, message.ID, message.ChatJID, message.DeviceID, message.Sender, message.Content,
264286
message.Timestamp, message.IsFromMe, message.MediaType, message.CallMetadata, message.Filename,
265287
message.URL, message.MediaKey, message.FileSHA256, message.FileEncSHA256,
266-
message.FileLength, message.ReferralMetadata, message.CreatedAt, message.UpdatedAt)
288+
message.FileLength, message.ReferralMetadata, message.ContextMetadata, message.CreatedAt, message.UpdatedAt)
267289
}
268290
return err
269291
}
@@ -284,7 +306,7 @@ func (r *SQLiteRepository) StoreMessagesBatch(messages []*domainChatStorage.Mess
284306
updateStmt, err := tx.Prepare(`
285307
UPDATE messages SET sender = ?, content = ?, timestamp = ?, is_from_me = ?,
286308
media_type = ?, call_metadata = ?, filename = ?, url = ?, media_key = ?, file_sha256 = ?,
287-
file_enc_sha256 = ?, file_length = ?, referral_metadata = ?, updated_at = ?
309+
file_enc_sha256 = ?, file_length = ?, referral_metadata = ?, context_metadata = ?, updated_at = ?
288310
WHERE id = ? AND chat_jid = ? AND device_id = ?
289311
`)
290312
if err != nil {
@@ -296,8 +318,8 @@ func (r *SQLiteRepository) StoreMessagesBatch(messages []*domainChatStorage.Mess
296318
INSERT INTO messages (
297319
id, chat_jid, device_id, sender, content, timestamp, is_from_me,
298320
media_type, call_metadata, filename, url, media_key, file_sha256,
299-
file_enc_sha256, file_length, referral_metadata, created_at, updated_at
300-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
321+
file_enc_sha256, file_length, referral_metadata, context_metadata, created_at, updated_at
322+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
301323
`)
302324
if err != nil {
303325
return fmt.Errorf("failed to prepare insert statement: %w", err)
@@ -316,7 +338,7 @@ func (r *SQLiteRepository) StoreMessagesBatch(messages []*domainChatStorage.Mess
316338
result, err := updateStmt.Exec(
317339
message.Sender, message.Content, message.Timestamp, message.IsFromMe,
318340
message.MediaType, message.CallMetadata, message.Filename, message.URL, message.MediaKey, message.FileSHA256,
319-
message.FileEncSHA256, message.FileLength, message.ReferralMetadata, message.UpdatedAt,
341+
message.FileEncSHA256, message.FileLength, message.ReferralMetadata, message.ContextMetadata, message.UpdatedAt,
320342
message.ID, message.ChatJID, message.DeviceID,
321343
)
322344
if err != nil {
@@ -329,7 +351,7 @@ func (r *SQLiteRepository) StoreMessagesBatch(messages []*domainChatStorage.Mess
329351
message.ID, message.ChatJID, message.DeviceID, message.Sender, message.Content,
330352
message.Timestamp, message.IsFromMe, message.MediaType, message.CallMetadata, message.Filename,
331353
message.URL, message.MediaKey, message.FileSHA256, message.FileEncSHA256,
332-
message.FileLength, message.ReferralMetadata, message.CreatedAt, message.UpdatedAt,
354+
message.FileLength, message.ReferralMetadata, message.ContextMetadata, message.CreatedAt, message.UpdatedAt,
333355
)
334356
if err != nil {
335357
return fmt.Errorf("failed to insert message %s: %w", message.ID, err)
@@ -379,7 +401,7 @@ func (r *SQLiteRepository) GetMessages(filter *domainChatStorage.MessageFilter)
379401
query := `
380402
SELECT id, chat_jid, device_id, sender, content, timestamp, is_from_me,
381403
media_type, call_metadata, filename, url, media_key, file_sha256,
382-
file_enc_sha256, file_length, referral_metadata, created_at, updated_at
404+
file_enc_sha256, file_length, referral_metadata, context_metadata, created_at, updated_at
383405
FROM messages
384406
WHERE ` + strings.Join(conditions, " AND ") + `
385407
ORDER BY timestamp DESC
@@ -444,7 +466,7 @@ func (r *SQLiteRepository) SearchMessages(deviceID, chatJID, searchText string,
444466
query := `
445467
SELECT id, chat_jid, device_id, sender, content, timestamp, is_from_me,
446468
media_type, call_metadata, filename, url, media_key, file_sha256,
447-
file_enc_sha256, file_length, referral_metadata, created_at, updated_at
469+
file_enc_sha256, file_length, referral_metadata, context_metadata, created_at, updated_at
448470
FROM messages
449471
WHERE ` + strings.Join(conditions, " AND ") + `
450472
ORDER BY timestamp DESC
@@ -507,7 +529,7 @@ func (r *SQLiteRepository) scanMessage(scanner interface{ Scan(...any) error })
507529
&message.ID, &message.ChatJID, &message.DeviceID, &message.Sender, &message.Content,
508530
&message.Timestamp, &message.IsFromMe, &message.MediaType, &message.CallMetadata, &message.Filename,
509531
&message.URL, &message.MediaKey, &message.FileSHA256, &message.FileEncSHA256,
510-
&message.FileLength, &message.ReferralMetadata, &message.CreatedAt, &message.UpdatedAt,
532+
&message.FileLength, &message.ReferralMetadata, &message.ContextMetadata, &message.CreatedAt, &message.UpdatedAt,
511533
)
512534
return message, err
513535
}
@@ -853,6 +875,12 @@ func (r *SQLiteRepository) CreateMessage(ctx context.Context, evt *events.Messag
853875
}
854876
}
855877

878+
// Capture whatsmeow ContextInfo (reply refs, ...) as a JSON blob so the
879+
// info survives past the webhook event and shows up in /chat/:jid/messages
880+
// responses. Structure is open so additional keys (mentions, forwards)
881+
// can land without a new migration.
882+
contextMetadata := buildContextMetadata(utils.ExtractContextInfo(evt.Message))
883+
856884
message := &domainChatStorage.Message{
857885
ID: evt.Info.ID,
858886
ChatJID: chatJID,
@@ -869,6 +897,7 @@ func (r *SQLiteRepository) CreateMessage(ctx context.Context, evt *events.Messag
869897
FileEncSHA256: fileEncSHA256,
870898
FileLength: fileLength,
871899
ReferralMetadata: referralMetadata,
900+
ContextMetadata: contextMetadata,
872901
}
873902

874903
// Store the message
@@ -1098,22 +1127,31 @@ func (r *SQLiteRepository) StoreSentMessageWithContext(ctx context.Context, mess
10981127
mediaType, filename, mediaURL, mediaKey, fileSHA256, fileEncSHA256, fileLength = utils.ExtractMediaInfo(msg)
10991128
}
11001129

1130+
// Capture ContextInfo (reply refs, ...) from the outgoing message too so
1131+
// bot-authored replies show up in /chat/:jid/messages with the same shape
1132+
// as inbound replies. Mirrors the logic in CreateMessage.
1133+
var contextMetadata string
1134+
if msg != nil {
1135+
contextMetadata = buildContextMetadata(utils.ExtractContextInfo(msg))
1136+
}
1137+
11011138
// Store the sent message
11021139
message := &domainChatStorage.Message{
1103-
ID: messageID,
1104-
ChatJID: chatJID,
1105-
DeviceID: deviceID,
1106-
Sender: senderJID,
1107-
Content: content,
1108-
Timestamp: timestamp,
1109-
IsFromMe: true,
1110-
MediaType: mediaType,
1111-
Filename: filename,
1112-
URL: mediaURL,
1113-
MediaKey: mediaKey,
1114-
FileSHA256: fileSHA256,
1115-
FileEncSHA256: fileEncSHA256,
1116-
FileLength: fileLength,
1140+
ID: messageID,
1141+
ChatJID: chatJID,
1142+
DeviceID: deviceID,
1143+
Sender: senderJID,
1144+
Content: content,
1145+
Timestamp: timestamp,
1146+
IsFromMe: true,
1147+
MediaType: mediaType,
1148+
Filename: filename,
1149+
URL: mediaURL,
1150+
MediaKey: mediaKey,
1151+
FileSHA256: fileSHA256,
1152+
FileEncSHA256: fileEncSHA256,
1153+
FileLength: fileLength,
1154+
ContextMetadata: contextMetadata,
11171155
}
11181156

11191157
return r.StoreMessage(message)
@@ -1269,5 +1307,10 @@ func (r *SQLiteRepository) getMigrations() []string {
12691307

12701308
// Migration 16: JSON metadata for Meta Ads referral/attribution (CTWA)
12711309
`ALTER TABLE messages ADD COLUMN referral_metadata TEXT DEFAULT ''`,
1310+
1311+
// Migration 17: JSON metadata for whatsmeow ContextInfo (reply refs,
1312+
// mentions, forward info). Structure kept open-ended so future fields
1313+
// don't require a new migration — consumers JSON-parse on read.
1314+
`ALTER TABLE messages ADD COLUMN context_metadata TEXT DEFAULT ''`,
12721315
}
12731316
}

src/usecase/chat.go

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -165,19 +165,20 @@ func (service serviceChat) GetChatMessages(ctx context.Context, request domainCh
165165
messageInfos := make([]domainChat.MessageInfo, 0, len(messages))
166166
for _, message := range messages {
167167
messageInfo := domainChat.MessageInfo{
168-
ID: message.ID,
169-
ChatJID: message.ChatJID,
170-
SenderJID: message.Sender,
171-
Content: message.Content,
172-
Timestamp: message.Timestamp.Format(time.RFC3339),
173-
IsFromMe: message.IsFromMe,
174-
MediaType: message.MediaType,
175-
CallMetadata: message.CallMetadata,
176-
Filename: message.Filename,
177-
URL: message.URL,
178-
FileLength: message.FileLength,
179-
CreatedAt: message.CreatedAt.Format(time.RFC3339),
180-
UpdatedAt: message.UpdatedAt.Format(time.RFC3339),
168+
ID: message.ID,
169+
ChatJID: message.ChatJID,
170+
SenderJID: message.Sender,
171+
Content: message.Content,
172+
Timestamp: message.Timestamp.Format(time.RFC3339),
173+
IsFromMe: message.IsFromMe,
174+
MediaType: message.MediaType,
175+
CallMetadata: message.CallMetadata,
176+
ContextMetadata: message.ContextMetadata,
177+
Filename: message.Filename,
178+
URL: message.URL,
179+
FileLength: message.FileLength,
180+
CreatedAt: message.CreatedAt.Format(time.RFC3339),
181+
UpdatedAt: message.UpdatedAt.Format(time.RFC3339),
181182
}
182183
messageInfos = append(messageInfos, messageInfo)
183184
}

0 commit comments

Comments
 (0)