Repository navigation
fix: Components V2 messages are archived with empty text and missing from search - #265
Conversation
discordgo decodes Message.Components but tags the field json:"-", so raw_json dropped the component tree, and normalization ignored components, leaving Components V2 messages with empty normalized_content, FTS and snapshot text. Restore components when marshaling raw_json and index Components V2 text via the embed normalization path in bot sync and Desktop import.
|
🦞👀 Pull request received. I will update this pull request when review starts. ClawSweeper review completeClawSweeper finished reviewing this revision. The review result is being finalized. |
|
Codex review: blocked before merge. Reviewed September 30, 2026, 12:30 AM ET / 04:30 UTC (Revision 3). ClawSweeper reviewWhat this changesThe branch preserves Discord Components V2 trees during bot sync, indexes their visible text from bot and Desktop imports, and documents search behavior and recovery of older archives. Merge readiness⛔ Blocked before merge - 3 items remain Current main and the latest release still omit Components V2 text from archived search content. This PR addresses both bot sync and Desktop imports, and its latest commit addresses the earlier Desktop recovery finding. Priority: P2 Review scores
Verification
How this fits togetherDiscrawl imports messages from the Discord bot API and Desktop cache into a local SQLite archive. The archive’s normalized text feeds search indexes and published Git snapshots. flowchart LR
A[Discord bot API] --> B[Bot sync]
C[Desktop cache] --> D[Desktop importer]
B --> E[SQLite archive]
D --> E
E --> F[Search indexes]
E --> G[Git snapshots]
Decision needed
Why: The procedure changes persisted archive state and can trigger a broad replay; technical proof does not establish the archive operator’s acceptance of that operation. Before merge
Agent review detailsSecurityNone. Review metrics
Merge-risk optionsMaintainer options:
Technical reviewBest possible solution: Ship the two import-path fixes with an archive-owner-approved, scoped recovery procedure that verifies repaired rows before publication and states which old rows cannot be recovered. Do we have a high-confidence way to reproduce the issue? Yes. The supplied Components V2 payload and before/after CLI transcript give a repeatable current-main path from bot sync to an empty search result; source inspection supports the same failure mechanism. Is this the best way to solve the issue? Yes. Extracting text in the two existing import paths while preserving Discord’s original content field is a focused repair; historical replay needs archive-operator approval. AGENTS.md: not found in the target repository. Codex review notes: model internal, reasoning high; reviewed against d55485748d2a. LabelsLabel changes:
Label justifications:
EvidenceWhat I checked:
Likely related people:
Rank-up movesOptional improvements that raise the rating; they are not merge blockers.
Rating scale
Overall follows the weaker of proof and patch quality. Workflow
History |
|
Maintainer approval on exact head As archive owner, I approve the documented recovery runbook in Revision 3 of the review has no findings, and all CI checks pass on this head. The fix is confirmed: the regression test fails on |
What Problem This Solves
Fixes: Discord messages sent with Components V2 (message flag
IS_COMPONENTS_V2,1 << 15) are archived with emptycontentandnormalized_content, and without theircomponents, sosearch, FTS, SQL and published Git snapshots lose them. One example is the Hermit app's/githubPR cards in the OpenClaw Discord ([OPEN] PR #160926 fix(secrets): … size: XL • P0 • needs proof). The publishing archive has 50 such rows since 2026-09-22, each withflags = 32768,content = "",embeds = []and nocomponentskey.User Impact
User impact: newly synced Components V2 messages keep their component tree in
raw_json, and their visible text is searchable viasearch,normalized_content, FTS, embeddings input and published snapshots.contentis left as Discord sent it (empty), which matches how embeds are handled.Action required on the publishing side: rows archived before this fix never stored their components, so they can't be repaired locally. They have to be fetched from Discord again. None of the existing commands do this on their own (see Backfill below).
Why This Change Was Made
Root cause
components.go:41-77: 1 action row, 2 button, 9 section, 10 text display, 11 thumbnail, 12 media gallery, 14 separator, 17 container) intoMessage.Components.discordgo@v0.29.0/message.go:89tagsComponents []MessageComponentasjson:"-".Messagehas a customUnmarshalJSON(message.go:167-183) but noMarshalJSON, sointernal/syncer/records.go:45(raw := marshalJSONString(message, "{}")) writesraw_jsonwithoutcomponents.internal/syncer/records.go:94-135(normalizeMessageParts) buildsnormalized_contentfrom content, attachments, embed title and description, reply and poll. A Components V2 message has none of these, so the result is"". FTS (internal/store/write.go:432-435) and snapshots both readnormalized_content.internal/discorddesktop/payload.go:175) had the same gap innormalizeText. Itsraw_jsonis intentionally a sanitized summary, so that stays unchanged.Fix:
toMessageRecordmarshals the message with itscomponentsrestored (omitempty, soraw_jsonfor messages without components is byte-identical).normalizeMessagePartsreuses the embed path: when the Components V2 flag is set, it appends the tree's text to the same parts list. That covers text display content (including inside containers and sections), button labels and link URLs, and thumbnail and media-gallery descriptions. Classic messages whose components are only buttons keep their currentnormalized_content, so they don't churn and no embeddings are re-queued.componentTextnext toembedText.@merows are still excluded, and the change adds no new secret-bearing fields.Backfill
Neither
sync,sync --all-channels,sync --fullnorsync --channelsre-fetches messages that are already archived. Each resumes after the channel'slatest_message_idcursor, and--fullskips channels withhistory_complete.scripts/reconcile_cloud_source.goskips rows that already exist. I checked this against a pre-fix archive using the fixed binary: all three runs wrotemessages=0, and--channelsrequestedafter=<the affected id>. Desktop imports have the same gap: a repeatwiretapskips unchanged cache files. Their sanitizedraw_jsonalso stores no flags, so the bot-row query can't find them.The recovery procedure is now in the docs, under Re-fetching Components V2 messages archived with empty text in
docs/commands/sync.md; the CHANGELOG entry links to it. The steps are:sqlite3 "$DB" ".backup '$DB.before-components-v2'", whereDBcomes fromdiscrawl status. The online backup is consistent. To roll back, stop writers, thencp "$DB.before-components-v2" "$DB" && rm -f "$DB-wal" "$DB-shm".sync,tailand scheduled sync that writes to the archive.deleted_at is null and json_extract(raw_json,'$.flags') & 32768 and json_type(raw_json,'$.components') is null. Save the channel ids.channel:<id>:latest_message_idcursor to (oldest broken id − 1).discrawl sync --source discord --channels "$CHANNELS". It forward-crawls from the rewound cursor and upserts every message it receives, which rewritesraw_json,normalized_content, FTS and lexical indexes in place.AdvanceChannelLatestMessageIDonly moves forward, so the cursor ends at the channel head again.0, anddiscrawl search "<text from a known card>"should find the card. Any rows left are messages Discord no longer returns.wiretap:file_index:%fromsync_stateand rerundiscrawl wiretap, which rescans every cache file.BROKEN="$DESKTOP and guild_id <> '@me'", passing those channels with--channels.discrawl publish --push.For the current 50 rows this re-crawls about eight days of the affected channels. The live
tailalso re-fetches a message onMESSAGE_UPDATE, so cards edited later (for exampleOPEN→MERGED) repair themselves.Evidence
TestComponentsV2MessageKeepsCardTextSearchable(internal/syncer/records_test.go) runs a sanitized Hermit-style payload throughjson.Unmarshal→ the syncer message crawl → the store. The payload is a container holding text displays, a separator, a section with a thumbnail accessory, a media gallery, and an action row with a link button. The test asserts thatraw_jsonkeepscomponentsand re-decodes to the same text, thatnormalized_contentholds the card text, and that FTS search finds the message by160926,redact gateway tokensandneeds proof. It fails onorigin/main(normalized_contentis"") and passes with this fix.TestParseMessageIndexesComponentsV2Textcovers the Desktop importer and its flag gate.discrawl sync --source discordran in-process with everydiscord.comrequest routed to a local fake API; any other host was refused and nothing contacted Discord.searchandsqlthen ran from the built binary:discrawl publish --no-committo a local repo: before the fix, the messages shard row hadnormalized_content: ''and noraw_json.components. After the fix it has the card text andcomponentspresent.discrawl wiretapagainst a fixture Desktop cache file:search 160926returned nothing before and the card after.160926for the search placeholder and a fake-Discordsync.900000000000000002|1|1554528840938819725, the cursor was rewound to…724, and the sync ranGET …/messages?after=1554528840938819724and wrotemessages=1. The broken count then read0andsearch 160926found the card. The shard pushed to a local bare remote carries the card text andcomponents. The documented rollback restored the backup and the broken count went back to1.1, thenwiretapwrotemessages=1, then candidate count0.search 160926found the card and the pushed shard carries its text.wiretapwrotemessages=0and the candidate count stayed at1. Steps 2–5 were then repeated with the documentedBROKEN="$DESKTOP and guild_id <> '@me'", which listed the channel, rewound the cursor, and ran a sync that wrotemessages=1(after=…724). The count then read0,search 160926found the card, and it was published.make tidy-check fmt lint test-coverage smokeall pass locally.snapshot(goreleaser) was not run locally.