修复转发含flat非Drafty内容的消息(手机端文本/图片/语音/视频/名片/通话结果)导致content丢失显示"已删除"
真实数据库排查确认根因:forwardMessage()原来无条件把 msg.content 喂给
Drafty.forwardedContent(toDraftyContent(...)),假设内容永远是纯字符串或真
Drafty文档({txt,fmt,ent})。但手机端把文本/图片/语音/视频/名片都发成扁平非
Drafty JSON(如 {"text":"11"}),只靠 head['x-im-type']/head.webrtc 标记类型
——对这类对象跑 Drafty 处理会退化成空内容,SDK 出站的 simplify() 又会把空
content 整个从包里剥掉,服务端收到的 {pub} 根本没有 content 字段,落库后
content/content_nonce 都是真正的 NULL,客户端据此正确地(从自己的角度)把
它当成"已撤回"渲染成"⚠ 消息已删除"。
用 SSH+kubectl exec 直接查了k3s上的真实MySQL(meeting_im库, im_003_group_
messages_202609/im_003_messages两张表),确认了具体是哪几条消息、它们的
head/content 真实长什么样,而不是猜测。
修复:flatForwardPayload() 识别这几种扁平内容形状,文本类重建成真正的
Drafty文本,附件/名片/通话结果类原样保留内容和 x-im-type/webrtc 标记(接收
端已有的对应dispatch逻辑本来就认识这些形状),只有真正的纯字符串/Drafty
内容才继续走原来的 Drafty.forwardedContent() 路径。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0ace395cfb
commit
eb0a6eba7e
@@ -7,6 +7,64 @@ function toDraftyContent(content: DraftyContent | string | null | undefined): Dr
|
|||||||
return typeof content === 'string' ? Drafty.init(content) : content;
|
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<string, unknown>; headOverrides: Record<string, unknown> } | 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<string, unknown> = { 'x-im-type': headType };
|
||||||
|
if (typeof msg.head?.mime === 'string') headOverrides.mime = msg.head.mime;
|
||||||
|
return { content: c as Record<string, unknown>, 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<string, unknown>, headOverrides: { webrtc: msg.head.webrtc } };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* docs/IM-前后端对接文档.md:486/952/1128 -- every `{pub}` must carry a client-generated
|
* 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
|
* `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).
|
* 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<unknown> {
|
export function forwardMessage(sourceTopic: string, msg: StoredMessage, target: Topic): Promise<unknown> {
|
||||||
const content = Drafty.forwardedContent(toDraftyContent(msg.content));
|
const flat = flatForwardPayload(msg);
|
||||||
const newMsg = target.createMessage(content, false);
|
const content = flat ? flat.content : Drafty.forwardedContent(toDraftyContent(msg.content));
|
||||||
newMsg.head = Object.assign(newMsg.head || {}, { forwarded: `${sourceTopic}:${msg.seq}`, 'x-im-client-mid': newClientMid() });
|
// `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);
|
return publishWithReattach(target, newMsg);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user