@adatechnology/conversations-ui 0.1.0 → 0.2.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.
Files changed (49) hide show
  1. package/dist/{ConversationSimulatorPanel--5fIzXWY.d.ts → ConversationSimulatorPanel-ikgmlciE.d.ts} +103 -1
  2. package/dist/{chunk-BJNRLLDO.js → chunk-NHQDQJL2.js} +769 -244
  3. package/dist/index.d.ts +304 -9
  4. package/dist/index.js +2166 -509
  5. package/dist/preview/index.d.ts +2 -2
  6. package/dist/preview/index.js +147 -1
  7. package/dist/styles.css +135 -0
  8. package/package.json +2 -2
  9. package/src/MessageComposer.test.tsx +14 -0
  10. package/src/MessageComposer.tsx +327 -73
  11. package/src/RichMessageComposer.test.tsx +22 -4
  12. package/src/RichMessageComposer.tsx +674 -370
  13. package/src/index.ts +28 -1
  14. package/src/preview/createMockConversationsApi.ts +184 -0
  15. package/src/providers/types.ts +34 -0
  16. package/src/quickReplies/AttachmentsFormSection.tsx +160 -0
  17. package/src/quickReplies/QuickRepliesPicker.test.tsx +114 -0
  18. package/src/quickReplies/QuickRepliesPicker.tsx +188 -0
  19. package/src/quickReplies/QuickRepliesWorkspace.test.tsx +32 -0
  20. package/src/quickReplies/QuickRepliesWorkspace.tsx +395 -0
  21. package/src/quickReplies/createUploadQueue.test.ts +106 -0
  22. package/src/quickReplies/createUploadQueue.ts +69 -0
  23. package/src/quickReplies/index.ts +5 -0
  24. package/src/quickReplies/labels.ts +124 -0
  25. package/src/quickReplies/quickReply.types.ts +74 -0
  26. package/src/quickReplies/quickReplyAttachmentUpload.test.ts +76 -0
  27. package/src/quickReplies/quickReplyAttachmentUpload.ts +81 -0
  28. package/src/quickReplies/quickReplyAttachments.test.ts +396 -0
  29. package/src/quickReplies/quickReplyAttachments.ts +289 -0
  30. package/src/quickReplies/quickReplySearch.test.ts +88 -0
  31. package/src/quickReplies/quickReplySearch.ts +104 -0
  32. package/src/quickReplies/quickReplyShortcut.test.ts +38 -0
  33. package/src/quickReplies/quickReplyShortcut.ts +46 -0
  34. package/src/quickReplies/resolveConversationVariables.test.ts +63 -0
  35. package/src/quickReplies/resolveConversationVariables.ts +41 -0
  36. package/src/quickReplies/useQuickRepliesPicker.test.ts +20 -0
  37. package/src/quickReplies/useQuickRepliesPicker.ts +121 -0
  38. package/src/quickReplies/useQuickRepliesWorkspace.test.ts +222 -0
  39. package/src/quickReplies/useQuickRepliesWorkspace.ts +426 -0
  40. package/src/quickReplies/useQuickReplyAttachmentUploads.ts +159 -0
  41. package/src/styles.css +87 -0
  42. package/src/workspace/ConversationPane.tsx +137 -73
  43. package/src/workspace/ConversationsWorkspace.tsx +16 -1
  44. package/src/workspace/QueuedAttachmentsList.test.ts +27 -0
  45. package/src/workspace/QueuedAttachmentsList.tsx +198 -0
  46. package/src/workspace/index.ts +1 -0
  47. package/src/workspace/labels.ts +14 -0
  48. package/src/workspace/useComposerAttachmentRetry.ts +155 -0
  49. package/src/workspace/useComposerQueue.ts +220 -0
@@ -0,0 +1,396 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import {
4
+ applySendResults,
5
+ attachmentKey,
6
+ canAddAttachments,
7
+ excludeRetryingItems,
8
+ orderOutgoingItems,
9
+ queuedAttachmentsFromQuickReply,
10
+ resolveIdempotencyKey,
11
+ resolveMaxAttachmentSizeBytes,
12
+ resolveRetryOutcome,
13
+ retryStoredAttachments,
14
+ sendQueuedMessage,
15
+ } from './quickReplyAttachments'
16
+ import type { QueuedAttachment } from './quickReply.types'
17
+
18
+ const LOCAL: QueuedAttachment = {
19
+ kind: 'local',
20
+ localId: 'local-1',
21
+ file: new File(['x'], 'local.pdf', { type: 'application/pdf' }),
22
+ }
23
+ const FIRST: QueuedAttachment = {
24
+ kind: 'stored',
25
+ uploadId: 'a',
26
+ filename: 'a.pdf',
27
+ mimeType: 'application/pdf',
28
+ sizeBytes: 1,
29
+ }
30
+ const SECOND: QueuedAttachment = {
31
+ kind: 'stored',
32
+ uploadId: 'b',
33
+ filename: 'b.png',
34
+ mimeType: 'image/png',
35
+ sizeBytes: 1,
36
+ }
37
+
38
+ describe('orderOutgoingItems', () => {
39
+ it('põe guardados na ordem do cadastro antes dos locais', () => {
40
+ const items = orderOutgoingItems('oi', [LOCAL, FIRST, SECOND])
41
+ expect(items.text).toBe('oi')
42
+ expect(items.attachments).toEqual([FIRST, SECOND, LOCAL])
43
+ })
44
+ })
45
+
46
+ describe('applySendResults', () => {
47
+ it('mantém só o que não foi enviado', () => {
48
+ const queue = applySendResults(
49
+ [FIRST, SECOND, LOCAL],
50
+ [
51
+ { uploadId: 'a', status: 'sent' },
52
+ { uploadId: 'b', status: 'failed', errorCode: 'X' },
53
+ ],
54
+ )
55
+ expect(queue).toEqual([SECOND, LOCAL])
56
+ })
57
+
58
+ it('mantém o pulado', () => {
59
+ expect(applySendResults([FIRST], [{ uploadId: 'a', status: 'skipped' }])).toEqual([FIRST])
60
+ })
61
+ })
62
+
63
+ describe('onAttachmentStatus: pulado distinto de falhou', () => {
64
+ it('sendQueuedMessage reporta skipped, não failed, para um item pulado no lote', async () => {
65
+ const statuses: { key: string; status: string }[] = []
66
+ await sendQueuedMessage({
67
+ text: '',
68
+ queue: [FIRST, SECOND],
69
+ idempotencyKey: 'k1',
70
+ sendText: async () => true,
71
+ sendStoredAttachments: async () => ({
72
+ results: [
73
+ { uploadId: 'a', status: 'failed', errorCode: 'UPLOAD_FAILED' },
74
+ { uploadId: 'b', status: 'skipped' },
75
+ ],
76
+ }),
77
+ onAttachmentStatus: (key, status) => statuses.push({ key, status }),
78
+ })
79
+ expect(statuses).toContainEqual({ key: 'a', status: 'failed' })
80
+ expect(statuses).toContainEqual({ key: 'b', status: 'skipped' })
81
+ })
82
+
83
+ it('retryStoredAttachments reporta skipped, não failed, para o item pulado', async () => {
84
+ const statuses: { key: string; status: string }[] = []
85
+ await retryStoredAttachments({
86
+ queue: [FIRST],
87
+ uploadIds: ['a'],
88
+ idempotencyKey: 'k',
89
+ sendStoredAttachments: async () => ({ results: [{ uploadId: 'a', status: 'skipped' }] }),
90
+ onAttachmentStatus: (key, status) => statuses.push({ key, status }),
91
+ })
92
+ expect(statuses).toEqual([
93
+ { key: 'a', status: 'sending' },
94
+ { key: 'a', status: 'skipped' },
95
+ ])
96
+ })
97
+ })
98
+
99
+ describe('canAddAttachments', () => {
100
+ it('aceita até o teto e recusa acima', () => {
101
+ expect(canAddAttachments(8, 2)).toBe(true)
102
+ expect(canAddAttachments(9, 2)).toBe(false)
103
+ })
104
+ })
105
+
106
+ describe('resolveMaxAttachmentSizeBytes', () => {
107
+ it('usa o teto por tipo', () => {
108
+ expect(resolveMaxAttachmentSizeBytes('image/png')).toBe(5 * 1024 * 1024)
109
+ expect(resolveMaxAttachmentSizeBytes('audio/ogg')).toBe(16 * 1024 * 1024)
110
+ expect(resolveMaxAttachmentSizeBytes('video/mp4')).toBe(16 * 1024 * 1024)
111
+ expect(resolveMaxAttachmentSizeBytes('application/pdf')).toBe(100 * 1024 * 1024)
112
+ })
113
+
114
+ it('aceita limites do host', () => {
115
+ expect(resolveMaxAttachmentSizeBytes('image/png', { document: 1, image: 2, audio: 3, video: 4 })).toBe(2)
116
+ })
117
+ })
118
+
119
+ describe('sendQueuedMessage', () => {
120
+ it('manda texto, depois guardados, depois locais, e esvazia a fila quando tudo sai', async () => {
121
+ const order: string[] = []
122
+ const result = await sendQueuedMessage({
123
+ text: 'oi',
124
+ queue: [LOCAL, FIRST, SECOND],
125
+ idempotencyKey: 'k1',
126
+ sendText: async (text) => {
127
+ order.push(`text:${text}`)
128
+ return true
129
+ },
130
+ sendStoredAttachments: async ({ uploadIds, idempotencyKey }) => {
131
+ order.push(`stored:${uploadIds.join(',')}:${idempotencyKey}`)
132
+ return { results: uploadIds.map((uploadId) => ({ uploadId, status: 'sent' as const })) }
133
+ },
134
+ sendLocalAttachments: async (files) => {
135
+ order.push(`local:${files.map((file) => file.name).join(',')}`)
136
+ },
137
+ })
138
+ expect(order).toEqual(['text:oi', 'stored:a,b:k1', 'local:local.pdf'])
139
+ expect(result.textSent).toBe(true)
140
+ expect(result.remainingQueue).toEqual([])
141
+ // sentAttachmentKeys deixa o chamador remover por chave de um estado corrente em vez de
142
+ // sobrescrever com remainingQueue (calculado sobre a fila capturada antes do await) — o item
143
+ // adicionado durante o envio (MEDIUM 1) não pode se perder nessa troca.
144
+ expect([...result.sentAttachmentKeys].sort()).toEqual(['a', 'b', 'local-1'])
145
+ })
146
+
147
+ it('não manda anexo nenhum quando o texto falha (QR-43)', async () => {
148
+ let storedCalled = false
149
+ let localCalled = false
150
+ const result = await sendQueuedMessage({
151
+ text: 'oi',
152
+ queue: [FIRST, LOCAL],
153
+ idempotencyKey: 'k1',
154
+ sendText: async () => false,
155
+ sendStoredAttachments: async () => {
156
+ storedCalled = true
157
+ return { results: [] }
158
+ },
159
+ sendLocalAttachments: async () => {
160
+ localCalled = true
161
+ },
162
+ })
163
+ expect(storedCalled).toBe(false)
164
+ expect(localCalled).toBe(false)
165
+ expect(result.textSent).toBe(false)
166
+ expect(result.remainingQueue).toEqual([FIRST, LOCAL])
167
+ })
168
+
169
+ it('mantém na fila só o que falhou ou não tem porta (QR-37)', async () => {
170
+ const result = await sendQueuedMessage({
171
+ text: '',
172
+ queue: [FIRST, SECOND, LOCAL],
173
+ idempotencyKey: 'k1',
174
+ sendText: async () => true,
175
+ sendStoredAttachments: async () => ({
176
+ results: [
177
+ { uploadId: 'a', status: 'sent' },
178
+ { uploadId: 'b', status: 'failed', errorCode: 'X' },
179
+ ],
180
+ }),
181
+ })
182
+ // sem sendLocalAttachments, o item local não sai e continua na fila
183
+ expect(result.remainingQueue).toEqual([SECOND, LOCAL])
184
+ })
185
+
186
+ it('marca todo guardado como falha quando o lote lança (H3)', async () => {
187
+ const result = await sendQueuedMessage({
188
+ text: '',
189
+ queue: [FIRST, SECOND],
190
+ idempotencyKey: 'k1',
191
+ sendText: async () => true,
192
+ sendStoredAttachments: async () => {
193
+ throw new Error('rede caiu')
194
+ },
195
+ })
196
+ expect(result.textSent).toBe(true)
197
+ expect(result.remainingQueue).toEqual([FIRST, SECOND])
198
+ })
199
+
200
+ it('trata guardado ausente do resultado do lote como falha (M5)', async () => {
201
+ const result = await sendQueuedMessage({
202
+ text: '',
203
+ queue: [FIRST, SECOND],
204
+ idempotencyKey: 'k1',
205
+ sendText: async () => true,
206
+ sendStoredAttachments: async () => ({ results: [{ uploadId: 'a', status: 'sent' as const }] }),
207
+ })
208
+ expect(result.remainingQueue).toEqual([SECOND])
209
+ })
210
+
211
+ it('reusa a mesma chave de idempotência ao reenviar o que sobrou', async () => {
212
+ const keys: string[] = []
213
+ await sendQueuedMessage({
214
+ text: '',
215
+ queue: [FIRST],
216
+ idempotencyKey: 'retry-key',
217
+ sendText: async () => true,
218
+ sendStoredAttachments: async ({ idempotencyKey }) => {
219
+ keys.push(idempotencyKey)
220
+ return { results: [{ uploadId: 'a', status: 'failed' as const }] }
221
+ },
222
+ })
223
+ await sendQueuedMessage({
224
+ text: '',
225
+ queue: [FIRST],
226
+ idempotencyKey: 'retry-key',
227
+ sendText: async () => true,
228
+ sendStoredAttachments: async ({ idempotencyKey }) => {
229
+ keys.push(idempotencyKey)
230
+ return { results: [{ uploadId: 'a', status: 'sent' as const }] }
231
+ },
232
+ })
233
+ expect(keys).toEqual(['retry-key', 'retry-key'])
234
+ })
235
+ })
236
+
237
+ describe('resolveIdempotencyKey', () => {
238
+ it('gera chave nova sem estado anterior', () => {
239
+ const state = resolveIdempotencyKey(undefined, ['a', 'b'], () => 'new-key')
240
+ expect(state).toEqual({ key: 'new-key', uploadIds: ['a', 'b'] })
241
+ })
242
+
243
+ it('reusa a chave quando o conjunto ordenado de uploadIds não muda', () => {
244
+ const previous = { key: 'k1', uploadIds: ['a', 'b'] }
245
+ const state = resolveIdempotencyKey(previous, ['a', 'b'], () => 'should-not-be-used')
246
+ expect(state).toBe(previous)
247
+ })
248
+
249
+ it('gera chave nova quando um uploadId entra ou sai', () => {
250
+ const previous = { key: 'k1', uploadIds: ['a', 'b'] }
251
+ expect(resolveIdempotencyKey(previous, ['a'], () => 'k2')).toEqual({ key: 'k2', uploadIds: ['a'] })
252
+ expect(resolveIdempotencyKey(previous, ['a', 'b', 'c'], () => 'k3')).toEqual({
253
+ key: 'k3',
254
+ uploadIds: ['a', 'b', 'c'],
255
+ })
256
+ })
257
+
258
+ it('gera chave nova quando a ordem muda, mesmo com o mesmo conjunto', () => {
259
+ const previous = { key: 'k1', uploadIds: ['a', 'b'] }
260
+ expect(resolveIdempotencyKey(previous, ['b', 'a'], () => 'k2')).toEqual({ key: 'k2', uploadIds: ['b', 'a'] })
261
+ })
262
+ })
263
+
264
+ describe('retryStoredAttachments', () => {
265
+ it('reenvia só o uploadId pedido, sem tocar no resto da fila', async () => {
266
+ const calls: { uploadIds: readonly string[]; idempotencyKey: string }[] = []
267
+ const result = await retryStoredAttachments({
268
+ queue: [FIRST, SECOND, LOCAL],
269
+ uploadIds: ['b'],
270
+ idempotencyKey: 'retry-b',
271
+ sendStoredAttachments: async ({ uploadIds, idempotencyKey }) => {
272
+ calls.push({ uploadIds, idempotencyKey })
273
+ return { results: [{ uploadId: 'b', status: 'sent' as const }] }
274
+ },
275
+ })
276
+ expect(calls).toEqual([{ uploadIds: ['b'], idempotencyKey: 'retry-b' }])
277
+ expect(result.remainingQueue).toEqual([FIRST, LOCAL])
278
+ expect(result.sentAttachmentKeys).toEqual(['b'])
279
+ })
280
+
281
+ it('sentAttachmentKeys vem vazio quando o reenvio falha (nada para remover por chave)', async () => {
282
+ const result = await retryStoredAttachments({
283
+ queue: [FIRST],
284
+ uploadIds: ['a'],
285
+ idempotencyKey: 'k',
286
+ sendStoredAttachments: async () => ({ results: [{ uploadId: 'a', status: 'failed' as const }] }),
287
+ })
288
+ expect(result.sentAttachmentKeys).toEqual([])
289
+ })
290
+
291
+ it('sem item correspondente na fila, não chama a porta e devolve a fila intacta', async () => {
292
+ let called = false
293
+ const result = await retryStoredAttachments({
294
+ queue: [FIRST],
295
+ uploadIds: ['nao-existe'],
296
+ idempotencyKey: 'k',
297
+ sendStoredAttachments: async () => {
298
+ called = true
299
+ return { results: [] }
300
+ },
301
+ })
302
+ expect(called).toBe(false)
303
+ expect(result.remainingQueue).toEqual([FIRST])
304
+ })
305
+
306
+ it('mantém o item na fila quando o reenvio falha', async () => {
307
+ const result = await retryStoredAttachments({
308
+ queue: [FIRST],
309
+ uploadIds: ['a'],
310
+ idempotencyKey: 'k',
311
+ sendStoredAttachments: async () => {
312
+ throw new Error('rede caiu')
313
+ },
314
+ })
315
+ expect(result.remainingQueue).toEqual([FIRST])
316
+ })
317
+ })
318
+
319
+ describe('queuedAttachmentsFromQuickReply', () => {
320
+ const quickReplyWithAttachments = {
321
+ attachments: [
322
+ { uploadId: 'a', filename: 'a.pdf', mimeType: 'application/pdf', sizeBytes: 1 },
323
+ { uploadId: 'b', filename: 'b.png', mimeType: 'image/png', sizeBytes: 2 },
324
+ ],
325
+ }
326
+
327
+ it('empurra os anexos como itens guardados quando o host sabe mandar (QR-32)', () => {
328
+ expect(queuedAttachmentsFromQuickReply(quickReplyWithAttachments, true)).toEqual([
329
+ { kind: 'stored', uploadId: 'a', filename: 'a.pdf', mimeType: 'application/pdf', sizeBytes: 1 },
330
+ { kind: 'stored', uploadId: 'b', filename: 'b.png', mimeType: 'image/png', sizeBytes: 2 },
331
+ ])
332
+ })
333
+
334
+ it('não empurra nada sem a porta (QR-33)', () => {
335
+ expect(queuedAttachmentsFromQuickReply(quickReplyWithAttachments, false)).toEqual([])
336
+ })
337
+
338
+ it('não empurra nada quando a mensagem não tem anexo', () => {
339
+ expect(queuedAttachmentsFromQuickReply({ attachments: [] }, true)).toEqual([])
340
+ expect(queuedAttachmentsFromQuickReply({ attachments: undefined }, true)).toEqual([])
341
+ })
342
+ })
343
+
344
+ describe('attachmentKey', () => {
345
+ it('usa o uploadId para guardado e o localId para local', () => {
346
+ expect(attachmentKey(FIRST)).toBe('a')
347
+ expect(attachmentKey(LOCAL)).toBe('local-1')
348
+ })
349
+
350
+ it('distingue duas cópias do mesmo arquivo local pelo localId gerado, não pela identidade do File', () => {
351
+ const file = new File(['x'], 'same.pdf', { type: 'application/pdf' })
352
+ const first: QueuedAttachment = { kind: 'local', localId: 'local-a', file }
353
+ const second: QueuedAttachment = { kind: 'local', localId: 'local-b', file }
354
+ expect(attachmentKey(first)).not.toBe(attachmentKey(second))
355
+ })
356
+ })
357
+
358
+ describe('resolveRetryOutcome', () => {
359
+ it('conversa diferente da do retry devolve undefined, deixando a fila corrente intocada', () => {
360
+ const outcome = resolveRetryOutcome({
361
+ conversationIdAtRetry: 'conversation-1',
362
+ currentConversationId: 'conversation-2',
363
+ sentAttachmentKeys: ['a'],
364
+ queue: [],
365
+ })
366
+ expect(outcome).toBeUndefined()
367
+ })
368
+
369
+ it('mesma conversa: tira só as chaves enviadas e mantém item adicionado durante o retry', () => {
370
+ const addedMidRetry: QueuedAttachment = {
371
+ kind: 'stored',
372
+ uploadId: 'c',
373
+ filename: 'c.pdf',
374
+ mimeType: 'application/pdf',
375
+ sizeBytes: 1,
376
+ }
377
+ const outcome = resolveRetryOutcome({
378
+ conversationIdAtRetry: 'conversation-1',
379
+ currentConversationId: 'conversation-1',
380
+ sentAttachmentKeys: ['a'],
381
+ queue: [FIRST, SECOND, addedMidRetry],
382
+ })
383
+ expect(outcome).toEqual([SECOND, addedMidRetry])
384
+ })
385
+ })
386
+
387
+ describe('excludeRetryingItems', () => {
388
+ it('sem chaves em retry devolve a fila como veio', () => {
389
+ expect(excludeRetryingItems([FIRST, LOCAL], new Set())).toEqual([FIRST, LOCAL])
390
+ })
391
+
392
+ it('tira só os itens cuja chave está em retry avulso', () => {
393
+ const result = excludeRetryingItems([FIRST, SECOND, LOCAL], new Set(['b']))
394
+ expect(result).toEqual([FIRST, LOCAL])
395
+ })
396
+ })
@@ -0,0 +1,289 @@
1
+ import { QUICK_REPLY_ATTACHMENT_LIMIT } from './quickReply.types'
2
+ import type { QueuedAttachment, QuickReply, StoredAttachmentSendResult } from './quickReply.types'
3
+
4
+ /**
5
+ * Anexos de uma mensagem pronta escolhida no picker, prontos para entrar na fila como `stored`
6
+ * (QR-32). Vazio sem `hasAttachmentsCapability` — sem `sendStoredAttachments` no host, empurrar o
7
+ * item só encalharia na fila sem jeito de sair; a linha do picker já avisou disso antes do clique.
8
+ */
9
+ export function queuedAttachmentsFromQuickReply(
10
+ quickReply: Pick<QuickReply, 'attachments'>,
11
+ hasAttachmentsCapability: boolean,
12
+ ): readonly QueuedAttachment[] {
13
+ if (!hasAttachmentsCapability || !quickReply.attachments?.length) return []
14
+ return quickReply.attachments.map(
15
+ (attachment): QueuedAttachment => ({
16
+ kind: 'stored',
17
+ uploadId: attachment.uploadId,
18
+ filename: attachment.filename,
19
+ mimeType: attachment.mimeType,
20
+ sizeBytes: attachment.sizeBytes,
21
+ }),
22
+ )
23
+ }
24
+
25
+ /**
26
+ * Chave estável do item na fila — `uploadId` para guardado, `localId` gerado para local. A
27
+ * identidade do `File` (nome+tamanho+data) não bastava: duas cópias do mesmo arquivo colidiam na
28
+ * mesma chave e removê-la de uma removia as duas.
29
+ */
30
+ export function attachmentKey(item: QueuedAttachment): string {
31
+ return item.kind === 'stored' ? item.uploadId : item.localId
32
+ }
33
+
34
+ export type AttachmentSendStatus = 'waiting' | 'sending' | 'sent' | 'failed' | 'skipped'
35
+
36
+ /** Espelha os tetos da API; o host sobrescreve quando o backend dele aceita outro tamanho. */
37
+ export const DEFAULT_MAX_ATTACHMENT_SIZE_BYTES = {
38
+ document: 100 * 1024 * 1024,
39
+ image: 5 * 1024 * 1024,
40
+ audio: 16 * 1024 * 1024,
41
+ video: 16 * 1024 * 1024,
42
+ } as const
43
+
44
+ export type MaxAttachmentSizeBytes = {
45
+ readonly document: number
46
+ readonly image: number
47
+ readonly audio: number
48
+ readonly video: number
49
+ }
50
+
51
+ export type OutgoingItems = {
52
+ readonly text: string
53
+ readonly attachments: readonly QueuedAttachment[]
54
+ }
55
+
56
+ /**
57
+ * Texto primeiro, depois os guardados na ordem do cadastro, por último os locais: o cliente lê a
58
+ * explicação antes do arquivo, e o que o atendente anexou na hora é complemento do roteiro.
59
+ */
60
+ export function orderOutgoingItems(text: string, queue: readonly QueuedAttachment[]): OutgoingItems {
61
+ const stored = queue.filter((item) => item.kind === 'stored')
62
+ const local = queue.filter((item) => item.kind === 'local')
63
+ return { text, attachments: [...stored, ...local] }
64
+ }
65
+
66
+ /** Tira da fila só o que foi enviado; falha e pulado ficam para o atendente tentar de novo. */
67
+ export function applySendResults(
68
+ queue: readonly QueuedAttachment[],
69
+ results: readonly StoredAttachmentSendResult[],
70
+ ): readonly QueuedAttachment[] {
71
+ const sentUploadIds = new Set(results.filter((result) => result.status === 'sent').map((result) => result.uploadId))
72
+ return queue.filter((item) => item.kind === 'local' || !sentUploadIds.has(item.uploadId))
73
+ }
74
+
75
+ export function canAddAttachments(current: number, adding: number): boolean {
76
+ return current + adding <= QUICK_REPLY_ATTACHMENT_LIMIT
77
+ }
78
+
79
+ export function resolveMaxAttachmentSizeBytes(
80
+ mimeType: string,
81
+ limits: MaxAttachmentSizeBytes = DEFAULT_MAX_ATTACHMENT_SIZE_BYTES,
82
+ ): number {
83
+ if (mimeType.startsWith('image/')) return limits.image
84
+ if (mimeType.startsWith('audio/')) return limits.audio
85
+ if (mimeType.startsWith('video/')) return limits.video
86
+ return limits.document
87
+ }
88
+
89
+ export type IdempotencyKeyState = {
90
+ readonly key: string
91
+ /** Conjunto ORDENADO de `uploadId` desta tentativa — a chave só sobrevive enquanto for igual. */
92
+ readonly uploadIds: readonly string[]
93
+ }
94
+
95
+ /**
96
+ * Decide se a chave de idempotência de `previous` ainda serve (M3): serve quando o conjunto
97
+ * ORDENADO de `uploadIds` não mudou desde a tentativa anterior. Mudou — um anexo saiu, entrou, ou
98
+ * trocou de posição — vira uma tentativa diferente perante o backend, e precisa de chave nova; a
99
+ * mesma chave reenviaria o lote antigo como se fosse o novo (ou o servidor recusaria por conflito).
100
+ */
101
+ export function resolveIdempotencyKey(
102
+ previous: IdempotencyKeyState | undefined,
103
+ uploadIds: readonly string[],
104
+ generateKey: () => string = () => crypto.randomUUID(),
105
+ ): IdempotencyKeyState {
106
+ if (previous && sameUploadIds(previous.uploadIds, uploadIds)) return previous
107
+ return { key: generateKey(), uploadIds }
108
+ }
109
+
110
+ function sameUploadIds(a: readonly string[], b: readonly string[]): boolean {
111
+ if (a.length !== b.length) return false
112
+ return a.every((id, index) => id === b[index])
113
+ }
114
+
115
+ export type SendQueuedMessageParams = {
116
+ readonly text: string
117
+ readonly queue: readonly QueuedAttachment[]
118
+ /** Uma por clique, reusada em cada tentativa de reenvio do que sobrou (QR-38). */
119
+ readonly idempotencyKey: string
120
+ /** Devolve se o texto saiu — falso interrompe o pipeline antes de qualquer anexo (QR-43). */
121
+ readonly sendText: (text: string) => Promise<boolean>
122
+ /** Ausente, itens `stored` continuam na fila — o produto não sabe mandar por referência. */
123
+ readonly sendStoredAttachments?: (params: {
124
+ uploadIds: readonly string[]
125
+ idempotencyKey: string
126
+ }) => Promise<{ results: readonly StoredAttachmentSendResult[] }>
127
+ /** Ausente, itens `local` continuam na fila — o mesmo comportamento de hoje sem a porta. */
128
+ readonly sendLocalAttachments?: (files: readonly File[]) => Promise<void>
129
+ readonly onAttachmentStatus?: (key: string, status: AttachmentSendStatus) => void
130
+ }
131
+
132
+ export type SendQueuedMessageResult = {
133
+ readonly textSent: boolean
134
+ /** O que não foi enviado — falha, pulado ou sem porta — para o atendente tentar de novo. */
135
+ readonly remainingQueue: readonly QueuedAttachment[]
136
+ /** Chaves (`attachmentKey`) dos itens que saíram — para o chamador remover por chave de um estado
137
+ * corrente, em vez de sobrescrever a fila com este `remainingQueue` (calculado sobre uma fila
138
+ * capturada antes do `await`, que já pode estar desatualizada). */
139
+ readonly sentAttachmentKeys: readonly string[]
140
+ }
141
+
142
+ type SendStoredAttachmentsPort = NonNullable<SendQueuedMessageParams['sendStoredAttachments']>
143
+ type StoredQueuedAttachment = Extract<QueuedAttachment, { kind: 'stored' }>
144
+
145
+ /** Traduz o status do resultado do servidor para o status visual do item na fila. */
146
+ function attachmentSendStatusOf(status: StoredAttachmentSendResult['status']): AttachmentSendStatus {
147
+ if (status === 'sent') return 'sent'
148
+ if (status === 'skipped') return 'skipped'
149
+ return 'failed'
150
+ }
151
+
152
+ /**
153
+ * Manda um lote de itens `stored` e traduz a resposta em status por item — usado tanto pelo envio
154
+ * normal quanto pelo retry avulso (M5): guardado sem resultado no lote é tratado como falha, e o
155
+ * lote inteiro falhando (rede, 500) marca cada item como falha em vez de sumir da tela sem explicação.
156
+ * `skipped` (o servidor parou antes de tentar este arquivo, por causa de uma falha anterior no
157
+ * mesmo lote) fica distinto de `failed` — o item não falhou, só não chegou a ser tentado.
158
+ */
159
+ async function sendStoredBatch(
160
+ stored: readonly StoredQueuedAttachment[],
161
+ idempotencyKey: string,
162
+ sendStoredAttachments: SendStoredAttachmentsPort,
163
+ onAttachmentStatus?: (key: string, status: AttachmentSendStatus) => void,
164
+ ): Promise<readonly StoredAttachmentSendResult[]> {
165
+ for (const item of stored) onAttachmentStatus?.(attachmentKey(item), 'sending')
166
+ try {
167
+ const response = await sendStoredAttachments({ uploadIds: stored.map((item) => item.uploadId), idempotencyKey })
168
+ let results = response.results
169
+ const resultedUploadIds = new Set(results.map((result) => result.uploadId))
170
+ for (const item of stored) {
171
+ if (!resultedUploadIds.has(item.uploadId)) results = [...results, { uploadId: item.uploadId, status: 'failed' }]
172
+ }
173
+ for (const result of results) {
174
+ onAttachmentStatus?.(result.uploadId, attachmentSendStatusOf(result.status))
175
+ }
176
+ return results
177
+ } catch {
178
+ for (const item of stored) onAttachmentStatus?.(attachmentKey(item), 'failed')
179
+ return stored.map((item) => ({ uploadId: item.uploadId, status: 'failed' as const }))
180
+ }
181
+ }
182
+
183
+ export type RetryStoredAttachmentsParams = {
184
+ readonly queue: readonly QueuedAttachment[]
185
+ /** Só este subconjunto é reenviado — o resto da fila (texto já mandado) fica intocado. */
186
+ readonly uploadIds: readonly string[]
187
+ readonly idempotencyKey: string
188
+ readonly sendStoredAttachments: SendStoredAttachmentsPort
189
+ readonly onAttachmentStatus?: (key: string, status: AttachmentSendStatus) => void
190
+ }
191
+
192
+ export type RetryStoredAttachmentsResult = {
193
+ readonly remainingQueue: readonly QueuedAttachment[]
194
+ /** Chaves (`uploadId`) dos itens que saíram — ver `SendQueuedMessageResult.sentAttachmentKeys`. */
195
+ readonly sentAttachmentKeys: readonly string[]
196
+ }
197
+
198
+ /**
199
+ * Reenvia só os `stored` de `uploadIds` — nunca o texto do rascunho (M3): o botão "Tentar de novo"
200
+ * de um item é sobre aquele anexo, não sobre a mensagem inteira que já foi lida ou já saiu.
201
+ */
202
+ export async function retryStoredAttachments(
203
+ params: RetryStoredAttachmentsParams,
204
+ ): Promise<RetryStoredAttachmentsResult> {
205
+ const { queue, uploadIds, idempotencyKey, sendStoredAttachments, onAttachmentStatus } = params
206
+ const targetIds = new Set(uploadIds)
207
+ const targets = queue.filter(
208
+ (item): item is StoredQueuedAttachment => item.kind === 'stored' && targetIds.has(item.uploadId),
209
+ )
210
+ if (targets.length === 0) return { remainingQueue: queue, sentAttachmentKeys: [] }
211
+ const results = await sendStoredBatch(targets, idempotencyKey, sendStoredAttachments, onAttachmentStatus)
212
+ const sentAttachmentKeys = results.filter((result) => result.status === 'sent').map((result) => result.uploadId)
213
+ return { remainingQueue: applySendResults(queue, results), sentAttachmentKeys }
214
+ }
215
+
216
+ export type ResolveRetryOutcomeParams = {
217
+ readonly conversationIdAtRetry: string
218
+ readonly currentConversationId: string
219
+ readonly sentAttachmentKeys: readonly string[]
220
+ readonly queue: readonly QueuedAttachment[]
221
+ }
222
+
223
+ /**
224
+ * Decide o que gravar na fila depois de um retry avulso resolver (H2/M5): `undefined` quando a
225
+ * conversa trocou no meio do retry — o resultado é de outra thread e o chamador deve ignorá-lo,
226
+ * mantendo a fila corrente intocada. Na mesma conversa, tira só as chaves enviadas — nunca
227
+ * sobrescreve com uma fila capturada antes do `await`, que perderia item adicionado durante o envio.
228
+ */
229
+ export function resolveRetryOutcome(params: ResolveRetryOutcomeParams): readonly QueuedAttachment[] | undefined {
230
+ const { conversationIdAtRetry, currentConversationId, sentAttachmentKeys, queue } = params
231
+ if (conversationIdAtRetry !== currentConversationId) return undefined
232
+ const sentKeys = new Set(sentAttachmentKeys)
233
+ return queue.filter((item) => !sentKeys.has(attachmentKey(item)))
234
+ }
235
+
236
+ /** Tira da fila de um envio completo os itens com retry avulso em voo (M-retry-race): a chave de
237
+ * idempotência do retry é outra, e mandar o mesmo item nos dois pipelines ao mesmo tempo deixa o
238
+ * servidor sem jeito de deduplicar. */
239
+ export function excludeRetryingItems(
240
+ queue: readonly QueuedAttachment[],
241
+ retryingKeys: ReadonlySet<string>,
242
+ ): readonly QueuedAttachment[] {
243
+ if (retryingKeys.size === 0) return queue
244
+ return queue.filter((item) => !retryingKeys.has(attachmentKey(item)))
245
+ }
246
+
247
+ /**
248
+ * Orquestra QR-34/QR-37/QR-43: texto primeiro; se falhar, nada de anexo sai. Depois os `stored` (em
249
+ * lote, um resultado por arquivo) e por último os `local` (sem legenda — o texto já foi mandado).
250
+ * Pura para ser testável sem montar componente: os efeitos colaterais são só as três portas recebidas.
251
+ */
252
+ export async function sendQueuedMessage(params: SendQueuedMessageParams): Promise<SendQueuedMessageResult> {
253
+ const { text, queue, idempotencyKey, sendText, sendStoredAttachments, sendLocalAttachments, onAttachmentStatus } =
254
+ params
255
+
256
+ if (text.trim()) {
257
+ const textSent = await sendText(text)
258
+ if (!textSent) return { textSent: false, remainingQueue: queue, sentAttachmentKeys: [] }
259
+ }
260
+
261
+ const { attachments } = orderOutgoingItems(text, queue)
262
+ const stored = attachments.filter((item): item is StoredQueuedAttachment => item.kind === 'stored')
263
+ const local = attachments.filter(
264
+ (item): item is Extract<QueuedAttachment, { kind: 'local' }> => item.kind === 'local',
265
+ )
266
+
267
+ const results =
268
+ stored.length > 0 && sendStoredAttachments
269
+ ? await sendStoredBatch(stored, idempotencyKey, sendStoredAttachments, onAttachmentStatus)
270
+ : []
271
+
272
+ let remainingQueue = applySendResults(queue, results)
273
+ const sentAttachmentKeys = results.filter((result) => result.status === 'sent').map((result) => result.uploadId)
274
+
275
+ if (local.length > 0 && sendLocalAttachments) {
276
+ for (const item of local) onAttachmentStatus?.(attachmentKey(item), 'sending')
277
+ try {
278
+ await sendLocalAttachments(local.map((item) => item.file))
279
+ for (const item of local) onAttachmentStatus?.(attachmentKey(item), 'sent')
280
+ const sentKeys = new Set(local.map((item) => attachmentKey(item)))
281
+ remainingQueue = remainingQueue.filter((item) => item.kind !== 'local' || !sentKeys.has(attachmentKey(item)))
282
+ sentAttachmentKeys.push(...sentKeys)
283
+ } catch {
284
+ for (const item of local) onAttachmentStatus?.(attachmentKey(item), 'failed')
285
+ }
286
+ }
287
+
288
+ return { textSent: true, remainingQueue, sentAttachmentKeys }
289
+ }