Skip to content

feat(chatstorage): persist WhatsApp ContextInfo and expose in /chat/:jid/messages#648

Open
lucaslorentz wants to merge 1 commit intoaldinokemal:mainfrom
lucaslorentz:feat/persist-context-metadata
Open

feat(chatstorage): persist WhatsApp ContextInfo and expose in /chat/:jid/messages#648
lucaslorentz wants to merge 1 commit intoaldinokemal:mainfrom
lucaslorentz:feat/persist-context-metadata

Conversation

@lucaslorentz
Copy link
Copy Markdown

Problem

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.

Concrete scenario: a bot listens to message.reaction webhooks, then calls GET /chat/:jid/messages to resolve the reacted message. If the reacted message itself was a reply, the bot can't reconstruct that link — the field simply doesn't exist in the response.

Approach

Add a single context_metadata TEXT DEFAULT '' column to the messages table (migration 17) storing a JSON blob derived from ContextInfo. Shape is open-ended so additional fields (mentions, forward info) can land later without another migration.

Pattern matches call_metadata (Migration 15) and referral_metadata (Migration 16) — both JSON blobs stored alongside the core fields.

Current scope

Only replied_to_id is captured for now:

```json
{"replied_to_id":"AC625C65EA7143E71D784176C4A90A93"}
```

Enough to unblock reply resolution. quoted_body, mentioned_jids, is_forwarded, etc. can follow without schema changes.

Changes

  • Migration 17 — `ALTER TABLE messages ADD COLUMN context_metadata TEXT DEFAULT ''`
  • `domains/chatstorage.Message` — new `ContextMetadata string` field with `db:"context_metadata"` tag
  • `infrastructure/chatstorage/sqlite_repository.go` — INSERT/UPDATE (both `StoreMessage` and `StoreMessagesBatch`), SELECTs, and `scanMessage` include the new column
  • `CreateMessage` (inbound ingestion) — extracts `ContextInfo.StanzaID` via `utils.ExtractContextInfo` and marshals into the blob
  • `CreateSentMessage` (outgoing bot messages) — same extraction, so bot-authored replies are also recoverable
  • `domains/chat.MessageInfo` — new `ContextMetadata string `json:"context_metadata,omitempty"`` field
  • `usecase/chat.go` — mapper copies the field through

Response shape

For messages with context:

```json
{
"id": "AC4BFC5C3AD9CFB3797F8E9F8D2968A0",
"content": "got it",
"context_metadata": "{\"replied_to_id\":\"AC625C65EA7143E71D784176C4A90A93\"}",
...
}
```

Consumers `json.Unmarshal` when present. Messages without context omit the field entirely (`omitempty`), so existing responses look unchanged.

Webhook payloads unchanged

`replied_to_id` and `quoted_body` still show up as flat top-level fields in `message`-event webhook payloads (`infrastructure/whatsapp/event_message.go`). Existing webhook consumers are not affected.

Backward compatibility

  • Additive migration (`ADD COLUMN ... DEFAULT ''`).
  • `omitempty` keeps old responses byte-identical for messages without context.
  • No renames, no type changes, no behavior change for non-reply messages.

Validation

Fresh build against `main` HEAD on darwin/arm64, deployed over a live instance with schema previously at version 14. Migrations 15–17 ran cleanly. Sent a WhatsApp reply; verified:

  • `chatstorage.db` row has `context_metadata = '{"replied_to_id":"..."}'` on the reply, empty on the quoted message.
  • `GET /chat/:jid/messages` response includes the `context_metadata` string.
  • Pre-migration messages show empty `context_metadata` → omitted from response, no visible change.

Related gap (separate)

`referral_metadata` is stored but not mapped into `MessageInfo` (no struct field, no mapper copy). Kept out of scope here; can follow up in a tiny separate PR if interested.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 24, 2026

Walkthrough

Adds an optional context_metadata JSON field to messages across domain, storage, repository, and use-case layers; the field is derived from WhatsMeow context (e.g., replied-to IDs), persisted in the DB, and included in message retrieval responses.

Changes

Cohort / File(s) Summary
Domain Models
src/domains/chat/chat.go, src/domains/chatstorage/chatstorage.go
Added ContextMetadata string field to exported message structs (json:"context_metadata,omitempty", db:"context_metadata") to carry an open-ended WhatsMeow ContextInfo JSON blob.
Repository / Persistence
src/infrastructure/chatstorage/sqlite_repository.go
Added context_metadata DB column via migration; introduced buildContextMetadata helper to marshal context info; read paths (GetMessageByID, GetMessages, SearchMessages, scanMessage) now select/scan the field; write paths (StoreMessage, StoreMessagesBatch, CreateMessage, StoreSentMessageWithContext) populate it.
Use Case Layer
src/usecase/chat.go
GetChatMessages now maps message.ContextMetadata into returned domainChat.MessageInfo responses; no other control-flow changes.

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant Usecase as "Usecase\nGetChatMessages"
    participant Repo as "SQLiteRepo\n(messages table)"
    participant Domain as "Domain\nCreateMessage"

    Client->>Usecase: Request messages
    Usecase->>Repo: Query messages (includes context_metadata)
    Repo-->>Usecase: Rows with context_metadata
    Usecase-->>Client: Response (messages include context_metadata)

    Note right of Repo: insertion path
    Domain->>Repo: buildContextMetadata(contextInfo) -> store context_metadata
    Repo-->>Domain: persist message with context_metadata
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I found a tiny breadcrumb of context to keep,
I tucked it into messages, gentle and deep,
From event to DB and back to the view,
Replies now whisper where they once grew. 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: adding persistence of WhatsApp ContextInfo metadata and exposing it in the messages API endpoint.
Description check ✅ Passed The description is comprehensive, covering problem statement, approach, scope, detailed changes, response format, backward compatibility, and validation. It follows the intent of good PR documentation.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.11.4)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/infrastructure/chatstorage/sqlite_repository.go (2)

1119-1135: Fix stale comment reference and consider extracting context metadata logic.

Two issues on this block:

  1. The comment on line 1121 references StoreMessageFromEvent, which does not exist in the repo. The inbound counterpart is CreateMessage. Update to avoid future confusion.
  2. The local ctx := map[string]any{} (line 1125) shadows the outer ctx context.Context parameter. The same pattern appears in CreateMessage (lines 856–871). Consider extracting both into a reusable helper function (e.g., buildContextMetadata) to eliminate the shadowing and reduce duplication.
🧹 Suggested refactoring
-	// Capture ContextInfo (reply refs, ...) from the outgoing message too so
-	// bot-authored replies show up in /chat/:jid/messages with the same shape
-	// as inbound replies. Mirrors the logic in StoreMessageFromEvent.
+	// Capture ContextInfo (reply refs, ...) from the outgoing message too so
+	// bot-authored replies show up in /chat/:jid/messages with the same shape
+	// as inbound replies. Mirrors the logic in CreateMessage.
	var contextMetadata string
	if msg != nil {
-		if ci := utils.ExtractContextInfo(msg); ci != nil {
-			ctx := map[string]any{}
-			if stanzaID := ci.GetStanzaID(); stanzaID != "" {
-				ctx["replied_to_id"] = stanzaID
-			}
-			if len(ctx) > 0 {
-				if jsonBytes, err := json.Marshal(ctx); err == nil {
-					contextMetadata = string(jsonBytes)
-				}
-			}
-		}
+		contextMetadata = buildContextMetadata(utils.ExtractContextInfo(msg))
	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/infrastructure/chatstorage/sqlite_repository.go` around lines 1119 -
1135, Update the stale comment to reference the inbound counterpart
CreateMessage instead of the non-existent StoreMessageFromEvent, and eliminate
the local variable shadowing of the context.Context parameter by extracting the
context-metadata construction into a reusable helper (e.g.,
buildContextMetadata) that returns the JSON string; replace the inline map
creation in the current function and the CreateMessage function with calls to
buildContextMetadata and use a different name for the context.Context parameter
where needed to avoid shadowing (refer to utils.ExtractContextInfo,
CreateMessage, and the current function when locating the code to change).

856-871: Rename the local ctx map — it shadows the context.Context parameter, and extract a helper to dedupe with StoreSentMessageWithContext.

Two concerns in this block:

  1. Shadowing ctx context.Context. ctx := map[string]any{} on line 862 shadows the function's ctx context.Context parameter. Although the shadow scope currently ends before any further context usage in this function, a future edit that adds a ctx-taking call inside this if block will silently misuse the map. go vet -vettool=shadow would flag this.
  2. Duplication. The exact same 10-line extraction pattern is repeated in StoreSentMessageWithContext (lines 1125–1134). Extracting a small helper keeps future ContextInfo fields (mentions, forward info — per the doc comment) consistent across inbound and outbound paths.
♻️ Proposed helper + call sites

Add a package-level helper (e.g., near the top of the file or next to ExtractContextInfo in pkg/utils):

// buildContextMetadata marshals whatsmeow ContextInfo-derived fields into a
// JSON blob for storage. Returns "" when the message has no meaningful context.
func buildContextMetadata(ci *waE2E.ContextInfo) string {
    if ci == nil {
        return ""
    }
    meta := map[string]any{}
    if stanzaID := ci.GetStanzaID(); stanzaID != "" {
        meta["replied_to_id"] = stanzaID
    }
    if len(meta) == 0 {
        return ""
    }
    jsonBytes, err := json.Marshal(meta)
    if err != nil {
        return ""
    }
    return string(jsonBytes)
}

Then in CreateMessage:

-	// Capture whatsmeow ContextInfo (reply refs, ...) as a JSON blob so the
-	// info survives past the webhook event and shows up in /chat/:jid/messages
-	// responses. Structure is open so additional keys (mentions, forwards)
-	// can land without a new migration.
-	var contextMetadata string
-	if ci := utils.ExtractContextInfo(evt.Message); ci != nil {
-		ctx := map[string]any{}
-		if stanzaID := ci.GetStanzaID(); stanzaID != "" {
-			ctx["replied_to_id"] = stanzaID
-		}
-		if len(ctx) > 0 {
-			if jsonBytes, err := json.Marshal(ctx); err == nil {
-				contextMetadata = string(jsonBytes)
-			}
-		}
-	}
+	// Capture whatsmeow ContextInfo (reply refs, ...) as a JSON blob so the
+	// info survives past the webhook event and shows up in /chat/:jid/messages.
+	contextMetadata := buildContextMetadata(utils.ExtractContextInfo(evt.Message))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/infrastructure/chatstorage/sqlite_repository.go` around lines 856 - 871,
The local map variable named ctx shadows the function parameter ctx
context.Context and the same ContextInfo-to-JSON logic is duplicated in
StoreSentMessageWithContext; fix by adding a helper function (e.g.,
buildContextMetadata(ci *waE2E.ContextInfo) string) that returns the JSON blob
or "" and replace the inline map logic in both CreateMessage (the block using
utils.ExtractContextInfo and the local ctx map) and StoreSentMessageWithContext
with calls to buildContextMetadata; also rename/remove the local ctx map to
avoid shadowing so the function parameter ctx remains unshadowed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/infrastructure/chatstorage/sqlite_repository.go`:
- Around line 1119-1135: Update the stale comment to reference the inbound
counterpart CreateMessage instead of the non-existent StoreMessageFromEvent, and
eliminate the local variable shadowing of the context.Context parameter by
extracting the context-metadata construction into a reusable helper (e.g.,
buildContextMetadata) that returns the JSON string; replace the inline map
creation in the current function and the CreateMessage function with calls to
buildContextMetadata and use a different name for the context.Context parameter
where needed to avoid shadowing (refer to utils.ExtractContextInfo,
CreateMessage, and the current function when locating the code to change).
- Around line 856-871: The local map variable named ctx shadows the function
parameter ctx context.Context and the same ContextInfo-to-JSON logic is
duplicated in StoreSentMessageWithContext; fix by adding a helper function
(e.g., buildContextMetadata(ci *waE2E.ContextInfo) string) that returns the JSON
blob or "" and replace the inline map logic in both CreateMessage (the block
using utils.ExtractContextInfo and the local ctx map) and
StoreSentMessageWithContext with calls to buildContextMetadata; also
rename/remove the local ctx map to avoid shadowing so the function parameter ctx
remains unshadowed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: d8aa6253-bf70-4833-b054-b31c59c7840d

📥 Commits

Reviewing files that changed from the base of the PR and between ae4bd12 and 37dcfd5.

📒 Files selected for processing (4)
  • src/domains/chat/chat.go
  • src/domains/chatstorage/chatstorage.go
  • src/infrastructure/chatstorage/sqlite_repository.go
  • src/usecase/chat.go

coderabbitai[bot]
coderabbitai Bot previously approved these changes Apr 24, 2026
lucaslorentz added a commit to lucaslorentz/go-whatsapp-web-multidevice that referenced this pull request Apr 24, 2026
…mment

Address CodeRabbit nitpicks on aldinokemal#648:

- Extract the inline ContextInfo → JSON marshalling into a private
  buildContextMetadata(*waE2E.ContextInfo) string helper so CreateMessage
  and StoreSentMessageWithContext share one path. Future ContextInfo
  fields (mentioned_jids, is_forwarded) only need to be added once.
- Drop the local `ctx := map[string]any{}` that shadowed the outer
  context.Context parameter.
- Fix a stale comment that referenced the non-existent
  StoreMessageFromEvent — the inbound counterpart is CreateMessage.

No behavior change; output JSON shape is identical.
…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>
@lucaslorentz lucaslorentz force-pushed the feat/persist-context-metadata branch from f4720f9 to 2cda0b0 Compare April 24, 2026 22:06
@kilo-code-bot
Copy link
Copy Markdown

kilo-code-bot Bot commented Apr 24, 2026

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (4 files)
  • src/domains/chat/chat.go
  • src/domains/chatstorage/chatstorage.go
  • src/infrastructure/chatstorage/sqlite_repository.go
  • src/usecase/chat.go

Reviewed by step-3.5-flash · 233,081 tokens

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/infrastructure/chatstorage/sqlite_repository.go (1)

30-50: LGTM with one optional refinement.

The helper is correctly defensive: nil input, empty meta, and marshal failure all collapse to "", which combines cleanly with omitempty in the API model. With map[string]any of plain strings, json.Marshal cannot realistically fail, so the silent fallback is fine. If this helper later grows to include richer types (mentions list, forward info, etc.), consider logging the marshal error so a future regression doesn't get swallowed.

♻️ Optional: log the marshal failure path
 	jsonBytes, err := json.Marshal(meta)
 	if err != nil {
+		logrus.WithError(err).Warn("failed to marshal context metadata")
 		return ""
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/infrastructure/chatstorage/sqlite_repository.go` around lines 30 - 50,
The buildContextMetadata helper swallows json.Marshal errors silently; update
buildContextMetadata (which accepts *waE2E.ContextInfo) to log the marshal
failure before returning "" so future richer types don't lose
diagnostics—capture the error from json.Marshal(meta) and call the package
logger (or processLogger) with a clear message including the error and context
(e.g., the meta contents or stanzaID) then return "" as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/infrastructure/chatstorage/sqlite_repository.go`:
- Around line 30-50: The buildContextMetadata helper swallows json.Marshal
errors silently; update buildContextMetadata (which accepts *waE2E.ContextInfo)
to log the marshal failure before returning "" so future richer types don't lose
diagnostics—capture the error from json.Marshal(meta) and call the package
logger (or processLogger) with a clear message including the error and context
(e.g., the meta contents or stanzaID) then return "" as before.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: e8f2dc38-7424-435d-b0ff-594da549abbb

📥 Commits

Reviewing files that changed from the base of the PR and between f4720f9 and 2cda0b0.

📒 Files selected for processing (4)
  • src/domains/chat/chat.go
  • src/domains/chatstorage/chatstorage.go
  • src/infrastructure/chatstorage/sqlite_repository.go
  • src/usecase/chat.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/domains/chatstorage/chatstorage.go
  • src/domains/chat/chat.go

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant