diff --git a/web/src/lib/tinode/send.ts b/web/src/lib/tinode/send.ts index c9f6ca91..d06ea769 100644 --- a/web/src/lib/tinode/send.ts +++ b/web/src/lib/tinode/send.ts @@ -7,6 +7,64 @@ function toDraftyContent(content: DraftyContent | string | null | undefined): Dr return typeof content === 'string' ? Drafty.init(content) : content; } +/** + * Real, DB-confirmed root cause of a reported bug ("转发消息显示已经删除"): this backend's mobile + * apps send several message kinds as FLAT, non-Drafty JSON objects tagged only by `head['x-im-type']` + * (plain text as `{text:"..."}`, not `{txt:"..."}` -- same "flat object, not a real Drafty document" + * shape dispatch.ts's mobileAttachmentContentOf/contactCardContentOf already document for + * image/file/voice/video/contact_card) or by `head.webrtc` (a call result, `{callId,callType,...}`). + * The original `forwardMessage` fed `msg.content` straight into `Drafty.forwardedContent()` assuming + * it was always a plain string or a real `{txt,fmt,ent}` document -- for any of these flat shapes, + * `draftyToTree()` on an object with none of those fields degenerates to empty content, which the + * SDK's own outbound `simplify()` (every `{pub}` packet gets this pass -- see this file's other + * comments on it) then strips entirely before the packet reaches the server: confirmed live via a + * direct DB query on a real reported case (a real "seq测试" group, tenant 3) -- the persisted row had + * `head.forwarded` set correctly but `content`/`content_nonce` genuinely NULL, which the client + * (correctly, from its own perspective) then renders as "already recalled" (`isRecalled()` in + * dispatch.ts treats null content exactly that way; the original source message, one seq earlier in + * the chain, was itself an already-forwarded flat `{text:"11"}` mobile message, `head:{"x-im-type": + * "text",...}` -- confirmed via `DESCRIBE`+`SELECT` against the real `im_003_group_messages_202609`/ + * `im_003_messages` tables on the k3s MySQL cluster, not inferred). + * + * Returns the right `{content, headOverrides}` to forward with, or `null` for an ordinary + * string/real-Drafty message (the pre-existing `Drafty.forwardedContent(toDraftyContent(...))` path + * still handles that case, unchanged). Duplicates dispatch.ts's own tiny shape checks inline rather + * than importing them: dispatch.ts imports from lib/tinode/calls.ts, which itself imports this file's + * own newClientMid/publishWithReattach, so importing dispatch.ts here would create a 3-way cycle + * (send.ts -> dispatch.ts -> calls.ts -> send.ts) for what's otherwise a handful of stable, + * unlikely-to-drift structural checks. + */ +function flatForwardPayload(msg: StoredMessage): { content: DraftyContent | Record; headOverrides: Record } | null { + const c = msg.content as unknown; + if (!c || typeof c !== 'object' || Array.isArray(c)) return null; + const headType = msg.head?.['x-im-type']; + // A real Drafty document (this app's own composer never produces any of the flat shapes below) + // always has `ent`/`fmt` -- bail out to the normal Drafty path rather than misclassifying it. + if ('ent' in c || 'fmt' in c) return null; + if (typeof (c as { text?: unknown }).text === 'string') { + // Flat plain text (mobile-sent, or a group-announcement auto-post) -- rebuild real Drafty from + // the extracted string instead of preserving the flat object verbatim, so it reads and searches + // like any other text message on this shape's next hop. + return { content: Drafty.forwardedContent(Drafty.init((c as { text: string }).text)), headOverrides: {} }; + } + if (typeof headType === 'string' && (headType === 'contact_card' || headType === 'image' || headType === 'file' || headType === 'voice' || headType === 'video')) { + // Flat attachment/contact-card content -- Drafty has no equivalent entity to rebuild (these were + // never real Drafty entities to begin with), so preserve the original content AND its `x-im-type` + // tag verbatim; the receiving client's own dispatch (mobileAttachmentContentOf/ + // contactCardContentOf) already renders this shape correctly regardless of which topic it's in. + const headOverrides: Record = { 'x-im-type': headType }; + if (typeof msg.head?.mime === 'string') headOverrides.mime = msg.head.mime; + return { content: c as Record, headOverrides }; + } + if (typeof msg.head?.webrtc === 'string' && typeof (c as { callId?: unknown }).callId === 'string') { + // A call-result message -- forwarding one is a low-value edge case (nobody has asked for it), + // but preserving it verbatim is still strictly better than silently losing it the way every + // other flat shape here used to. + return { content: c as Record, headOverrides: { webrtc: msg.head.webrtc } }; + } + return null; +} + /** * docs/IM-前后端对接文档.md:486/952/1128 -- every `{pub}` must carry a client-generated * `x-im-client-mid` (scoped to "tenant + sender"), and a disconnect/failure retry MUST resend the @@ -132,8 +190,12 @@ export function sendReply(topic: Topic, text: string, quoted: StoredMessage): Pr * has no reader anywhere yet, matching the old app (no confirmed call site reads it back either). */ export function forwardMessage(sourceTopic: string, msg: StoredMessage, target: Topic): Promise { - const content = Drafty.forwardedContent(toDraftyContent(msg.content)); - const newMsg = target.createMessage(content, false); - newMsg.head = Object.assign(newMsg.head || {}, { forwarded: `${sourceTopic}:${msg.seq}`, 'x-im-client-mid': newClientMid() }); + const flat = flatForwardPayload(msg); + const content = flat ? flat.content : Drafty.forwardedContent(toDraftyContent(msg.content)); + // `createMessage`'s own type only admits Drafty content, but its real implementation (see + // lib/tinode/calls.ts's own identical cast) passes a plain object straight through as literal + // `content` -- true for the attachment/contact-card/call-result branches of `flatForwardPayload`. + const newMsg = target.createMessage(content as unknown as DraftyContent, false); + newMsg.head = Object.assign(newMsg.head || {}, flat?.headOverrides, { forwarded: `${sourceTopic}:${msg.seq}`, 'x-im-client-mid': newClientMid() }); return publishWithReattach(target, newMsg); }