Skip to content

fix: prevent malformed text messages from crashing conversation render (#465)#467

Open
ken-anderson2006 wants to merge 3 commits into
Mininglamp-OSS:mainfrom
ken-anderson2006:fix/issue-465-group-message-crash
Open

fix: prevent malformed text messages from crashing conversation render (#465)#467
ken-anderson2006 wants to merge 3 commits into
Mininglamp-OSS:mainfrom
ken-anderson2006:fix/issue-465-group-message-crash

Conversation

@ken-anderson2006

Copy link
Copy Markdown
Contributor

Closes #465.

Problem

In one specific group, the conversation history vanished and messages could not be sent, while other groups worked fine. The console showed:

TypeError: Cannot read properties of undefined (reading 'length') in the filter → refreshMessages → appendMessage / updateMessageStatusBySendAck chain.

Root cause

The affected group's history contains a malformed text message: the payload is typed as text (type === 1) but the content body is missing, so the decoded message.content.text is undefined. During render, ConversationVM.refreshMessages runs the sensitive-word filter on every text message and called ProhibitwordsService.filter(undefined). ProhibitwordsService.filter forwarded the value straight to the third-party sensitive-word-tool, which reads content.length and threw. This aborted refreshMessages before it could assign the rendered list, blanking the whole conversation. Send acks and live-message appends both re-run the same refresh, so every attempt hit the same bad history message and failed.

Fix

  • ProhibitwordsService.filter now accepts unknown, normalizes any non-string (including undefined/null/empty string) to "", and only invokes the library for real strings. This also hardens the other call sites.
  • The text-filter branch in ConversationVM.refreshMessages skips entries whose content object is missing.
  • ConversationVM.updateReplyMessageContent uses optional chaining so retained malformed messages no longer crash when a replied-to message is edited (same root cause, different trigger).

Tests

  • New ProhibitwordsService unit tests run against the real sensitive-word-tool and reproduce the original crash before the fix (filter(undefined/null/non-string/"")"", normal strings pass through).
  • New messageOrder regression tests cover refreshMessages, appendMessage, updateMessageStatusBySendAck and the edited-reply path with a malformed text message present, asserting no throw and correct normalization.

ken-anderson2006 added 2 commits June 27, 2026 17:04
…er (Mininglamp-OSS#465)

A text message with a missing content body (payload type is text but
content.text is undefined) caused ProhibitwordsService.filter to receive
undefined, which the sensitive-word-tool library crashed on when reading
content.length. This aborted refreshMessages, blanking the whole
conversation and blocking sends for the affected group.

- ProhibitwordsService.filter now normalizes non-string input to an empty
  string and only invokes the library for real strings.
- The text-filter branch in ConversationVM.refreshMessages skips entries
  whose content object is missing.

Adds regression tests covering refreshMessages, appendMessage and
updateMessageStatusBySendAck with malformed text messages, plus unit
tests for the filter null-guard against the real library.
Mininglamp-OSS#465)

The crash fix intentionally keeps malformed text messages (content
missing) in the rendered list. When a replied-to message is later edited,
updateReplyMessageContent walked those entries and dereferenced
message.content.reply, throwing on the undefined content object — the same
root cause as the filter crash, via a different trigger.

Use optional chaining so entries without a content object are skipped, and
add a regression test covering the edited-reply path with a malformed
message present.
@github-actions github-actions Bot added the size/M PR size: M label Jun 27, 2026
OctoBoooot
OctoBoooot previously approved these changes Jun 27, 2026

@OctoBoooot OctoBoooot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review: fix: prevent malformed text messages from crashing conversation render (#465) (#467)

Verdict: Approve

Excellent defensive crash fix for a real production incident (one group's history fully blanked + unsendable). The root-cause analysis is precise and the fix is layered at exactly the right depth.

Root cause is correctly identified and the fix matches it. A type===1 (text) message with a missing content body decodes to content.text === undefined; refreshMessages ran the sensitive-word filter on every text message, ProhibitwordsService.filter(undefined) forwarded straight to the third-party sensitive-word-tool, which read content.length and threw — aborting refreshMessages before it assigned the render list, blanking the conversation. Send-acks and live appends re-run the same refresh, so every retry re-hit the bad history message. That's a complete, mechanism-level diagnosis, and the diff addresses each link in the chain.

Defense in depth, verified across all sites:

  • ProhibitwordsService.filter(v: unknown): string (:60-65) — the load-bearing fix. Normalizes any non-string / empty to "" and only calls the library for real strings. Widening the signature to unknown is the right call: it hardens every caller, not just the one that crashed.
  • vm.ts:1878-1882 (refreshMessages text branch) — guards if (content) before touching content.text, so a fully-missing content object is skipped rather than dereferenced.
  • vm.ts:1322 (updateReplyMessageContent) — message.content?.reply optional chaining for the retained-malformed-message edit path (same root cause, different trigger). Verified the inner content.reply.messageID/.content reads at :1325-1326 are safe because the ?.reply === undefined continue-guard above filters them out.
  • Cross-checked the other two filter() call sites (Pages/Chat/vm.ts:228/281/611): all already guard lastMessage?.content && before calling, AND are now additionally covered by the hardened filter — belt and suspenders.

Tests reproduce the actual crash. ProhibitwordsService.test.ts runs against the real sensitive-word-tool (not a mock), so the filter(undefined/null/non-string/"")"" cases genuinely reproduce the original throw before the fix. messageOrder.test.ts covers all four trigger paths (refreshMessages, appendMessage, updateMessageStatusBySendAck, edited-reply) with a malformed message present, each asserting no-throw + correct normalization. Both the missing-content and present-content-missing-text shapes are tested.

Praise

  • Choosing to fix ProhibitwordsService.filter at the type boundary (stringunknown, normalize internally) rather than only guarding the one crashing call site — that's the difference between patching the symptom and hardening the contract. Every present and future caller of filter is now safe against the same class of dirty input, and the comment names exactly why (third-party content.length read).
  • The ProhibitwordsService test runs the real sensitive-word-tool rather than mocking it. A mocked test would have passed even with the bug present (the mock wouldn't read .length); using the real library means the test actually reproduces #465's throw and would catch a regression that swaps the guard back. This is the right instinct for a "library threw on our dirty input" fix — test against the thing that threw.
  • messageOrder.test.ts distinguishes the two malformed shapes (content entirely missing vs content present with text undefined) — these hit different guards (if (content) vs the filter-normalization), and testing both ensures neither guard is silently load-bearing-alone.

Out of scope (informational)

  • PR is a draft (per gate) — flagging so merge waits for ready-for-review. Also vm.ts itself remains without a co-located unit test by naming convention, but the new messageOrder.test.ts / ProhibitwordsService.test.ts exercise the changed paths directly, so the coverage that matters for this fix is present.

@Jerry-Xin Jerry-Xin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The PR is relevant to octo-web, but it does not fully prevent malformed text messages with missing content from crashing conversation render.

🔴 Blocking

  • 🔴 Critical: refreshMessages still leaves content === undefined text messages in the render list. The new guard at packages/dmworkbase/src/Components/Conversation/vm.ts:1877 only skips filtering, then messageUI later evaluates message.flame at packages/dmworkbase/src/Components/Conversation/index.tsx:1977, which dereferences this.message.content.contentObj at packages/dmworkbase/src/Service/Model.tsx:741. That will still throw for the exact malformed shape described in the PR. If it gets past that path, text rendering also calls message.parts at packages/dmworkbase/src/bridge/message/useTextMessageUI.ts:66, and parseMention dereferences this.content.contentType at packages/dmworkbase/src/Service/Model.tsx:401. The regression test only calls refreshMessages, so it misses the actual React render crash. Please normalize missing text content to a safe text content object, drop/replace malformed messages before render, or make the shared MessageWrap accessors render-safe, then add a regression that exercises the render/message cell path.

✅ Highlights

  • ProhibitwordsService.filter is correctly hardened against undefined, null, non-string, and empty-string inputs.
  • The edited-reply optional chaining fix is appropriate for retained malformed messages.

Verification

  • I attempted the targeted Vitest run, but this checkout has no installed dependencies: vitest: command not found and pnpm reported node_modules missing.

mochashanyao
mochashanyao previously approved these changes Jun 27, 2026

@mochashanyao mochashanyao left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Octo-Q · automated review]

Verdict: Approve — no blocking findings; notes below (data-flow traced).


octo-web PR#467 Review Report

PR: fix: prevent malformed text messages from crashing conversation render (#465)
Head SHA: 417f3af7447103b70b7a1c126a26ea5e724e79a6
Reviewer: Octo-Q (automated review)


1. Verification Summary

Item Status Evidence
refreshMessages guards against content=undefined for text messages vm.ts:1879-1881const content = message.content; if (content)
updateReplyMessageContent uses optional chaining on message.content vm.ts:1322message.content?.reply === undefined
ProhibitwordsService.filter handles non-string/non-empty input ProhibitwordsService.ts:60-63typeof v !== "string" || v.length === 0 → ""
Tests cover the fix paths 4 new test cases in messageOrder.test.ts; 5 new tests in ProhibitwordsService.test.ts
No regressions to existing call sites of ProhibitwordsService.filter All 3 call sites in Chat/vm.ts (L228/281/611) already guard with ?.content before calling

2. Findings

F1 — message.content.conversationDigest in reply handler lacks null guard (P2 / pre-existing)

Location: index.tsx:1110 and index.tsx:3556

// index.tsx:1109-1110 (reply() handler)
const content = message.remoteExtra?.isEdit
  ? message.remoteExtra?.contentEdit?.conversationDigest
  : message.content.conversationDigest;  // ← crashes if content is undefined

// index.tsx:3555-3556 (ReplyView render)
const messageText = message.remoteExtra?.isEdit
  ? message.remoteExtra?.contentEdit?.conversationDigest
  : message.content.conversationDigest;  // ← same issue

Diff-scope check (R2):

  1. Pre-existing — not introduced or touched by this PR.
  2. The PR did NOT change the reply handler path or the ReplyView render.
  3. On base branch, behavior is identical — message.content.conversationDigest crashes identically when content is undefined.

Severity (R1): Not P1. The PR does not amplify this path. The crash was already latent before #465. Note: index.tsx:1593 already uses message.content?.conversationDigest (optional chaining), suggesting inconsistency across the codebase.

Suggestion: Apply the same optional-chaining fix at lines 1110 and 3556 for consistency:

message.content?.conversationDigest

F2 — Missing trailing newline in ProhibitwordsService.ts (nit)

Location: ProhibitwordsService.ts end-of-file — diff shows \ No newline at end of file.

POSIX convention requires trailing newline. Minor cosmetic issue.

3. Data-Flow Trace

refreshMessages (vm.ts:1874-1885)

  • Input: newMessages array (from SDK sync / WS push / local buffer)
  • Gate: message.contentType === MessageContentType.text → enters text-processing branch
  • Data consumed: message.content.text
  • Upstream: Message.content is set by SDK during deserialization. For malformed text messages (#465), the SDK can produce content = undefined while contentType = 1 (text).
  • Fix verification: The PR extracts const content = message.content and guards with if (content). When content is undefined, the loop body is skipped entirely — no crash. ✅
  • Downstream effect: content.text = ProhibitwordsService.shared.filter(content.text) — ProhibitwordsService now returns "" for undefined input (defensive), but the caller guard means filter(undefined) is never reached. Belt-and-suspenders. ✅

updateReplyMessageContent (vm.ts:1316-1328)

  • Input: MessageExtra array (from updateMessageByMessageExtras)
  • Data consumed: message.content.reply.messageID, message.content.reply.content
  • Upstream: message.content comes from MessageWrap.content getter → this.message.content (SDK Message object). For malformed messages, this can be undefined.
  • Fix verification: message.content?.reply === undefined → if content is undefined, content?.reply evaluates to undefined, which IS === undefined, so the loop continues. Lines 1325-1326 are only reached when both content and content.reply exist. ✅

ProhibitwordsService.filter (ProhibitwordsService.ts:60-66)

  • Input: v: unknown — previously typed as string
  • Data consumed: v.length (via sensitiveWordTool.filter(v))
  • Upstream: Called with content.text from 4 call sites (3 in Chat/vm.ts, 1 in Conversation/vm.ts). All callers now guard with ?.content before calling.
  • Fix verification: Early return "" for non-string/empty input. sensitiveWordTool.filter() is only called with valid non-empty strings. ✅

4. Blind Spot Checklist (R5)

  • C1 Double-path parity: N/A — no symmetric add/remove or create/delete paths affected.
  • C2 Control-flow ordering / nested reuse: N/A — no security controls (regex/escape/sanitize) or deeply-nested reused logic affected.
  • C3 Authorization boundary: N/A — no permission/jail/tool exposure changes.
  • C4 Auth lifecycle / container-member state cascade: N/A — no auth changes.
  • C5 Build/note ≠ runtime: N/A — no build artifacts, extensions, or packaging changes.
  • C6 Governance/policy docs: N/A — no doc changes.

5. Cross-round Blocker Re-check (R6)

N/A — first review.

6. Additional Observations

  • Test quality: The 4 new tests in messageOrder.test.ts correctly exercise the fix paths with realistic malformed message shapes (missing content, undefined text, mixed with well-formed messages). The 5 tests in ProhibitwordsService.test.ts test the real sensitive-word-tool integration (not mocked), confirming the library would crash on undefined input.
  • Mock update: The mock in messageOrder.test.ts:131 was updated to match the new filter(v: unknown): string signature — good practice to keep mocks consistent with implementation.

[Octo-Q] verdict: APPROVE — No P0/P1 findings. The fix is focused, correct, and well-tested. One P2 (pre-existing reply handler crash risk) and one nit noted for follow-up.

@OctoBoooot OctoBoooot dismissed their stale review June 27, 2026 10:01

Retracting this APPROVE — it missed the render-layer crash (Model.tsx:741 flame getter, unguarded content.contentObj) where the retained malformed message still throws. Re-reviewing as REQUEST_CHANGES with the verified blocker. My 'verified across all sites' praise was overreach; corrected.

@OctoBoooot OctoBoooot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review: fix: prevent malformed text messages from crashing conversation render (#465) (#467) — corrected

Verdict: Request changes
Correction: My earlier APPROVE on this SHA was wrong, and I'm retracting it. I praised the fix as "complete and defense-in-depth, verified across all sites" — but I only traced the data-refresh writer paths (filter() callers, updateReplyMessageContent) and never traced the render-time getters that read the retained malformed message. Jerry-Xin and mochashanyao independently caught the render-layer gap; I re-verified it at head and they're right. Wrong praise burns trust the same way a wrong blocker does, so this correction matters.

The crash the PR title claims to fix is still live at the render layer. Verified at head:

  • The fix retains the content-missing message: vm.ts:1878 skips the filter with if (content) but the message stays in newMessagesthis.messages → render.
  • Conversation/index.tsx:1977 messageUI() reads message.flame for every rendered message.
  • Model.tsx:741 flame getter does if (this.message.content.contentObj)no optional chaining. For a content === undefined message (the exact shape the PR's own if (content) guard and missing-content test construct), this throws TypeError: Cannot read properties of undefined (reading 'contentObj') — reproducing the original "crashing conversation render."

So the guards landed one layer above where the crash actually fires. The filter/reply paths are now safe, but the retained malformed message still reaches the render-time getters unprotected.

Blockers

  • Render-layer crash unfixed. The malformed (content === undefined) message is retained in this.messages and crashes at Model.tsx:741 (flame getter, unguarded this.message.content.contentObj) via Conversation/index.tsx:1977's per-message message.flame read. Two fix shapes: (a) normalize/drop content-missing messages in refreshMessages before genMessageLinkedData so no downstream getter sees content === undefined, or (b) add optional chaining to the render-time getters (flame, and audit siblings like parts). (a) is the more robust single-point fix — it stops every render-time getter from having to individually defend.
  • Regression tests pin the wrong layer. The new messageOrder.test.ts cases assert refreshMessages/appendMessage/updateMessageStatusBySendAck/reply-edit don't throw, but none mount the cell or read flame — so they pass green while the UI still crashes. A regression test must exercise the real render path (read message.flame / render the cell with a malformed message present) to actually pin #465.

Praise

  • ProhibitwordsService.filter(v: unknown): string hardening is genuinely good and stands — normalizing null/non-string/empty to "" at the type boundary protects every caller (verified the Pages/Chat/vm.ts sites benefit too). This part of the fix is correct; it's just not where the render crash lives.

Out of scope (informational)

  • Draft PR + trailing-newline nit on ProhibitwordsService.ts (mochashanyao's 🟡) — minor, separate from the blocker.

yujiawei
yujiawei previously approved these changes Jun 27, 2026

@yujiawei yujiawei left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review — PR #467 (octo-web)

Reviewed at head SHA 417f3af7447103b70b7a1c126a26ea5e724e79a6 against main (merge-base 6055b72). Independent multi-pass review of the changed files plus the surrounding render/decode paths.

Verdict: Approved

This PR correctly and completely fixes the reported crash. The findings below are non-blocking robustness and test-quality suggestions.


1. Spec compliance — PASS

The diff implements exactly what #465 requires, with no over-build and no deviation:

  • The real throw site was ProhibitwordsService.filter() forwarding a non-string to sensitive-word-tool (which reads content.length). The fix hardens filter(v: unknown): string to normalize any non-string/empty value to "" and only call the library for real strings (Service/ProhibitwordsService.ts:60-66). This is the right place to fix it and also protects the other call sites.
  • The two VM read paths that could dereference missing content are guarded: refreshMessages skips the filter when content is falsy (Components/Conversation/vm.ts:1877-1883), and updateReplyMessageContent uses optional chaining (vm.ts:1318).

I confirmed the actual production scenario is fully resolved. The decoded payload for the affected history message has content present (a MessageText object) but content.text === undefined. With the fix, that message renders safely all the way through: Model.tsx parseMention uses textContent.text || '' (line 408) and reads contentType/mention off a real object, and the flame getter (line 741) is already if-guarded. The only failure for this shape was the filter call, which is now fixed.

2. Code quality — Approved (with suggestions)

Suggestion A — render-path guards are asymmetric with the new VM guards (non-blocking)

The fix (and the new missingContent test) explicitly defend the case where content is entirely undefined. But the render accessors do not guard that same shape:

  • Service/Model.tsx:401parseMention() reads this.content.contentType with no null check; reached during render via the parts getter (Model.tsx:364, consumed in Messages/Text/index.tsx:96,121).
  • Service/Model.tsx:741flame getter reads this.message.content.contentObj unguarded; reached via Components/Conversation/index.tsx.
  • Messages/Text/index.tsx:221message?.content.reply optional-chains message but not content.

In practice this is not currently reachable, because the decode path always assigns a content object: Service/Convert.ts:296-300 sets message.content = WKSDK.shared().getMessageContent(contentType), and the SDK factory never returns undefined. So a fully-undefined content only arises from synthetic test construction, and these render accessors are safe today.

The point is internal consistency: the PR hardens the VM layer and adds a regression test for content === undefined, yet leaves the render layer crashing on that exact shape. If that state ever does occur (a future SDK change, or a path that bypasses Convert.ts), the crash resurfaces in render where it is harder to diagnose, and — because the conversation is wrapped in an ErrorBoundary (Pages/Chat/index.tsx:1228) — it would surface as a degraded error panel rather than the original recoverable behavior. Recommend either making the render accessors null-safe too, or dropping the synthetic content === undefined coverage to match the actual reachable failure (content.text === undefined). Either way the layers should agree on what shapes they defend.

Suggestion B — regression test mocks the unit under test (non-blocking)

In Components/Conversation/__tests__/messageOrder.test.ts, ProhibitwordsService is mocked with a re-implementation of the new normalization logic, and MessageWrap is mocked entirely. The "does not throw" assertions therefore validate VM control flow but never exercise the real MessageWrap render path (parts/parseMention/flame) where a malformed message would actually be rendered. The meaningful regression guard is the new Service/__tests__/ProhibitwordsService.test.ts, which runs the real sensitive-word-tool and reproduces the original throw — that one is well-targeted. Consider adding one assertion that drives a real MessageWrap through render (or at least asserts the message shape is normalized before entering this.messages) so the regression covers the path that actually broke.

Minor

  • Service/ProhibitwordsService.ts ends without a trailing newline (\ No newline at end of file).

3. Items checked and found clean

  • All three sibling ProhibitwordsService.shared.filter call sites in Pages/Chat/vm.ts (227-228, 280-281, 610-611) already guard with lastMessage?.content && before touching .text, so the conversation-list path does not reproduce the crash. The signature change string → unknown is type-compatible (return stays string, assigned to .text).
  • fullStreamContent (Model.tsx:729) already uses optional chaining.
  • Build/test verification could not be completed in this environment (no node_modules; tsc/vitest unavailable), so the type-check and test-run are unverified here — recommend confirming CI is green before merge.

…ersation crash (Mininglamp-OSS#465)

A text message whose content failed to decode (message.content === undefined)
slipped past the write/filter-layer guards and crashed the React render: the
SDK Message.contentType getter, plus MessageWrap.flame and parts, dereference
the missing content while the list is sorted, deduplicated and rendered.

Normalize such messages to an empty MessageText at a single point at the top of
ConversationVM.refreshMessages, before any contentType read, so a malformed
message renders as an empty bubble instead of taking down the whole list.

Tests:
- messageOrder.test.ts: make the MessageWrap mock contentType faithful to the
  SDK (reads through content) so the missing-content case can no longer false-green.
- malformedMessageRender.test.tsx: real wukongimjssdk + real MessageWrap coverage
  of the crashing getters, the refreshMessages normalization, and the render path.
@ken-anderson2006 ken-anderson2006 dismissed stale reviews from yujiawei and mochashanyao via a0cbccb June 27, 2026 11:13
@github-actions github-actions Bot added size/L PR size: L and removed size/M PR size: M labels Jun 27, 2026

@OctoBoooot OctoBoooot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review: fix: prevent malformed text messages from crashing conversation render (#465) — delta review (#467)

Verdict: Approve

Delta from 417f3afa0cbccb. The author pivoted from the wrong-layer patch to the correct fix shape, and it fully resolves the render-layer blocker I (corrected), Steve, OctoBoooot, and Jerry-Xin all converged on.

Prior blocker — resolved. Instead of scattering guards across the readers, refreshMessages now normalizes at a single point (vm.ts:1875): any message with content == null gets a new MessageText("") assigned before the sort/dedup/filter/render pipeline runs. Verified the ordering: the normalization loop is the first statement in refreshMessages, ahead of sortMessages / filterPersonMessagesBySpace / deduplicateSystemTips / insertTimeOrHistorySplit (all of which read contentType) and ahead of render. After it, no message in the list has missing content, so the three crash getters can't fire:

  • Message.contentType (this.content.contentType)
  • MessageWrap.flame (this.message.content.contentObj)
  • MessageWrap.partsparseMention (this.content.contentType)

This is the robust single-point fix that was the better of the two options I suggested — it stops every downstream getter from individually needing to defend, rather than patching readers one at a time.

Tests now pin the right layer. malformedMessageRender.test.tsx (new, 186 lines) uses the real wukongimjssdk + real MessageWrap, and:

  • First proves the raw malformed wrap throws on contentType / flame / parts — i.e. it reproduces the actual crash, so the test would catch a regression that removes the normalization.
  • Then asserts refreshMessages normalizes it (content instanceof MessageText, flame === false, parts → empty text) and that getTextMessageUI + an actual TextCell ReactDOM render don't throw.

That closes the exact gap from the prior round: the old tests false-greened because the mock contentType didn't read through content; this round also fixes the mock to be faithful to the SDK (messageOrder.test.ts), so the missing-content case can no longer pass spuriously.

The earlier-round guards (if (content) in the filter loop, content?.reply in updateReplyMessageContent, the ProhibitwordsService.filter(unknown) hardening) are all still present — now belt-and-suspenders on top of the normalization, which is fine.

Praise

  • Choosing normalization-at-the-boundary over per-getter guards is the architecturally correct call for the refreshMessages ingestion path. A malformed message entering the rendered list is the invariant violation; fixing it once before sort/dedup/render means flame, parts, contentType are all safe for every message that reaches this.messages. The commit message names all three crashing getters explicitly, showing the fix was derived from tracing the actual crash surface, not guessing.
  • malformedMessageRender.test.tsx runs the real SDK and actually renders the cell — and it asserts the pre-fix throw on the three getters first. A test that only checked "after fix, no throw" could pass against a no-op; proving the getters throw on the raw input means the test genuinely pins the crash. This is exactly the render-path coverage the prior round was missing.
  • Fixing the messageOrder.test.ts mock to read contentType through content (faithful to the SDK) — the prior round's false-green came from an unfaithful mock. Correcting the mock so the missing-content case can't spuriously pass is the kind of test-infrastructure fix that prevents the next content-shape bug from slipping through too.

Out of scope (informational)

  • Residual crash path on live WS push (pre-existing, not introduced here; credit @mochashanyao). The single-point normalization only protects messages flowing through refreshMessages. The live WS message listener reads message.contentType at vm.ts:842 (the rtcData early-return) before appendMessagerefreshMessages runs — and the SDK contentType getter dereferences this.content.contentType (this PR's own test asserts it throws on a content-missing message). So a malformed message arriving via live push can still crash at the listener entry, ahead of normalization. This is narrower than the filed #465 incident (which was history-load) and the line is untouched by this PR, so it doesn't block — but my earlier "the whole class is fixed" framing was too broad: this PR fixes the ingestion/render path, not the live-listener entry. Worth a follow-up to normalize (or guard the contentType read) at the top of the WS listener too. Correcting my own overclaim here.
  • PR is still a draft — merge waits for ready-for-review + the check-sprint linked-issue gate (body has Closes #465, so that should clear once CI re-runs green on a non-draft).

@Jerry-Xin Jerry-Xin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Summary: The PR is in scope for octo-web and the fix addresses the malformed-message crash at the right render-path choke point.

💬 Non-blocking

  • 🔵 Suggestion: packages/dmworkbase/src/Components/Conversation/vm.ts:1322 now handles missing content, but reply: null would still fall through and throw at message.content.reply.messageID. Consider if (message.content?.reply == null) continue for the same defensive shape.
  • 🔵 Suggestion: packages/dmworkbase/src/Components/Conversation/__tests__/messageOrder.test.ts:177 says the mock is faithful to the SDK dereference behavior, but it falls back to message.contentType when content is missing. The new real-SDK test covers the actual crash path, so this is not blocking, but the comment could be tightened.

✅ Highlights

  • ProhibitwordsService.filter now hardens all callers against non-string inputs.
  • refreshMessages normalizes malformed content (new MessageText("")) before sort/dedupe/render reads can touch SDK getters.
  • Regression coverage includes service-level filtering, VM refresh/append/ack paths, and a real SDK render-path test (malformedMessageRender.test.tsx) that exercises the actual crashing getters (contentType/flame/parts) and a ReactDOM.render of the text cell.

Note: could not run the targeted Vitest suite because this checkout has no node_modules.

@ploy-elison ploy-elison left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

APPROVED — the render-layer root cause is genuinely closed this round, and the tests are honest regression guards rather than green-by-mock.

Verification (head a0cbccb, traced on the branch, not the commit message)

1. Single-point normalization actually fronts the whole render path.
refreshMessages (vm.ts) now normalizes m.message.content == null → new MessageText("") as its very first statement, before sortMessages, distinctMessages, filterPersonMessagesBySpace, deduplicateSystemTips, the text-filter loop, genMessageLinkedData, and buildRenderItems. this.messages is assigned only inside refreshMessages (the one non-empty assignment), and every other contentType / flame / parts read in the VM operates on this.messages or on messagesOfOrigin entries that have already been through refreshMessages and were normalized in place. So once a malformed message has passed through here once, the SDK getters (Message.contentType → this.content.contentType, MessageWrap.flame → content.contentObj, MessageWrap.parts → parseMention → content.contentType) are all safe. The earlier choke points that abort the rebuild (deduplicateSystemTips reading m.contentType) are downstream of the normalization, so they no longer throw.

2. MessageText("") is a safe normalization target.
It mutates only message.content. messageSeq / order / clientSeq / clientMsgNo / messageID / status / remoteExtra are untouched, so sort ordering, dedup keys, send-ack matching and extras are unaffected. For the empty text: contentType === text, flame === false (no contentObj), parts === []. A malformed bubble degrades to an empty bubble instead of taking down the list — correct trade-off given the original content is undecodable anyway.

3. Tests are real, not false-green — confirmed by running them.

  • malformedMessageRender.test.tsx uses the real wukongimjssdk + real MessageWrap (only app/service singletons are stubbed). It asserts the raw wrap throws on contentType / flame / parts, then that refreshMessages normalizes it, then drives getTextMessageUI and an actual ReactDOM.render. I reverted the vm.ts fix to its pre-a0cbccba state and this suite goes red with exactly the documented crash — Message.contentTypethis.content.contentType undefined deref, fired from deduplicateSystemTips inside refreshMessages. With the fix in place all 24 tests across the three files pass.
  • messageOrder.test.ts: the contentType mock now reads through content and the ProhibitwordsService mock collapses non-strings to "", plus the new append / send-ack / edited-reply cases. These are logic-flow guards on a mocked SDK (they don't independently reproduce the getter crash — that's by design, since the real-SDK reproduction lives in malformedMessageRender).
  • ProhibitwordsService.test.ts exercises the real sensitive-word-tool and locks the null-guard (undefined / null / non-string / empty / passthrough).

4. First-round filter null-guard retained and tightenedfilter(v: unknown): string now short-circuits non-strings and empty strings before touching the library. Good.

updateReplyMessageContent's content?.reply optional chaining is correct: it sits behind the same normalization for the rendered list and additionally guards the standalone path.

Non-blocking follow-ups (not in this PR's render-path scope; file separately)

  1. Live push path still reads raw contentType before normalization. In the realtime message listener, if (message.contentType == MessageContentTypeConst.rtcData) dereferences the raw SDK Message.contentType before the message is wrapped/appended/normalized. A malformed message arriving over WS (rather than from history sync) would still throw there. Severity is lower than the original bug — it drops a single live message rather than blanking the loaded conversation — and it's pre-existing, but it's the same root cause via a different entry point and worth closing.
  2. Conversation-list lastMessage still derefs content on the LHS. In Pages/Chat/vm.ts (lines ~228 / ~281 / ~611) the assignments conversation.lastMessage.content.text = filter(...) are now safe on the filter argument, but if lastMessage.content itself is undefined the left-hand .content.text = still throws. Different surface from this PR's group-conversation render, same family.

Neither blocks merge. The PR delivers its stated scope.

@mochashanyao mochashanyao left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Octo-Q · automated review]

Verdict: Approve — no blocking findings; notes below (data-flow traced).


octo-web PR#467 Review Report (automated review)

Reviewer: Octo-Q (automated review)
PR: Mininglamp-OSS/octo-web #467
Head SHA: a0cbccbae30a7ff3470cb838e2d3d6e8ed20fcc5
Scope: fix: prevent malformed text messages from crashing conversation render (#465)
Files: 5 changed (+303/-6) — 2 production, 3 test


1. Verification Summary

Item Status Evidence
Root cause correctly identified PR description matches code: ProhibitwordsService.filter(undefined)sensitive-word-tool reads content.length → crash. Confirmed at vm.ts:1891 (pre-fix) and ProhibitwordsService.ts:60 (pre-fix).
Single-point normalization in refreshMessages vm.ts:1875-1880m.message.content == null → new MessageText(""). Runs before sortMessages, deduplicateSystemTips, insertTimeOrHistorySplit, prohibitwords loop, and rebuildRenderItems. All downstream contentType reads are safe.
ProhibitwordsService.filter hardening ProhibitwordsService.ts:60-65v: unknown, typeof guard, early return "" for non-string/empty. Matches crash signature (library reads content.length on non-string).
updateReplyMessageContent optional chain vm.ts:1321message.content?.reply === undefined guards against missing content. Correctly skips malformed entries.
Test coverage 3 test files: unit tests for filter (5 cases), messageOrder regression (4 cases with malformed messages), render-layer test with real SDK + real MessageWrap (4 cases).
appendMessage / updateMessageStatusBySendAck covered Both call refreshMessages internally (vm.ts:1449, vm.ts:1291), so normalization applies.

2. Findings

Finding 1 — P2 (pre-existing): WS push messageListener reads message.contentType before normalization

File: vm.ts:842
Code: if (message.contentType == MessageContentTypeConst.rtcData) { return }

The messageListener callback receives raw SDK Message objects from WS push. At line 842, message.contentType invokes the SDK getter which dereferences this.content.contentType. If a WS-delivered message has content === undefined (same root cause as #465), this throws inside the listener.

Diff-scope check: Pre-existing. The PR does not touch the messageListener callback. The fix in refreshMessages only covers the history-load / append / ack paths, not the raw WS entry point. Not amplified.

Severity: P2 — lower impact than the history path because (a) the reported crash came from history, not live push; (b) the listener crash would affect only the current message, not the entire rendered list. But it is a reachable crash on the same root cause.

Suggestion: Add a message.content == null guard before line 842, or wrap the listener body in try/catch to isolate per-message errors.

Finding 2 — P2 (pre-existing): Chat/vm.ts sidebar paths accidentally protected by && short-circuit

File: Pages/Chat/vm.ts:228, 281, 611
Code: if (conversation.lastMessage?.content && conversation.lastMessage?.contentType === MessageContentType.text)

The && short-circuit ensures contentType (which derefs content.contentType) is only read when content is truthy. Combined with the ProhibitwordsService.filter hardening (now handles non-string), this is safe today.

Diff-scope check: Pre-existing. The && guard was already present. The PR's filter hardening adds defense-in-depth for the inner .text access.

Severity: P2/nit — safe but fragile. The protection is accidental (the && was likely added for space filtering, not crash prevention). A future refactor that reorders the condition could expose the crash.

Suggestion: Consider adding an explicit comment or extracting a guard function to make the safety intent clear.

Finding 3 — Nit: Redundant if (content) guard in prohibitwords loop

File: vm.ts:1889-1892
Code:

const content = message.content
if (content) {
    content.text = ProhibitwordsService.shared.filter(content.text)
}

After the normalization at lines 1875-1880, message.content is guaranteed non-null for all messages in the array. The if (content) check is therefore always true. Harmless defense-in-depth, but creates a false impression that content can be null at this point.

Suggestion: No action needed. If cleaning up, can remove the if and inline.

3. Suggestions

  1. Follow-up PR for WS push path (Finding 1): Add if (message.content == null) return before line 842 in the messageListener, mirroring the refreshMessages normalization strategy. This closes the last remaining crash path on the same root cause.
  2. Consider normalizing in the MessageWrap constructor instead of (or in addition to) refreshMessages. This would protect all downstream readers automatically, including any future paths that bypass refreshMessages. Trade-off: mutates SDK state at wrap time.

4. Additional Findings

  • module.tsx:1001 reads message.content.contentObj for thread creation. After normalization, content is MessageText("")contentObj would be undefined, falling through to the ?? fallback. Safe.
  • The MessageWrap.flame getter (Model.tsx:740) and parts getter (Model.tsx:364) both dereference this.message.content.contentObj / this.content.contentType. Both are now protected by the upstream normalization in refreshMessages. The malformedMessageRender.test.tsx test at lines 112-115 correctly verifies these crash pre-normalization and are safe post-normalization.

5. Data Flow Trace

Consumed data Upstream source Does data reach consumption point?
message.contentType in sort/dedup/render SDK Message.contentType getter → this.content.contentType ✅ After normalization: content = MessageText("")contentType = 1 (text)
message.content.text in prohibitwords loop SDK MessageText.text ✅ After normalization: text = "". Filter returns "". Safe.
message.content.reply in updateReplyMessageContent SDK content's reply property ✅ After normalization: content = MessageText(""), no reply property → optional chain ?.reply === undefinedcontinue. Safe.
message.content in MessageWrap.flame getter this.message.content.contentObj ✅ After normalization: content = MessageText(""). contentObj is undefined → flame = false. No crash.
message.content in MessageWrap.partsparseMention this.content.contentType ✅ After normalization: contentType = 1 (text). text = ""parseMention returns single empty text part. No crash.
ProhibitwordsService.filter(v) Various callers pass content.text or lastMessage.content.text ✅ Filter now accepts unknown, returns "" for non-string. All callers safe.
conversation.lastMessage.content.text in Chat/vm.ts SDK Conversation.lastMessageMessage.content.text ✅ Protected by && short-circuit (content existence checked before contentType read). Filter hardening adds second layer.

6. Blind-spot Checklist (R5)

  • C1 — Dual-path parity (symmetric path self-check): N/A. This PR does not touch add/remove/subscribe/unsubscribe pairs. The fix is a unilateral guard on a read path.
  • C2 — Control-flow ordering / nesting reuse: N/A. The normalization runs once at the top of refreshMessages. No nested or reused control flow. The ProhibitwordsService.filter is called from 4 sites — verified all are safe (see data flow trace).
  • C3 — Authorization boundary ≠ capability boundary: N/A. No permission/auth/jail/tool exposure changes.
  • C4 — Authorization lifecycle / container-member state cascade: N/A. No auth changes.
  • C5 — Build/note ≠ runtime path: N/A. No build artifacts, extensions, CLI, or relative-URL changes.
  • C6 — Governance/policy/security doc consistency: N/A. No governance or policy changes.

7. Cross-round Blocker Recheck (R6)

N/A — first review of this PR.


[Octo-Q] verdict: APPROVE — No P0 or P1 introduced or amplified by this PR. The fix is well-designed (single-point normalization + defense-in-depth), correctly addresses the reported crash, and has thorough test coverage including real-SDK render-path tests. Two pre-existing P2 items noted (WS push path, Chat/vm.ts accidental guard) for follow-up.

@ken-anderson2006 ken-anderson2006 marked this pull request as ready for review June 27, 2026 11:31
@ken-anderson2006 ken-anderson2006 requested review from a team as code owners June 27, 2026 11:31

@yujiawei yujiawei left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review — PR #467 (octo-web)

Verdict: APPROVE — head SHA a0cbccba. The fix correctly resolves the #465 P0 crash, the tests genuinely reproduce the original failure, and I could not confirm any remaining crash path after tracing the data flow against the SDK source.

1. Spec compliance

The linked issue (#465) is a P0: one group's conversation went blank and could not send, with TypeError: Cannot read properties of undefined (reading 'length') in the filter → refreshMessages → appendMessage / updateMessageStatusBySendAck chain.

  • Missing requirements: none. The PR fixes the crash at its source, hardens the related call sites, and adds regression tests.
  • Out-of-scope additions: none. No new flags, fields, or public APIs. The whole-content-missing normalization in refreshMessages goes slightly beyond the literal stack trace (which is a missing content.text, not a missing content), but it is defensive hardening for the same bug class, not scope creep.
  • Deviations: none.

Root-cause analysis in the PR description matches the code: ProhibitwordsService.filter forwarded a non-string straight to sensitive-word-tool, which reads content.length and throws. Confirmed against the SDK: Message.contentType is get() { return this.content.contentType }, so any content-less message also throws on the contentType read.

2. Code quality

The load-bearing fix is correct. ProhibitwordsService.filter(v: unknown) now normalizes any non-string (including undefined/null/"") to "" before touching the third-party library (ProhibitwordsService.ts:60-66). This is the actual production crash path from the #465 stack trace, and it is fixed cleanly.

Single-point normalization is the right shape. The render list this.messages is assigned in exactly one place — refreshMessages at vm.ts:1895, after the content == null → new MessageText("") normalization at vm.ts:1873-1875. Every entry point that mutates the rendered set (appendMessage, updateMessageStatusBySendAck) routes through refreshMessages, so no un-normalized message can reach buildRenderItems / the React layer.

I checked the realtime/WebSocket path specifically and it is not a regression. The live listeners (vm.ts:842, module.tsx:530) read message.contentType on the raw SDK message before it is wrapped. That read is safe because the SDK's decodePayload always returns a defined MessageContent (it falls back to UnknownContent() and always assigns message.content), so a wire-delivered message never has content === undefined. The content-less shape only arises from artificially constructed messages; appendMessage still funnels those through refreshMessages before render. No crash path remains there.

Tests are real, not theater. I reverted the vm.ts changes locally and re-ran the suite: 6 of the new cases fail with the exact original TypeError, and pass once the fix is restored (24/24 green with the fix). The new malformedMessageRender.test.tsx exercises the real SDK getters (contentType, flame, parts) that crash the list, which is good coverage.

Non-blocking suggestions (P2)

  • messageOrder.test.ts covers the case where origin already holds a malformed message, but not a direct vm.appendMessage(malformed) arrival. Adding that one case would document the realtime path explicitly. Not required — it is covered transitively today.
  • The in-place mutation m.message.content = new MessageText("") (vm.ts:1875) edits the shared SDK Message. This is safe here (it only replaces a missing content with an empty text, which is strictly safer for any other reader), but a null-safe getter on MessageWrap would be a slightly cleaner long-term shape if more of these accumulate.

3. Overall

Spec ✅ and quality approved → APPROVE. Solid, well-tested P0 fix.

@lml2468 lml2468 added review:running:qa qa-engineer review in progress review:running:security security-engineer review in progress review:running:code code-reviewer review in progress labels Jun 27, 2026
@lml2468

lml2468 commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

QA Engineer Verdict: APPROVE

Test coverage: Excellent for a P0 bug fix.

  • ProhibitwordsService.test.ts: 5 tests covering undefined/null/non-string/empty/normal inputs against real sensitive-word-tool
  • malformedMessageRender.test.tsx: 4 tests using real SDK (not mocked) verifying render-layer getters no longer crash
  • messageOrder.test.ts: 4 regression tests covering refreshMessages, appendMessage, updateMessageStatusBySendAck, and updateReplyMessageContent with malformed messages present

Boundary coverage: Both crash variants tested — content entirely missing AND content present but text undefined.

CI: All checks green (Build, code-review, dependency-review, OSV scan, secret-scan).

Minor note: refreshMessages normalizes in-place (mutates input array). Not a bug but worth being aware of for future callers. Not blocking.

@lml2468

lml2468 commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Security Engineer Verdict: APPROVE (CLEARED)

Threat assessment: This PR fixes a client-side Denial-of-Service vector — a malformed text message could crash the entire conversation render, making the group unusable. The fix properly addresses the root cause at multiple defense layers.

STRIDE analysis:

  • Denial of Service: FIXED — the crash path (ProhibitwordsService.filter(undefined)sensitive-word-tool reads .length) is fully neutralized
  • No changes to authN/authZ, crypto, data storage, or network communication

SBOM: No new dependencies added. Type signature widening (stringunknown) is defensive, not permissive — the function now rejects non-strings earlier.

CI security checks: OSV scan pass, secret scan pass, dependency review pass.

No security concerns.

@lml2468

lml2468 commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Code Reviewer Verdict: APPROVE

Correctness: Fix addresses the root cause at the right abstraction level. Single-point normalization in refreshMessages (line 1868-1874) ensures all malformed messages are normalized before any contentType access. The if (content) guard in the filter loop is redundant post-normalization but good defense-in-depth.

Readability: Comments are clear, reference #465, and explain the "why" not the "what". Chinese comments consistent with codebase convention.

Design fit:

  • ProhibitwordsService.filter(v: unknown): string — correct API evolution. Forces the service to be a safe boundary rather than trusting callers.
  • Optional chaining in updateReplyMessageContent (message.content?.reply) — minimal, correct.
  • new MessageText("") for normalization — uses the SDK's own type, not a raw object literal.

Maintainability: The normalization loop is at the top of refreshMessages, making it the first thing future readers see. Well-placed.

No issues found. Clean P0 bug fix.

@lml2468 lml2468 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Multica Loop Review — All 3 personas APPROVE

  • qa-engineer: APPROVE — thorough test coverage (13 test cases across 3 files), CI all green
  • security-engineer: APPROVE (CLEARED) — fixes client-side DoS vector, no new security concerns
  • code-reviewer: APPROVE — well-targeted minimal fix, clean design

Ready for human merge.

@lml2468 lml2468 removed the review:running:qa qa-engineer review in progress label Jun 27, 2026
@lml2468 lml2468 added review:done:qa:approve qa-engineer PASS review:done:security:approve security-engineer CLEARED review:done:code:approve code-reviewer APPROVED review:complete 3 verdicts aggregated, awaiting human merge and removed review:running:security security-engineer review in progress review:running:code code-reviewer review in progress labels Jun 27, 2026
@lml2468

lml2468 commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Aggregate Verdict: APPROVED — 等人工合并

All 3 reviewer personas have completed review:

Role Verdict Summary
qa-engineer APPROVE Test coverage excellent — unit tests reproduce the original crash + regression tests for all 3 call sites
security-engineer APPROVE (CLEARED) Fixes client-side DoS vector; fix addresses root cause at multiple defense layers
code-reviewer APPROVE Fix at right abstraction level; normalization in refreshMessages is clean

下一步: 等待人工合并。PR 已就绪。

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

Labels

review:complete 3 verdicts aggregated, awaiting human merge review:done:code:approve code-reviewer APPROVED review:done:qa:approve qa-engineer PASS review:done:security:approve security-engineer CLEARED size/L PR size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

突然之间,群消息内容消失也不能发送消息(只有一个群这样别的群没问题),发送消息失败。

7 participants