diff --git a/web/src/lib/tinode/attachments.ts b/web/src/lib/tinode/attachments.ts index 07f991b2..b767e17e 100644 --- a/web/src/lib/tinode/attachments.ts +++ b/web/src/lib/tinode/attachments.ts @@ -2,7 +2,7 @@ import { Drafty, type Topic } from 'tinode-sdk'; import { uploadFile, upgradeToHttps } from '@/lib/api/upload'; import { MAX_INBAND_ATTACHMENT_SIZE } from '@/lib/config'; -import { newClientMid } from '@/lib/tinode/send'; +import { newClientMid, publishWithReattach } from '@/lib/tinode/send'; function fileToBase64(file: File): Promise { return new Promise((resolve, reject) => { @@ -51,7 +51,7 @@ export async function sendImage(topic: Topic, file: File): Promise { }); const msg = topic.createMessage(content, false); msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'image', 'x-im-client-mid': newClientMid() }); - return topic.publishDraft(msg, Promise.resolve()); + return publishWithReattach(topic, msg); } export async function sendFile(topic: Topic, file: File): Promise { @@ -61,7 +61,7 @@ export async function sendFile(topic: Topic, file: File): Promise { : Drafty.attachFile(undefined, { mime: file.type, refurl: upgradeToHttps((await uploadFile(file, topic.name)).url), filename: file.name, size: file.size }); const msg = topic.createMessage(content, false); msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'file', 'x-im-client-mid': newClientMid() }); - return topic.publishDraft(msg, Promise.resolve()); + return publishWithReattach(topic, msg); } /** @@ -86,5 +86,5 @@ export async function sendVoice(topic: Topic, blob: Blob, durationMs: number): P }); const msg = topic.createMessage(content, false); msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'voice', 'x-im-client-mid': newClientMid() }); - return topic.publishDraft(msg, Promise.resolve()); + return publishWithReattach(topic, msg); } diff --git a/web/src/lib/tinode/calls.ts b/web/src/lib/tinode/calls.ts index bc790721..a4299c6f 100644 --- a/web/src/lib/tinode/calls.ts +++ b/web/src/lib/tinode/calls.ts @@ -1,6 +1,6 @@ import type { DraftyContent, StoredMessage, Topic } from 'tinode-sdk'; -import { newClientMid } from '@/lib/tinode/send'; +import { newClientMid, publishWithReattach } from '@/lib/tinode/send'; /** * Call signaling send-side helpers. Confirmed shape (docs/IM-前后端对接文档.md §0.5.4.7/§8, lines @@ -63,7 +63,7 @@ export function sendCallInvite(topic: Topic, callId: string, callType: 'audio' | // not guessed. This cast is deliberately lying to the type system for that one confirmed reason. const msg = topic.createMessage(content as unknown as DraftyContent, false); msg.head = Object.assign(msg.head || {}, { mime: 'application/json', webrtc: 'started', 'x-im-type': 'call', 'x-im-client-mid': newClientMid() }); - return topic.publishDraft(msg, Promise.resolve()) as Promise<{ code: number; params?: { seq: number } }>; + return publishWithReattach(topic, msg) as Promise<{ code: number; params?: { seq: number } }>; } /** diff --git a/web/src/lib/tinode/send.ts b/web/src/lib/tinode/send.ts index 44ff1eee..e63ef68e 100644 --- a/web/src/lib/tinode/send.ts +++ b/web/src/lib/tinode/send.ts @@ -28,6 +28,30 @@ export function newClientMid(): string { : `mid_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`; } +/** + * Wraps `topic.publishDraft` with a single retry after a forced re-subscribe. Confirmed live, + * 2026-09-21, on an independent native-Cluster verification deployment: killing the Pod holding a + * topic's Cluster master tears down that topic's server-side proxy session on the surviving nodes + * (`server/session.go`'s `s.publish` logs "must attach first" and replies with a 409/`ErrAttachFirst`) + * WITHOUT notifying the client -- the SDK's local `_attached` flag is never reset on this path (only + * `_gone()`/topic-deleted flows clear it), so `isSubscribed()`/`ensureTopicAttached()` keep reading a + * stale `true` and can't catch this in advance. Every already-open conversation kept failing to send + * until a fresh `subscribe()` was issued by hand. `publishDraft`'s own `_noForwarding` guard (set on + * the first call) makes calling it again on the SAME `msg` object safe -- it re-sends the `{pub}` + * without re-inserting a duplicate local echo, and the message's `x-im-client-mid` is unchanged so a + * network-level duplicate is still deduped server-side. Only 409 + this exact message is treated as + * recoverable; any other rejection (permissions, malformed content, network down) passes through + * unchanged so it still surfaces via the caller's existing `showError` handling. + */ +export function publishWithReattach(topic: Topic, msg: StoredMessage): Promise { + return topic.publishDraft(msg, Promise.resolve()).catch((err: unknown) => { + const code = err instanceof Error ? (err as Error & { code?: number }).code : undefined; + const recoverable = code === 409 && err instanceof Error && err.message.includes('attach'); + if (!recoverable) throw err; + return topic.subscribe().then(() => topic.publishDraft(msg, Promise.resolve())); + }); +} + /** * Plain-text send: createMessage + publishDraft (not the simpler `.publish()`), matching the old * app's own choice -- publishDraft inserts the message into the local cache and fires onData @@ -38,7 +62,7 @@ export function sendText(topic: Topic, text: string, head?: Record): Promise { const msg = topic.createMessage(content, false); msg.head = Object.assign(msg.head || {}, head, { 'x-im-client-mid': newClientMid() }); - return topic.publishDraft(msg, Promise.resolve()); + return publishWithReattach(topic, msg); } /** @@ -73,7 +97,7 @@ export function retryMessage(topic: Topic, seq: number): Promise | unde msg.head = Object.assign(msg.head || {}, failed.head, { 'x-im-client-mid': typeof existingMid === 'string' ? existingMid : newClientMid(), }); - return topic.publishDraft(msg, Promise.resolve()); + return publishWithReattach(topic, msg); } export function messageIsMine(msg: StoredMessage, myUserId: string | null): boolean { @@ -95,7 +119,7 @@ export function sendReply(topic: Topic, text: string, quoted: StoredMessage, quo const finalContent = Drafty.append(sanitized, Drafty.parse(text)); const msg = topic.createMessage(finalContent, false); msg.head = Object.assign(msg.head || {}, { reply: String(quoted.seq), 'x-im-client-mid': newClientMid() }); - return topic.publishDraft(msg, Promise.resolve()); + return publishWithReattach(topic, msg); } /** @@ -109,5 +133,5 @@ export function forwardMessage(sourceTopic: string, msg: StoredMessage, target: 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() }); - return target.publishDraft(newMsg, Promise.resolve()); + return publishWithReattach(target, newMsg); }