fix: make support IM delivery recoverable
Deploy olons-server production / deploy (push) Successful in 3m38s
Deploy olons-server production / deploy (push) Successful in 3m38s
This commit is contained in:
+164
-20
@@ -25,18 +25,57 @@ export interface ChatMessage {
|
||||
file_name?: string;
|
||||
}
|
||||
|
||||
export type CustomerServiceConnectionState = 'connecting' | 'online' | 'reconnecting' | 'offline';
|
||||
|
||||
interface CustomerServiceChatOptions {
|
||||
origin?: string;
|
||||
onConnectionState?: (state: CustomerServiceConnectionState) => void;
|
||||
}
|
||||
|
||||
export function resolveCustomerServiceEndpoints(session: CustomerServiceSession, origin: string) {
|
||||
const pageOrigin = new URL(origin);
|
||||
const sameOriginURL = (value: string, fallback: string) => {
|
||||
const configured = new URL(value || fallback, pageOrigin);
|
||||
const resolved = new URL(`${configured.pathname}${configured.search}`, pageOrigin);
|
||||
return resolved.toString().replace(/\/$/, '');
|
||||
};
|
||||
const websocket = new URL(sameOriginURL(session.websocket_url, '/v0/channels'));
|
||||
websocket.protocol = pageOrigin.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return {
|
||||
apiBaseURL: sameOriginURL(session.api_base_url, '/v1/im'),
|
||||
fileURL: sameOriginURL(session.file_url, '/v0/file'),
|
||||
websocketURL: websocket.toString().replace(/\/$/, ''),
|
||||
};
|
||||
}
|
||||
|
||||
export class CustomerServiceChat {
|
||||
private static readonly reconnectPageSize = 50;
|
||||
private static readonly maxReconnectPages = 100;
|
||||
private tinode: any;
|
||||
private topic: any;
|
||||
private currentUserID: string;
|
||||
private onMessages: (messages: ChatMessage[]) => void;
|
||||
private session: CustomerServiceSession;
|
||||
private endpoints: ReturnType<typeof resolveCustomerServiceEndpoints>;
|
||||
private token = '';
|
||||
private sessionActive = false;
|
||||
private intentionalDisconnect = false;
|
||||
private recovery: Promise<void> | null = null;
|
||||
private onConnectionState: (state: CustomerServiceConnectionState) => void;
|
||||
|
||||
constructor(session: CustomerServiceSession, onMessages: (messages: ChatMessage[]) => void) {
|
||||
const endpoint = new URL(session.websocket_url);
|
||||
constructor(
|
||||
session: CustomerServiceSession,
|
||||
onMessages: (messages: ChatMessage[]) => void,
|
||||
options: CustomerServiceChatOptions = {},
|
||||
) {
|
||||
const origin = options.origin || window.location.origin;
|
||||
this.endpoints = resolveCustomerServiceEndpoints(session, origin);
|
||||
const endpoint = new URL(this.endpoints.websocketURL);
|
||||
this.session = session;
|
||||
this.token = session.token;
|
||||
this.currentUserID = session.current_user_id;
|
||||
this.onMessages = onMessages;
|
||||
this.onConnectionState = options.onConnectionState || (() => undefined);
|
||||
this.tinode = new Tinode({
|
||||
appName: '悦选',
|
||||
host: endpoint.host,
|
||||
@@ -48,15 +87,35 @@ export class CustomerServiceChat {
|
||||
this.installTenantPatch(session.tenant_code);
|
||||
this.topic = this.tinode.getTopic(session.topic);
|
||||
this.topic.onData = () => this.refreshMessages();
|
||||
this.tinode.onDisconnect = () => {
|
||||
if (!this.intentionalDisconnect && this.sessionActive) {
|
||||
this.onConnectionState('reconnecting');
|
||||
}
|
||||
};
|
||||
this.tinode.onAutoreconnectIteration = (timeoutMs: number) => {
|
||||
if (!this.intentionalDisconnect && this.sessionActive) {
|
||||
this.onConnectionState(timeoutMs >= 0 ? 'reconnecting' : 'offline');
|
||||
}
|
||||
};
|
||||
this.tinode.onConnect = () => {
|
||||
if (!this.intentionalDisconnect && this.sessionActive) {
|
||||
return this.restoreAfterReconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async connect(token: string) {
|
||||
this.intentionalDisconnect = false;
|
||||
this.token = token;
|
||||
this.onConnectionState('connecting');
|
||||
await this.tinode.connect();
|
||||
const login = await this.tinode.loginToken(token);
|
||||
const login = await this.tinode.loginToken(this.token);
|
||||
if (login?.code >= 300) throw new Error('客服会话登录失败');
|
||||
this.acceptRotatedToken(login);
|
||||
const query = this.topic.startMetaQuery().withData(undefined, undefined, 50).build();
|
||||
const subscribed = await this.topic.subscribe(query);
|
||||
if (subscribed?.code >= 300) throw new Error('客服会话订阅失败');
|
||||
await this.requestMessagePage(() => this.topic.subscribe(query), '客服会话订阅失败');
|
||||
this.sessionActive = true;
|
||||
this.onConnectionState('online');
|
||||
this.refreshMessages();
|
||||
}
|
||||
|
||||
@@ -101,6 +160,9 @@ export class CustomerServiceChat {
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.intentionalDisconnect = true;
|
||||
this.sessionActive = false;
|
||||
this.onConnectionState('offline');
|
||||
try {
|
||||
this.topic?.leave?.(false);
|
||||
} finally {
|
||||
@@ -108,6 +170,85 @@ export class CustomerServiceChat {
|
||||
}
|
||||
}
|
||||
|
||||
private restoreAfterReconnect() {
|
||||
if (this.recovery) return this.recovery;
|
||||
this.recovery = (async () => {
|
||||
this.onConnectionState('reconnecting');
|
||||
const login = await this.tinode.loginToken(this.token);
|
||||
if (login?.code >= 300) throw new Error('客服会话重新登录失败');
|
||||
this.acceptRotatedToken(login);
|
||||
await this.restoreSubscriptionAndMessages();
|
||||
this.refreshMessages();
|
||||
this.onConnectionState('online');
|
||||
})()
|
||||
.catch(() => {
|
||||
this.onConnectionState('offline');
|
||||
})
|
||||
.finally(() => {
|
||||
this.recovery = null;
|
||||
});
|
||||
return this.recovery;
|
||||
}
|
||||
|
||||
private acceptRotatedToken(login: any) {
|
||||
const rotated = String(login?.params?.token || '');
|
||||
if (!rotated) return;
|
||||
this.token = rotated;
|
||||
this.session.token = rotated;
|
||||
}
|
||||
|
||||
private async restoreSubscriptionAndMessages() {
|
||||
const pageSize = CustomerServiceChat.reconnectPageSize;
|
||||
const firstQuery = this.topic.startMetaQuery().withLaterData(pageSize).build();
|
||||
let count = await this.requestMessagePage(() => this.topic.subscribe(firstQuery), '客服会话重新订阅失败');
|
||||
|
||||
for (let page = 1; count >= pageSize; page += 1) {
|
||||
if (page >= CustomerServiceChat.maxReconnectPages) {
|
||||
throw new Error('客服消息积压过多,请重新进入客服会话');
|
||||
}
|
||||
const previousMax = Number(this.topic.maxMsgSeq?.() || 0);
|
||||
const query = this.topic.startMetaQuery().withLaterData(pageSize).build();
|
||||
count = await this.requestMessagePage(() => this.topic.getMeta(query), '客服历史消息补拉失败');
|
||||
const currentMax = Number(this.topic.maxMsgSeq?.() || 0);
|
||||
if (count > 0 && currentMax <= previousMax) {
|
||||
throw new Error('客服历史消息补拉未推进');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async requestMessagePage(request: () => Promise<any>, errorMessage: string) {
|
||||
let observedCount: number | undefined;
|
||||
let resolveObserved: ((count: number) => void) | undefined;
|
||||
const observed = new Promise<number>((resolve) => {
|
||||
resolveObserved = resolve;
|
||||
});
|
||||
const previousHandler = this.topic.onAllMessagesReceived;
|
||||
this.topic.onAllMessagesReceived = (count: number) => {
|
||||
previousHandler?.(count);
|
||||
observedCount = Number(count || 0);
|
||||
resolveObserved?.(observedCount);
|
||||
};
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const response = await request();
|
||||
if (response?.code >= 300) throw new Error(errorMessage);
|
||||
if (observedCount !== undefined) return observedCount;
|
||||
return await Promise.race([
|
||||
observed,
|
||||
new Promise<number>((_, reject) => {
|
||||
// A Tinode request promise resolves before the SDK finishes routing
|
||||
// its data packets. Only this completion callback guarantees that
|
||||
// maxMsgSeq and the local message cache belong to the finished page.
|
||||
timeout = setTimeout(() => reject(new Error(errorMessage)), 5_000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
this.topic.onAllMessagesReceived = previousHandler;
|
||||
}
|
||||
}
|
||||
|
||||
private installTenantPatch(tenantCode: string) {
|
||||
const connection = this.tinode._connection;
|
||||
if (!connection || connection.__olonsTenantPatched) return;
|
||||
@@ -152,21 +293,24 @@ export class CustomerServiceChat {
|
||||
}
|
||||
|
||||
private async upload(file: File, onProgress?: (progress: number) => void) {
|
||||
const uploadURL = new URL(this.session.file_url || '/v0/file', this.session.api_base_url);
|
||||
const uploadURL = new URL(this.endpoints.fileURL);
|
||||
uploadURL.pathname = `${uploadURL.pathname.replace(/\/$/, '').replace(/\/u$/, '')}/u/`;
|
||||
uploadURL.searchParams.set('apikey', this.session.api_key);
|
||||
uploadURL.searchParams.set('auth', 'token');
|
||||
uploadURL.searchParams.set('secret', this.session.token);
|
||||
const form = new FormData();
|
||||
form.set('id', clientMID());
|
||||
form.set('file', file, file.name);
|
||||
onProgress?.(0.1);
|
||||
let response: Response;
|
||||
try {
|
||||
// Do not attach XMLHttpRequest upload progress listeners here. For a
|
||||
// cross-origin IM endpoint they trigger a CORS preflight which the file
|
||||
// service does not expose, while multipart/form-data itself is accepted.
|
||||
response = await fetch(uploadURL.toString(), { method: 'POST', body: form });
|
||||
// Keep uploads on fetch so API and token credentials stay in headers and
|
||||
// never leak into the URL, browser history, or proxy access logs.
|
||||
response = await fetch(uploadURL.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-MeetingIM-APIKey': this.session.api_key,
|
||||
Authorization: `Token ${this.token}`,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
} catch {
|
||||
throw new Error('媒体上传失败,请检查网络后重试');
|
||||
}
|
||||
@@ -186,15 +330,15 @@ export class CustomerServiceChat {
|
||||
|
||||
private mediaURL(ref?: string) {
|
||||
if (!ref) return '';
|
||||
const apiBase = new URL(this.session.api_base_url);
|
||||
const apiBase = new URL(this.endpoints.apiBaseURL);
|
||||
const resolved = new URL(ref, apiBase);
|
||||
// The SDK signs relative file URLs. Also tolerate historical absolute IM
|
||||
// references by converting same-origin URLs back to a signable path.
|
||||
const signable = resolved.origin === apiBase.origin
|
||||
? `${resolved.pathname}${resolved.search}`
|
||||
: ref;
|
||||
// Historical messages may contain the old absolute IM host. Route only
|
||||
// known IM file paths through the current frontend origin; leave unrelated
|
||||
// external media untouched.
|
||||
if (!resolved.pathname.startsWith('/v0/file/') && resolved.origin !== apiBase.origin) return ref;
|
||||
const signable = `${resolved.pathname}${resolved.search}`;
|
||||
const authorized = this.tinode.authorizeURL(signable);
|
||||
return new URL(authorized, this.session.api_base_url).toString();
|
||||
return new URL(authorized, this.endpoints.apiBaseURL).toString();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user