@csntgao/uni-base 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierignore +4 -0
- package/.prettierrc.json +6 -0
- package/AGENTS.md +519 -0
- package/CLAUDE.md +512 -0
- package/README.md +734 -0
- package/docs//346/225/260/345/255/227/345/221/230/345/267/245/345/256/214/346/225/264/346/212/275/345/261/211/346/216/245/345/205/245/350/257/264/346/230/216.md +110 -0
- package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/215/225/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +79 -0
- package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/244/232/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +93 -0
- package/docs//350/264/246/346/210/267/344/270/216/347/231/273/345/275/225/347/273/204/344/273/266/345/212/250/346/200/201/346/216/245/345/205/245/350/257/264/346/230/216.md +201 -0
- package/eslint.config.js +30 -0
- package/examples/vanilla/index.html +33 -0
- package/examples/vanilla/main.js +247 -0
- package/examples/vanilla/package.json +15 -0
- package/examples/vanilla/public/runtime-config.js +10 -0
- package/examples/vanilla/style.css +99 -0
- package/package.json +31 -0
- package/packages/core/package.json +30 -0
- package/packages/core/src/client.ts +1132 -0
- package/packages/core/src/customer-support-client.ts +825 -0
- package/packages/core/src/customer-support-types.ts +123 -0
- package/packages/core/src/index.ts +79 -0
- package/packages/core/src/notification-center-client.ts +462 -0
- package/packages/core/src/notification-center-types.ts +154 -0
- package/packages/core/src/protocol.ts +237 -0
- package/packages/core/src/types.ts +170 -0
- package/packages/core/tests/client.test.ts +760 -0
- package/packages/core/tests/customer-support-client.test.ts +576 -0
- package/packages/core/tests/fake-websocket.ts +55 -0
- package/packages/core/tests/notification-center-client.test.ts +467 -0
- package/packages/core/tests/protocol.test.ts +124 -0
- package/packages/core/tsconfig.json +7 -0
- package/packages/core/tsup.config.ts +11 -0
- package/packages/web-components/package.json +35 -0
- package/packages/web-components/scripts/verify-runtime-build.mjs +111 -0
- package/packages/web-components/src/account-context.ts +1540 -0
- package/packages/web-components/src/application-switcher-transport.ts +243 -0
- package/packages/web-components/src/application-switcher.ts +1103 -0
- package/packages/web-components/src/chat.ts +1754 -0
- package/packages/web-components/src/customer-support-chat.ts +862 -0
- package/packages/web-components/src/customer-support-transport.ts +269 -0
- package/packages/web-components/src/index.ts +256 -0
- package/packages/web-components/src/notification-center.ts +1066 -0
- package/packages/web-components/src/notification-popover.ts +615 -0
- package/packages/web-components/src/organization-multi-employee-selector.ts +1564 -0
- package/packages/web-components/src/organization-onboarding-transport.ts +54 -0
- package/packages/web-components/src/organization-onboarding.ts +1216 -0
- package/packages/web-components/src/organization-selector.ts +534 -0
- package/packages/web-components/src/organization-single-employee-selector.ts +114 -0
- package/packages/web-components/src/prototype-icons.ts +242 -0
- package/packages/web-components/src/safe-image-url.ts +29 -0
- package/packages/web-components/src/safe-markdown.ts +192 -0
- package/packages/web-components/src/uniplat-base-account-context-transport.ts +625 -0
- package/packages/web-components/src/uniplat-base-application-switcher-transport.ts +418 -0
- package/packages/web-components/src/uniplat-base-application-ticket.ts +254 -0
- package/packages/web-components/src/uniplat-base-identity-ticket.ts +202 -0
- package/packages/web-components/src/uniplat-base-notification-center-transport.ts +378 -0
- package/packages/web-components/src/uniplat-base-organization-multi-employee-selector-transport.ts +394 -0
- package/packages/web-components/src/uniplat-base-organization-onboarding-transport.ts +183 -0
- package/packages/web-components/src/uniplat-base-organization-single-employee-selector-transport.ts +14 -0
- package/packages/web-components/src/user-login-transport.ts +563 -0
- package/packages/web-components/src/user-login.ts +2027 -0
- package/packages/web-components/src/user-menu.ts +880 -0
- package/packages/web-components/tests/account-context.test.ts +798 -0
- package/packages/web-components/tests/application-switcher.test.ts +562 -0
- package/packages/web-components/tests/chat.test.ts +1130 -0
- package/packages/web-components/tests/customer-support-chat.test.ts +428 -0
- package/packages/web-components/tests/customer-support-transport.test.ts +79 -0
- package/packages/web-components/tests/notification-center-transport.test.ts +271 -0
- package/packages/web-components/tests/notification-center.test.ts +429 -0
- package/packages/web-components/tests/notification-popover.test.ts +283 -0
- package/packages/web-components/tests/organization-multi-employee-selector-transport.test.ts +332 -0
- package/packages/web-components/tests/organization-multi-employee-selector.test.ts +225 -0
- package/packages/web-components/tests/organization-onboarding.test.ts +260 -0
- package/packages/web-components/tests/organization-selector.test.ts +127 -0
- package/packages/web-components/tests/organization-single-employee-selector.test.ts +145 -0
- package/packages/web-components/tests/uniplat-base-account-context-transport.test.ts +496 -0
- package/packages/web-components/tests/uniplat-base-application-switcher-transport.test.ts +598 -0
- package/packages/web-components/tests/uniplat-base-application-ticket.test.ts +233 -0
- package/packages/web-components/tests/uniplat-base-identity-ticket.test.ts +226 -0
- package/packages/web-components/tests/uniplat-base-organization-onboarding-transport.test.ts +227 -0
- package/packages/web-components/tests/user-login-transport.test.ts +443 -0
- package/packages/web-components/tests/user-login.test.ts +626 -0
- package/packages/web-components/tests/user-menu.test.ts +271 -0
- package/packages/web-components/tsconfig.json +8 -0
- package/packages/web-components/tsup.config.ts +12 -0
- package/packages/web-components/tsup.runtime.config.ts +37 -0
- package/pnpm-workspace.yaml +6 -0
- package/tsconfig.base.json +21 -0
- package/vitest.config.ts +20 -0
- package//344/272/244/346/216/245/346/226/207/346/241/243.md +254 -0
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
import { createPingFrame, parseJsonRecord } from './protocol'
|
|
2
|
+
import type {
|
|
3
|
+
CustomerSupportClientEventMap,
|
|
4
|
+
CustomerSupportClientListener,
|
|
5
|
+
CustomerSupportClientOptions,
|
|
6
|
+
CustomerSupportErrorCode,
|
|
7
|
+
CustomerSupportErrorDetail,
|
|
8
|
+
CustomerSupportMessage,
|
|
9
|
+
CustomerSupportSession,
|
|
10
|
+
} from './customer-support-types'
|
|
11
|
+
import type { AiEmployeeConnectionState, AiEmployeeReconnectOptions } from './types'
|
|
12
|
+
|
|
13
|
+
type UntypedListener = (detail: unknown) => void
|
|
14
|
+
type JsonRecord = Record<string, unknown>
|
|
15
|
+
|
|
16
|
+
const DEFAULT_RECONNECT: AiEmployeeReconnectOptions = {
|
|
17
|
+
initialDelayMs: 1_000,
|
|
18
|
+
maximumDelayMs: 30_000,
|
|
19
|
+
factor: 2,
|
|
20
|
+
jitter: 0.2,
|
|
21
|
+
}
|
|
22
|
+
const MAX_TOKEN_RECONNECTS = 1
|
|
23
|
+
const MAX_AVAILABILITY_RECONNECTS = 3
|
|
24
|
+
|
|
25
|
+
type AuthenticationRetryMode = 'token' | 'availability' | 'never'
|
|
26
|
+
|
|
27
|
+
interface SocketAuthenticationFailure {
|
|
28
|
+
generation: number
|
|
29
|
+
retryMode: AuthenticationRetryMode
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CustomerSupportReadCursorState {
|
|
33
|
+
acknowledged: string
|
|
34
|
+
inFlight: string | null
|
|
35
|
+
pending: string | null
|
|
36
|
+
generation: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface CustomerSupportHistoryRefresh {
|
|
40
|
+
sessionId: string
|
|
41
|
+
promise: Promise<void>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface CustomerSupportSessionsRefresh {
|
|
45
|
+
promise: Promise<readonly CustomerSupportSession[]>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class CustomerSupportClientError extends Error {
|
|
49
|
+
readonly code: CustomerSupportErrorCode
|
|
50
|
+
|
|
51
|
+
constructor(code: CustomerSupportErrorCode, message: string) {
|
|
52
|
+
super(message)
|
|
53
|
+
this.name = 'CustomerSupportClientError'
|
|
54
|
+
this.code = code
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class CustomerSupportClient {
|
|
59
|
+
private readonly realtimeUrl: string
|
|
60
|
+
private readonly transport: CustomerSupportClientOptions['transport']
|
|
61
|
+
private readonly webSocketFactory: (url: string) => WebSocket
|
|
62
|
+
private readonly requestIdFactory: () => string
|
|
63
|
+
private readonly reconnectOptions: AiEmployeeReconnectOptions
|
|
64
|
+
private readonly minimumHeartbeatMs: number
|
|
65
|
+
private readonly listeners = new Map<keyof CustomerSupportClientEventMap, Set<UntypedListener>>()
|
|
66
|
+
|
|
67
|
+
private currentSession: CustomerSupportSession | null = null
|
|
68
|
+
private currentSessions: CustomerSupportSession[] = []
|
|
69
|
+
private currentMessages: CustomerSupportMessage[] = []
|
|
70
|
+
private currentState: AiEmployeeConnectionState = 'idle'
|
|
71
|
+
private socket: WebSocket | null = null
|
|
72
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
|
73
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
74
|
+
private reconnectAttempt = 0
|
|
75
|
+
private tokenReconnectAttempt = 0
|
|
76
|
+
private availabilityReconnectAttempt = 0
|
|
77
|
+
private socketGeneration = 0
|
|
78
|
+
private socketAuthenticationFailure: SocketAuthenticationFailure | null = null
|
|
79
|
+
private destroyed = false
|
|
80
|
+
private shouldReconnect = false
|
|
81
|
+
private sending = false
|
|
82
|
+
private historyRefresh: CustomerSupportHistoryRefresh | null = null
|
|
83
|
+
private sessionsRefresh: CustomerSupportSessionsRefresh | null = null
|
|
84
|
+
private readonly readCursorStates = new Map<string, CustomerSupportReadCursorState>()
|
|
85
|
+
private readLifecycleGeneration = 0
|
|
86
|
+
private requestedSessionId: string | null
|
|
87
|
+
|
|
88
|
+
constructor(options: CustomerSupportClientOptions) {
|
|
89
|
+
this.realtimeUrl = validateRealtimeUrl(options.realtimeUrl)
|
|
90
|
+
if (!options.transport) {
|
|
91
|
+
throw new CustomerSupportClientError('INVALID_CONFIGURATION', '缺少客服 transport 配置。')
|
|
92
|
+
}
|
|
93
|
+
this.transport = options.transport
|
|
94
|
+
this.requestedSessionId = normalizedText(options.sessionId) || null
|
|
95
|
+
this.requestIdFactory = options.requestIdFactory ?? createRequestId
|
|
96
|
+
this.reconnectOptions = normalizeReconnect(options.reconnect)
|
|
97
|
+
this.minimumHeartbeatMs = Math.max(1, options.minimumHeartbeatMs ?? 5_000)
|
|
98
|
+
this.webSocketFactory =
|
|
99
|
+
options.webSocketFactory ??
|
|
100
|
+
((url) => {
|
|
101
|
+
if (typeof WebSocket === 'undefined') {
|
|
102
|
+
throw new CustomerSupportClientError(
|
|
103
|
+
'WEBSOCKET_UNAVAILABLE',
|
|
104
|
+
'当前运行环境不支持 WebSocket。',
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
return new WebSocket(url)
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
get session(): CustomerSupportSession | null {
|
|
112
|
+
return this.currentSession
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
get messages(): readonly CustomerSupportMessage[] {
|
|
116
|
+
return [...this.currentMessages]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
get connectionState(): AiEmployeeConnectionState {
|
|
120
|
+
return this.currentState
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
on<K extends keyof CustomerSupportClientEventMap>(
|
|
124
|
+
type: K,
|
|
125
|
+
listener: CustomerSupportClientListener<K>,
|
|
126
|
+
): () => void {
|
|
127
|
+
this.assertUsable()
|
|
128
|
+
const listeners = this.listeners.get(type) ?? new Set<UntypedListener>()
|
|
129
|
+
const untyped = listener as UntypedListener
|
|
130
|
+
listeners.add(untyped)
|
|
131
|
+
this.listeners.set(type, listeners)
|
|
132
|
+
return () => listeners.delete(untyped)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async start(): Promise<void> {
|
|
136
|
+
this.assertUsable()
|
|
137
|
+
try {
|
|
138
|
+
const sessions = await this.transport.listSessions()
|
|
139
|
+
this.assertUsable()
|
|
140
|
+
this.replaceSessions(sessions)
|
|
141
|
+
const requested = this.requestedSessionId
|
|
142
|
+
this.currentSession = requested
|
|
143
|
+
? (sessions.find((session) => session.id === requested) ?? null)
|
|
144
|
+
: (sessions.find(
|
|
145
|
+
(session) => session.status !== 'closed' && hasCustomerSupportMessages(session),
|
|
146
|
+
) ??
|
|
147
|
+
sessions.find(hasCustomerSupportMessages) ??
|
|
148
|
+
null)
|
|
149
|
+
if (this.currentSession) {
|
|
150
|
+
this.emit('session', { session: this.currentSession })
|
|
151
|
+
await this.refreshHistory()
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
this.emitError('HISTORY_LOAD_FAILED', '客服会话加载失败,实时连接仍会继续。', true)
|
|
155
|
+
}
|
|
156
|
+
this.shouldReconnect = true
|
|
157
|
+
this.openSocket(false)
|
|
158
|
+
this.emit('ready', { sessionId: this.currentSession?.id ?? null })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
prepareNewConversation(): void {
|
|
162
|
+
this.assertUsable()
|
|
163
|
+
this.currentSession = null
|
|
164
|
+
this.requestedSessionId = null
|
|
165
|
+
this.currentMessages = []
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async bindSession(sessionId: string): Promise<void> {
|
|
169
|
+
this.assertUsable()
|
|
170
|
+
const normalized = requiredText(sessionId, 'sessionId')
|
|
171
|
+
const sessions = await this.transport.listSessions()
|
|
172
|
+
this.replaceSessions(sessions)
|
|
173
|
+
const session = sessions.find((candidate) => candidate.id === normalized)
|
|
174
|
+
if (!session) {
|
|
175
|
+
throw new CustomerSupportClientError('SESSION_UNAVAILABLE', '客服会话不存在。')
|
|
176
|
+
}
|
|
177
|
+
this.currentSession = session
|
|
178
|
+
this.requestedSessionId = session.id
|
|
179
|
+
this.emit('session', { session })
|
|
180
|
+
await this.refreshHistory()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async sendMessage(content: string): Promise<CustomerSupportMessage> {
|
|
184
|
+
this.assertUsable()
|
|
185
|
+
const normalized = content.trim()
|
|
186
|
+
if (!normalized) throw new CustomerSupportClientError('EMPTY_MESSAGE', '消息内容不能为空。')
|
|
187
|
+
if (this.sending) {
|
|
188
|
+
throw new CustomerSupportClientError('SEND_IN_PROGRESS', '上一条消息仍在发送中。')
|
|
189
|
+
}
|
|
190
|
+
this.sending = true
|
|
191
|
+
try {
|
|
192
|
+
if (!this.currentSession) {
|
|
193
|
+
const result = await this.transport.createSession({
|
|
194
|
+
clientRequestId: requiredRequestId(this.requestIdFactory()),
|
|
195
|
+
title: normalized.slice(0, 30),
|
|
196
|
+
initialMessage: normalized,
|
|
197
|
+
})
|
|
198
|
+
this.assertUsable()
|
|
199
|
+
this.currentSession = result.session
|
|
200
|
+
this.upsertSession(result.session)
|
|
201
|
+
this.requestedSessionId = result.session.id
|
|
202
|
+
this.emit('session', { session: result.session })
|
|
203
|
+
let message = result.initialMessage
|
|
204
|
+
if (!message) {
|
|
205
|
+
const page = await this.transport.listMessages(result.session.id)
|
|
206
|
+
message = [...page.messages]
|
|
207
|
+
.reverse()
|
|
208
|
+
.find(
|
|
209
|
+
(candidate) =>
|
|
210
|
+
candidate.senderType === 'customer' && candidate.content.trim() === normalized,
|
|
211
|
+
)
|
|
212
|
+
this.currentMessages = page.messages.filter((candidate) => candidate.id !== message?.id)
|
|
213
|
+
}
|
|
214
|
+
if (!message) throw new Error('Missing initial message')
|
|
215
|
+
this.appendMessage(message, 'local')
|
|
216
|
+
return message
|
|
217
|
+
}
|
|
218
|
+
const message = await this.transport.sendMessage(
|
|
219
|
+
this.currentSession.id,
|
|
220
|
+
requiredRequestId(this.requestIdFactory()),
|
|
221
|
+
normalized,
|
|
222
|
+
)
|
|
223
|
+
this.assertUsable()
|
|
224
|
+
this.appendMessage(message, 'local')
|
|
225
|
+
return message
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (error instanceof CustomerSupportClientError) throw error
|
|
228
|
+
const detail = this.emitError('SEND_FAILED', '消息发送失败,请稍后重试。', true)
|
|
229
|
+
throw new CustomerSupportClientError(detail.code, detail.message)
|
|
230
|
+
} finally {
|
|
231
|
+
this.sending = false
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async listSessions(): Promise<readonly CustomerSupportSession[]> {
|
|
236
|
+
this.assertUsable()
|
|
237
|
+
return this.refreshSessions()
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
destroy(): void {
|
|
241
|
+
if (this.destroyed) return
|
|
242
|
+
this.destroyed = true
|
|
243
|
+
this.shouldReconnect = false
|
|
244
|
+
this.clearTimers()
|
|
245
|
+
this.socketGeneration += 1
|
|
246
|
+
this.socket?.close()
|
|
247
|
+
this.socket = null
|
|
248
|
+
this.setState('destroyed')
|
|
249
|
+
this.listeners.clear()
|
|
250
|
+
this.currentMessages = []
|
|
251
|
+
this.currentSessions = []
|
|
252
|
+
this.historyRefresh = null
|
|
253
|
+
this.sessionsRefresh = null
|
|
254
|
+
this.readLifecycleGeneration += 1
|
|
255
|
+
this.readCursorStates.clear()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private async refreshHistory(realtime = false): Promise<void> {
|
|
259
|
+
const session = this.currentSession
|
|
260
|
+
if (!session) return
|
|
261
|
+
if (this.historyRefresh?.sessionId === session.id) return this.historyRefresh.promise
|
|
262
|
+
const pending = (async () => {
|
|
263
|
+
try {
|
|
264
|
+
const page = await this.transport.listMessages(session.id)
|
|
265
|
+
if (this.destroyed || this.currentSession?.id !== session.id) return
|
|
266
|
+
const unique = new Map<string, CustomerSupportMessage>()
|
|
267
|
+
for (const message of page.messages) {
|
|
268
|
+
if (message.sessionId === session.id && message.id) unique.set(message.id, message)
|
|
269
|
+
}
|
|
270
|
+
const previousIds = new Set(this.currentMessages.map((message) => message.id))
|
|
271
|
+
this.currentMessages = [...unique.values()].sort(compareMessages)
|
|
272
|
+
this.emit('history', { sessionId: session.id, messages: this.messages })
|
|
273
|
+
if (realtime) {
|
|
274
|
+
for (const message of this.currentMessages) {
|
|
275
|
+
if (!previousIds.has(message.id)) {
|
|
276
|
+
this.emit('message', { sessionId: session.id, message, source: 'realtime' })
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
this.markReadIfNeeded(session.id, page.latestSequence)
|
|
281
|
+
} catch {
|
|
282
|
+
this.emitError('HISTORY_LOAD_FAILED', '历史消息加载失败,实时连接仍会继续。', true)
|
|
283
|
+
}
|
|
284
|
+
})()
|
|
285
|
+
const refresh = { sessionId: session.id, promise: pending }
|
|
286
|
+
this.historyRefresh = refresh
|
|
287
|
+
await pending.finally(() => {
|
|
288
|
+
if (this.historyRefresh === refresh) this.historyRefresh = null
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private markReadIfNeeded(sessionId: string, latestSequence: string): void {
|
|
293
|
+
const markRead = this.transport.markRead
|
|
294
|
+
const cursor = normalizeReadCursor(latestSequence)
|
|
295
|
+
if (!markRead || !cursor || compareReadCursors(cursor, '0') <= 0 || this.destroyed) return
|
|
296
|
+
const state = this.readCursorState(sessionId)
|
|
297
|
+
if (compareReadCursors(cursor, state.acknowledged) <= 0) return
|
|
298
|
+
if (state.inFlight) {
|
|
299
|
+
if (compareReadCursors(cursor, state.inFlight) > 0) {
|
|
300
|
+
state.pending = maxReadCursor(state.pending, cursor)
|
|
301
|
+
}
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
this.submitReadCursor(sessionId, cursor, state, markRead)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private submitReadCursor(
|
|
308
|
+
sessionId: string,
|
|
309
|
+
cursor: string,
|
|
310
|
+
state: CustomerSupportReadCursorState,
|
|
311
|
+
markRead: NonNullable<CustomerSupportClientOptions['transport']['markRead']>,
|
|
312
|
+
): void {
|
|
313
|
+
const lifecycleGeneration = this.readLifecycleGeneration
|
|
314
|
+
const requestGeneration = ++state.generation
|
|
315
|
+
state.inFlight = cursor
|
|
316
|
+
let request: Promise<void>
|
|
317
|
+
try {
|
|
318
|
+
request = markRead.call(this.transport, sessionId, cursor)
|
|
319
|
+
} catch {
|
|
320
|
+
state.inFlight = null
|
|
321
|
+
state.pending = null
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
void request
|
|
325
|
+
.then(() => {
|
|
326
|
+
if (!this.isCurrentReadRequest(state, lifecycleGeneration, requestGeneration, cursor))
|
|
327
|
+
return
|
|
328
|
+
state.acknowledged = maxReadCursor(state.acknowledged, cursor)
|
|
329
|
+
const next = state.pending
|
|
330
|
+
state.pending = null
|
|
331
|
+
state.inFlight = null
|
|
332
|
+
if (next && compareReadCursors(next, state.acknowledged) > 0) {
|
|
333
|
+
this.submitReadCursor(sessionId, next, state, markRead)
|
|
334
|
+
}
|
|
335
|
+
})
|
|
336
|
+
.catch(() => {
|
|
337
|
+
if (!this.isCurrentReadRequest(state, lifecycleGeneration, requestGeneration, cursor))
|
|
338
|
+
return
|
|
339
|
+
state.inFlight = null
|
|
340
|
+
state.pending = null
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private readCursorState(sessionId: string): CustomerSupportReadCursorState {
|
|
345
|
+
const current = this.readCursorStates.get(sessionId)
|
|
346
|
+
if (current) return current
|
|
347
|
+
const created: CustomerSupportReadCursorState = {
|
|
348
|
+
acknowledged: '0',
|
|
349
|
+
inFlight: null,
|
|
350
|
+
pending: null,
|
|
351
|
+
generation: 0,
|
|
352
|
+
}
|
|
353
|
+
this.readCursorStates.set(sessionId, created)
|
|
354
|
+
return created
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private isCurrentReadRequest(
|
|
358
|
+
state: CustomerSupportReadCursorState,
|
|
359
|
+
lifecycleGeneration: number,
|
|
360
|
+
requestGeneration: number,
|
|
361
|
+
cursor: string,
|
|
362
|
+
): boolean {
|
|
363
|
+
return (
|
|
364
|
+
!this.destroyed &&
|
|
365
|
+
this.readLifecycleGeneration === lifecycleGeneration &&
|
|
366
|
+
state.generation === requestGeneration &&
|
|
367
|
+
state.inFlight === cursor
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private openSocket(reconnecting: boolean): void {
|
|
372
|
+
if (this.destroyed || !this.shouldReconnect) return
|
|
373
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
|
374
|
+
this.socket?.close()
|
|
375
|
+
this.setState(reconnecting ? 'reconnecting' : 'connecting')
|
|
376
|
+
let socket: WebSocket
|
|
377
|
+
try {
|
|
378
|
+
socket = this.webSocketFactory(this.realtimeUrl)
|
|
379
|
+
} catch {
|
|
380
|
+
this.emitError('WEBSOCKET_UNAVAILABLE', '无法建立客服实时连接。', true)
|
|
381
|
+
this.scheduleReconnect()
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
const generation = ++this.socketGeneration
|
|
385
|
+
this.socket = socket
|
|
386
|
+
socket.onopen = () => void this.authenticate(socket, generation)
|
|
387
|
+
socket.onmessage = (event) => void this.handleSocketData(event.data, socket, generation)
|
|
388
|
+
socket.onerror = () => {
|
|
389
|
+
if (this.isCurrentSocket(socket, generation)) {
|
|
390
|
+
this.emitError('WEBSOCKET_ERROR', '客服实时连接发生异常。', true)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
socket.onclose = (event) => this.handleSocketClose(socket, generation, event.code)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private async authenticate(socket: WebSocket, generation: number): Promise<void> {
|
|
397
|
+
if (!this.isCurrentSocket(socket, generation)) return
|
|
398
|
+
this.setState('authenticating')
|
|
399
|
+
let accessToken = ''
|
|
400
|
+
try {
|
|
401
|
+
accessToken = (await this.transport.getAccessToken()).trim()
|
|
402
|
+
} catch {
|
|
403
|
+
// Provider errors may contain credentials and are intentionally discarded.
|
|
404
|
+
}
|
|
405
|
+
if (!this.isCurrentSocket(socket, generation)) return
|
|
406
|
+
if (!accessToken) {
|
|
407
|
+
this.emitError('TOKEN_UNAVAILABLE', '无法获取客服实时连接凭证。', true)
|
|
408
|
+
socket.close()
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
socket.send(
|
|
412
|
+
JSON.stringify({
|
|
413
|
+
type: 'authenticate',
|
|
414
|
+
protocol_version: 1,
|
|
415
|
+
access_token: accessToken,
|
|
416
|
+
requested_channels: ['customer_support'],
|
|
417
|
+
cursors: {},
|
|
418
|
+
}),
|
|
419
|
+
)
|
|
420
|
+
accessToken = ''
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private async handleSocketData(
|
|
424
|
+
data: unknown,
|
|
425
|
+
socket: WebSocket,
|
|
426
|
+
generation: number,
|
|
427
|
+
): Promise<void> {
|
|
428
|
+
if (!this.isCurrentSocket(socket, generation)) return
|
|
429
|
+
let raw = ''
|
|
430
|
+
if (typeof data === 'string') raw = data
|
|
431
|
+
else if (data instanceof Blob) raw = await data.text()
|
|
432
|
+
else if (data instanceof ArrayBuffer) raw = new TextDecoder().decode(data)
|
|
433
|
+
const message = raw ? parseJsonRecord(raw) : null
|
|
434
|
+
if (!message) {
|
|
435
|
+
this.emitError('PROTOCOL_ERROR', '收到无法解析的客服实时消息。', true)
|
|
436
|
+
return
|
|
437
|
+
}
|
|
438
|
+
this.handleServerMessage(message, socket, generation)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private handleServerMessage(message: JsonRecord, socket: WebSocket, generation: number): void {
|
|
442
|
+
const type = normalizedText(message.type)
|
|
443
|
+
if (type === 'realtime_ready') {
|
|
444
|
+
const channels = record(message.channels)
|
|
445
|
+
if (record(channels.customer_support).available !== true) {
|
|
446
|
+
this.shouldReconnect = false
|
|
447
|
+
this.emitError('CHANNEL_UNAVAILABLE', '人工客服实时通道不可用。', false)
|
|
448
|
+
socket.close()
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
this.reconnectAttempt = 0
|
|
452
|
+
this.tokenReconnectAttempt = 0
|
|
453
|
+
this.availabilityReconnectAttempt = 0
|
|
454
|
+
this.startHeartbeat(numberValue(message.heartbeat_interval_seconds), socket, generation)
|
|
455
|
+
this.setState('connected')
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
if (type === 'realtime_event' && message.channel === 'customer_support') {
|
|
459
|
+
const event = record(message.event)
|
|
460
|
+
this.handleCustomerSupportEvent(event)
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
if (type === 'channel_resync_required' && message.channel === 'customer_support') {
|
|
464
|
+
void this.refreshFromRealtime()
|
|
465
|
+
return
|
|
466
|
+
}
|
|
467
|
+
if (type === 'authentication_error') {
|
|
468
|
+
const failure = authenticationFailure(normalizedText(message.code))
|
|
469
|
+
this.socketAuthenticationFailure = { generation, retryMode: failure.retryMode }
|
|
470
|
+
this.emitError(failure.code, failure.message, failure.retryMode !== 'never')
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private handleCustomerSupportEvent(event: JsonRecord): void {
|
|
475
|
+
const eventType = normalizedText(event.event_type)
|
|
476
|
+
const sessionId = normalizedText(event.session_id)
|
|
477
|
+
if (!eventType || !sessionId) return
|
|
478
|
+
this.refreshSessionsFromRealtime()
|
|
479
|
+
if (sessionId !== this.currentSession?.id) return
|
|
480
|
+
if (eventType === 'support_read_state_changed') return
|
|
481
|
+
if (eventType === 'support_message_created') {
|
|
482
|
+
void this.refreshHistory(true)
|
|
483
|
+
return
|
|
484
|
+
}
|
|
485
|
+
void this.refreshSessionFromRealtime()
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private async refreshFromRealtime(): Promise<void> {
|
|
489
|
+
await this.refreshSessions().catch(() => [...this.currentSessions])
|
|
490
|
+
await this.refreshSessionFromRealtime()
|
|
491
|
+
await this.refreshHistory(true)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private async refreshSessions(): Promise<readonly CustomerSupportSession[]> {
|
|
495
|
+
if (this.sessionsRefresh) return this.sessionsRefresh.promise
|
|
496
|
+
const pending = (async () => {
|
|
497
|
+
const sessions = await this.transport.listSessions()
|
|
498
|
+
if (!this.destroyed) this.replaceSessions(sessions)
|
|
499
|
+
return [...this.currentSessions]
|
|
500
|
+
})()
|
|
501
|
+
const refresh = { promise: pending }
|
|
502
|
+
this.sessionsRefresh = refresh
|
|
503
|
+
return pending.finally(() => {
|
|
504
|
+
if (this.sessionsRefresh === refresh) this.sessionsRefresh = null
|
|
505
|
+
})
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private refreshSessionsFromRealtime(): void {
|
|
509
|
+
void this.refreshSessions().catch(() => {
|
|
510
|
+
// A later realtime event, panel refresh or resync can recover the list projection.
|
|
511
|
+
})
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private replaceSessions(sessions: readonly CustomerSupportSession[]): void {
|
|
515
|
+
this.currentSessions = [...sessions]
|
|
516
|
+
this.emit('sessions', { sessions: [...this.currentSessions] })
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private upsertSession(session: CustomerSupportSession): void {
|
|
520
|
+
const currentIndex = this.currentSessions.findIndex((candidate) => candidate.id === session.id)
|
|
521
|
+
if (currentIndex < 0) this.currentSessions = [session, ...this.currentSessions]
|
|
522
|
+
else {
|
|
523
|
+
this.currentSessions = this.currentSessions.map((candidate, index) =>
|
|
524
|
+
index === currentIndex ? session : candidate,
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
this.emit('sessions', { sessions: [...this.currentSessions] })
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private async refreshSessionFromRealtime(): Promise<void> {
|
|
531
|
+
const sessionId = this.currentSession?.id
|
|
532
|
+
const getSession = this.transport.getSession
|
|
533
|
+
if (!sessionId || !getSession) return
|
|
534
|
+
try {
|
|
535
|
+
const session = await getSession.call(this.transport, sessionId)
|
|
536
|
+
if (!this.destroyed && this.currentSession?.id === sessionId) {
|
|
537
|
+
this.currentSession = session
|
|
538
|
+
this.emit('session', { session })
|
|
539
|
+
}
|
|
540
|
+
} catch {
|
|
541
|
+
// A later realtime event or resync can recover the optional session projection.
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private appendMessage(message: CustomerSupportMessage, source: 'local' | 'realtime'): void {
|
|
546
|
+
if (this.currentMessages.some((candidate) => candidate.id === message.id)) return
|
|
547
|
+
this.currentMessages = [...this.currentMessages, message].sort(compareMessages)
|
|
548
|
+
this.emit('message', { sessionId: message.sessionId, message, source })
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private startHeartbeat(intervalSeconds: number, socket: WebSocket, generation: number): void {
|
|
552
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
|
|
553
|
+
const requested = intervalSeconds > 0 ? intervalSeconds * 1_000 : 25_000
|
|
554
|
+
this.heartbeatTimer = setInterval(
|
|
555
|
+
() => {
|
|
556
|
+
if (this.isCurrentSocket(socket, generation) && socket.readyState === 1) {
|
|
557
|
+
socket.send(JSON.stringify(createPingFrame()))
|
|
558
|
+
}
|
|
559
|
+
},
|
|
560
|
+
Math.max(this.minimumHeartbeatMs, requested),
|
|
561
|
+
)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private handleSocketClose(socket: WebSocket, generation: number, code: number): void {
|
|
565
|
+
if (!this.isCurrentSocket(socket, generation)) return
|
|
566
|
+
this.socket = null
|
|
567
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
|
|
568
|
+
this.heartbeatTimer = null
|
|
569
|
+
const authenticationFailure =
|
|
570
|
+
this.socketAuthenticationFailure?.generation === generation
|
|
571
|
+
? this.socketAuthenticationFailure
|
|
572
|
+
: null
|
|
573
|
+
this.socketAuthenticationFailure = null
|
|
574
|
+
if (!this.shouldReconnect || this.destroyed) {
|
|
575
|
+
if (!this.destroyed) this.setState('disconnected')
|
|
576
|
+
return
|
|
577
|
+
}
|
|
578
|
+
if (authenticationFailure?.retryMode === 'never') {
|
|
579
|
+
this.shouldReconnect = false
|
|
580
|
+
this.setState('disconnected')
|
|
581
|
+
return
|
|
582
|
+
}
|
|
583
|
+
if (code === 4400 || code === 4403) {
|
|
584
|
+
this.shouldReconnect = false
|
|
585
|
+
if (!authenticationFailure) {
|
|
586
|
+
this.emitError(
|
|
587
|
+
code === 4400 ? 'PROTOCOL_ERROR' : 'AUTHENTICATION_FAILED',
|
|
588
|
+
code === 4400 ? '客服实时连接协议不受支持。' : '当前身份无权使用人工客服。',
|
|
589
|
+
false,
|
|
590
|
+
)
|
|
591
|
+
}
|
|
592
|
+
this.setState('disconnected')
|
|
593
|
+
return
|
|
594
|
+
}
|
|
595
|
+
if (authenticationFailure?.retryMode === 'availability' || code === 1013) {
|
|
596
|
+
if (!authenticationFailure) {
|
|
597
|
+
this.emitError('REALTIME_UNAVAILABLE', '客服实时鉴权服务暂不可用,正在重试。', true)
|
|
598
|
+
}
|
|
599
|
+
this.scheduleAvailabilityReconnect()
|
|
600
|
+
return
|
|
601
|
+
}
|
|
602
|
+
if (authenticationFailure?.retryMode === 'token' || code === 4401) {
|
|
603
|
+
if (!authenticationFailure) {
|
|
604
|
+
this.emitError('AUTHENTICATION_FAILED', '客服连接凭证已失效,正在重新连接。', true)
|
|
605
|
+
}
|
|
606
|
+
if (this.tokenReconnectAttempt >= MAX_TOKEN_RECONNECTS) {
|
|
607
|
+
this.shouldReconnect = false
|
|
608
|
+
this.emitError('AUTHENTICATION_FAILED', '人工客服身份验证失败,请重新登录后再试。', false)
|
|
609
|
+
this.setState('disconnected')
|
|
610
|
+
return
|
|
611
|
+
}
|
|
612
|
+
this.tokenReconnectAttempt += 1
|
|
613
|
+
}
|
|
614
|
+
this.scheduleReconnect()
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private scheduleAvailabilityReconnect(): void {
|
|
618
|
+
if (this.availabilityReconnectAttempt >= MAX_AVAILABILITY_RECONNECTS) {
|
|
619
|
+
this.shouldReconnect = false
|
|
620
|
+
this.emitError('REALTIME_UNAVAILABLE', '客服实时鉴权服务持续不可用,请稍后重试。', false)
|
|
621
|
+
this.setState('disconnected')
|
|
622
|
+
return
|
|
623
|
+
}
|
|
624
|
+
this.availabilityReconnectAttempt += 1
|
|
625
|
+
this.scheduleReconnect()
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
private scheduleReconnect(): void {
|
|
629
|
+
if (!this.shouldReconnect || this.destroyed || this.reconnectTimer) return
|
|
630
|
+
const base = Math.min(
|
|
631
|
+
this.reconnectOptions.maximumDelayMs,
|
|
632
|
+
this.reconnectOptions.initialDelayMs *
|
|
633
|
+
this.reconnectOptions.factor ** this.reconnectAttempt++,
|
|
634
|
+
)
|
|
635
|
+
const jitter = base * this.reconnectOptions.jitter * (Math.random() * 2 - 1)
|
|
636
|
+
this.reconnectTimer = setTimeout(
|
|
637
|
+
() => {
|
|
638
|
+
this.reconnectTimer = null
|
|
639
|
+
this.openSocket(true)
|
|
640
|
+
},
|
|
641
|
+
Math.max(0, base + jitter),
|
|
642
|
+
)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private clearTimers(): void {
|
|
646
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
|
|
647
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
|
648
|
+
this.heartbeatTimer = null
|
|
649
|
+
this.reconnectTimer = null
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private isCurrentSocket(socket: WebSocket, generation: number): boolean {
|
|
653
|
+
return !this.destroyed && this.socket === socket && this.socketGeneration === generation
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private setState(current: AiEmployeeConnectionState): void {
|
|
657
|
+
if (this.currentState === current) return
|
|
658
|
+
const previous = this.currentState
|
|
659
|
+
this.currentState = current
|
|
660
|
+
this.emit('connection-state-change', { previous, current })
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
private emitError(
|
|
664
|
+
code: CustomerSupportErrorCode,
|
|
665
|
+
message: string,
|
|
666
|
+
recoverable: boolean,
|
|
667
|
+
): CustomerSupportErrorDetail {
|
|
668
|
+
const detail = { code, message, recoverable }
|
|
669
|
+
this.emit('error', detail)
|
|
670
|
+
return detail
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
private emit<K extends keyof CustomerSupportClientEventMap>(
|
|
674
|
+
type: K,
|
|
675
|
+
detail: CustomerSupportClientEventMap[K],
|
|
676
|
+
): void {
|
|
677
|
+
for (const listener of this.listeners.get(type) ?? []) listener(detail)
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
private assertUsable(): void {
|
|
681
|
+
if (this.destroyed) {
|
|
682
|
+
throw new CustomerSupportClientError('CLIENT_DESTROYED', '客服客户端已经销毁。')
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function validateRealtimeUrl(value: string): string {
|
|
688
|
+
let url: URL
|
|
689
|
+
try {
|
|
690
|
+
url = new URL(value)
|
|
691
|
+
} catch {
|
|
692
|
+
throw new CustomerSupportClientError('INVALID_CONFIGURATION', '客服实时地址无效。')
|
|
693
|
+
}
|
|
694
|
+
if (!['ws:', 'wss:'].includes(url.protocol) || url.search || url.hash || url.username) {
|
|
695
|
+
throw new CustomerSupportClientError('INVALID_CONFIGURATION', '客服实时地址不安全。')
|
|
696
|
+
}
|
|
697
|
+
return url.href
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function normalizeReconnect(
|
|
701
|
+
value?: Partial<AiEmployeeReconnectOptions>,
|
|
702
|
+
): AiEmployeeReconnectOptions {
|
|
703
|
+
return {
|
|
704
|
+
initialDelayMs: Math.max(0, value?.initialDelayMs ?? DEFAULT_RECONNECT.initialDelayMs),
|
|
705
|
+
maximumDelayMs: Math.max(1, value?.maximumDelayMs ?? DEFAULT_RECONNECT.maximumDelayMs),
|
|
706
|
+
factor: Math.max(1, value?.factor ?? DEFAULT_RECONNECT.factor),
|
|
707
|
+
jitter: Math.min(1, Math.max(0, value?.jitter ?? DEFAULT_RECONNECT.jitter)),
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function compareMessages(left: CustomerSupportMessage, right: CustomerSupportMessage): number {
|
|
712
|
+
const leftSequence = Number(left.sequence)
|
|
713
|
+
const rightSequence = Number(right.sequence)
|
|
714
|
+
if (Number.isFinite(leftSequence) && Number.isFinite(rightSequence)) {
|
|
715
|
+
return leftSequence - rightSequence
|
|
716
|
+
}
|
|
717
|
+
return left.createdAt.localeCompare(right.createdAt)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function normalizeReadCursor(value: unknown): string | null {
|
|
721
|
+
const cursor = normalizedText(value)
|
|
722
|
+
if (!/^\d+$/.test(cursor)) return null
|
|
723
|
+
return cursor.replace(/^0+(?=\d)/, '')
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function compareReadCursors(left: string, right: string): number {
|
|
727
|
+
if (left.length !== right.length) return left.length < right.length ? -1 : 1
|
|
728
|
+
if (left === right) return 0
|
|
729
|
+
return left < right ? -1 : 1
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function maxReadCursor(left: string | null, right: string): string {
|
|
733
|
+
return left && compareReadCursors(left, right) >= 0 ? left : right
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function hasCustomerSupportMessages(session: CustomerSupportSession): boolean {
|
|
737
|
+
return session.lastMessage.trim().length > 0
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function requiredText(value: unknown, field: string): string {
|
|
741
|
+
const normalized = normalizedText(value)
|
|
742
|
+
if (!normalized) {
|
|
743
|
+
throw new CustomerSupportClientError('INVALID_CONFIGURATION', `${field} 不能为空。`)
|
|
744
|
+
}
|
|
745
|
+
return normalized
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function requiredRequestId(value: string): string {
|
|
749
|
+
return requiredText(value, 'requestId')
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function normalizedText(value: unknown): string {
|
|
753
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function record(value: unknown): JsonRecord {
|
|
757
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
758
|
+
? (value as JsonRecord)
|
|
759
|
+
: {}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function numberValue(value: unknown): number {
|
|
763
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function createRequestId(): string {
|
|
767
|
+
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
768
|
+
? crypto.randomUUID()
|
|
769
|
+
: `request-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function authenticationFailure(code: string): {
|
|
773
|
+
code: CustomerSupportErrorCode
|
|
774
|
+
message: string
|
|
775
|
+
retryMode: AuthenticationRetryMode
|
|
776
|
+
} {
|
|
777
|
+
if (code === 'UNAUTHORIZED') {
|
|
778
|
+
return {
|
|
779
|
+
code: 'AUTHENTICATION_FAILED',
|
|
780
|
+
message: '客服连接凭证已失效,正在重新连接。',
|
|
781
|
+
retryMode: 'token',
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (code === 'ORGANIZATION_CONTEXT_REQUIRED') {
|
|
785
|
+
return {
|
|
786
|
+
code: 'ORGANIZATION_CONTEXT_REQUIRED',
|
|
787
|
+
message: '请选择企业/团队后再联系人工客服。',
|
|
788
|
+
retryMode: 'never',
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
if (code === 'INVALID_ARGUMENT') {
|
|
792
|
+
return {
|
|
793
|
+
code: 'PROTOCOL_ERROR',
|
|
794
|
+
message: '客服实时连接协议不受支持。',
|
|
795
|
+
retryMode: 'never',
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
if (
|
|
799
|
+
code === 'REALTIME_UNAVAILABLE' ||
|
|
800
|
+
code === 'KANBAN_AUTH_FAILED' ||
|
|
801
|
+
code.startsWith('SERVICE_')
|
|
802
|
+
) {
|
|
803
|
+
return {
|
|
804
|
+
code: 'REALTIME_UNAVAILABLE',
|
|
805
|
+
message: '客服实时鉴权服务暂不可用,正在重试。',
|
|
806
|
+
retryMode: 'availability',
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
if (
|
|
810
|
+
code === 'ACCESS_DENIED' ||
|
|
811
|
+
code === 'ORIGIN_NOT_ALLOWED' ||
|
|
812
|
+
code === 'CHANNEL_ACCESS_DENIED'
|
|
813
|
+
) {
|
|
814
|
+
return {
|
|
815
|
+
code: 'AUTHENTICATION_FAILED',
|
|
816
|
+
message: '当前身份无权使用人工客服。',
|
|
817
|
+
retryMode: 'never',
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return {
|
|
821
|
+
code: 'AUTHENTICATION_FAILED',
|
|
822
|
+
message: '人工客服实时连接认证失败。',
|
|
823
|
+
retryMode: 'never',
|
|
824
|
+
}
|
|
825
|
+
}
|