@whitewall/blip-sdk 0.0.191 → 0.0.193

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 (42) hide show
  1. package/dist/cjs/client.js.map +1 -1
  2. package/dist/cjs/index.js +2 -0
  3. package/dist/cjs/index.js.map +1 -1
  4. package/dist/cjs/types/flow.js +46 -8
  5. package/dist/cjs/types/flow.js.map +1 -1
  6. package/dist/cjs/utils/builder.js +34 -34
  7. package/dist/cjs/utils/builder.js.map +1 -1
  8. package/dist/cjs/utils/flow-rules.js +304 -0
  9. package/dist/cjs/utils/flow-rules.js.map +1 -0
  10. package/dist/cjs/utils/minifier.js +410 -0
  11. package/dist/cjs/utils/minifier.js.map +1 -0
  12. package/dist/esm/client.js.map +1 -1
  13. package/dist/esm/index.js +2 -0
  14. package/dist/esm/index.js.map +1 -1
  15. package/dist/esm/types/flow.js +40 -6
  16. package/dist/esm/types/flow.js.map +1 -1
  17. package/dist/esm/utils/builder.js +31 -30
  18. package/dist/esm/utils/builder.js.map +1 -1
  19. package/dist/esm/utils/flow-rules.js +297 -0
  20. package/dist/esm/utils/flow-rules.js.map +1 -0
  21. package/dist/esm/utils/minifier.js +405 -0
  22. package/dist/esm/utils/minifier.js.map +1 -0
  23. package/dist/types/client.d.ts +2 -1
  24. package/dist/types/client.d.ts.map +1 -1
  25. package/dist/types/index.d.ts +2 -0
  26. package/dist/types/index.d.ts.map +1 -1
  27. package/dist/types/namespaces/account.d.ts +20 -20
  28. package/dist/types/types/flow.d.ts +286 -96
  29. package/dist/types/types/flow.d.ts.map +1 -1
  30. package/dist/types/utils/builder.d.ts +33 -30
  31. package/dist/types/utils/builder.d.ts.map +1 -1
  32. package/dist/types/utils/flow-rules.d.ts +50 -0
  33. package/dist/types/utils/flow-rules.d.ts.map +1 -0
  34. package/dist/types/utils/minifier.d.ts +2857 -0
  35. package/dist/types/utils/minifier.d.ts.map +1 -0
  36. package/package.json +1 -1
  37. package/src/client.ts +2 -1
  38. package/src/index.ts +2 -0
  39. package/src/types/flow.ts +52 -6
  40. package/src/utils/builder.ts +61 -73
  41. package/src/utils/flow-rules.ts +453 -0
  42. package/src/utils/minifier.ts +505 -0
@@ -0,0 +1,505 @@
1
+ /**
2
+ * Round-trippable projection of a builder flow.
3
+ *
4
+ * Unlike the digest in `./builder.ts`, this keeps the `$`-prefixed keys and maps
5
+ * 1:1 onto the builder document: `minifyFlow` strips only the metadata the
6
+ * builder regenerates on its own, and `expandMinifiedFlow` rebuilds it, so a
7
+ * minified flow can be edited by hand and pushed back.
8
+ */
9
+ import { z } from 'zod'
10
+ import {
11
+ actionTypeSchema,
12
+ baseActionSchema,
13
+ type CONTENT_TYPES,
14
+ CUSTOM_ACTION_LISTS,
15
+ conditionOutputSchema,
16
+ defaultOutputSchema,
17
+ type Flow,
18
+ type InputState,
19
+ inputStateSchema,
20
+ isChatStateSendMessage,
21
+ type MIME_TYPES,
22
+ ROOT_STATE_ID,
23
+ type State,
24
+ stateSchema,
25
+ } from '../types/flow.ts'
26
+ import { checkFlow } from './flow-rules.ts'
27
+
28
+ /**
29
+ * Everything `minifyFlow` removes, grouped by where it lives. Together these
30
+ * describe the same subset as `minifiedStateSchema`, so the two must be kept in
31
+ * sync. The lists deliberately differ from each other: a card only exists on a
32
+ * content action, and only a content action carries a generated `settings.id`.
33
+ */
34
+ const STATE_NOISE = [
35
+ '$invalidContentActions',
36
+ '$invalidOutputs',
37
+ '$invalidCustomActions',
38
+ '$invalid',
39
+ 'id',
40
+ '$tags',
41
+ '$inputSuggestions',
42
+ 'isAiGenerated',
43
+ 'deskStateVersion',
44
+ ] as const
45
+
46
+ const CUSTOM_ACTION_NOISE = ['$id', '$title', '$invalid', '$typeOfContent'] as const
47
+
48
+ const CONTENT_ACTION_ITEM_NOISE = ['$invalid'] as const
49
+ const CONTENT_ACTION_NOISE = ['$id', '$typeOfContent', '$cardContent', '$inputSchema'] as const
50
+ const CONTENT_ACTION_SETTINGS_NOISE = ['id'] as const
51
+ const CONTENT_INPUT_NOISE = ['$cardContent', '$invalid'] as const
52
+
53
+ const CONDITION_OUTPUT_NOISE = [
54
+ '$id',
55
+ '$invalid',
56
+ '$isBuilderDefaultOutput',
57
+ '$connId',
58
+ '$isDeskOutput',
59
+ '$isDeskCustomOutput',
60
+ '$isDeskDefaultOutput',
61
+ '$contentId',
62
+ ] as const
63
+
64
+ const DEFAULT_OUTPUT_NOISE = ['$invalid'] as const
65
+
66
+ /**
67
+ * Pairs an incoming item with the one the builder already has, so its generated
68
+ * ids survive a push instead of being replaced on every write.
69
+ *
70
+ * A match is consumed, because two items in the same state can be identical and
71
+ * must not end up sharing an id.
72
+ */
73
+ function createStaleMatcher<T>(candidates: ReadonlyArray<T> | undefined, key: (item: T) => string) {
74
+ const byKey = new Map<string, Array<T>>()
75
+
76
+ for (const candidate of candidates ?? []) {
77
+ const candidateKey = key(candidate)
78
+ const bucket = byKey.get(candidateKey)
79
+
80
+ if (bucket) {
81
+ bucket.push(candidate)
82
+ } else {
83
+ byKey.set(candidateKey, [candidate])
84
+ }
85
+ }
86
+
87
+ return (item: T): T | undefined => byKey.get(key(item))?.shift()
88
+ }
89
+
90
+ // The generated `settings.id` is left out of the key since a minified action
91
+ // never carries one.
92
+ const actionKey = ({ type, settings, conditions }: { type: string; settings: unknown; conditions?: unknown }) => {
93
+ const { id: _id, ...rest } = (settings ?? {}) as Record<string, unknown>
94
+ return JSON.stringify([type, rest, conditions])
95
+ }
96
+
97
+ const conditionOutputKey = ({ stateId, conditions }: { stateId: string; conditions?: unknown }) =>
98
+ JSON.stringify([stateId, conditions])
99
+
100
+ // Only the part of the input a minified flow carries, so an unchanged input keeps
101
+ // the card the builder drew for it.
102
+ const inputKey = ({ $cardContent: _card, $invalid: _invalid, ...rest }: InputState) => JSON.stringify(rest)
103
+
104
+ function stripKeys(target: unknown, keys: ReadonlyArray<string>): void {
105
+ if (!target || typeof target !== 'object') {
106
+ return
107
+ }
108
+
109
+ for (const key of keys) {
110
+ delete (target as Record<string, unknown>)[key]
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Angular tags every item of an `ng-repeat`ed array with a `$$hashKey`, so the
116
+ * document the builder saves carries them on its condition outputs, on the
117
+ * conditions inside them and on its content actions. Nothing outside the editor
118
+ * reads them and the editor puts them back, but `minifiedStateSchema` is strict —
119
+ * so left in place they would report a freshly pulled flow as invalid.
120
+ *
121
+ * They are stripped wherever they appear rather than per location, because which
122
+ * arrays the editor happened to repeat over is not something to keep in sync here.
123
+ */
124
+ function stripAngularKeys(target: unknown): void {
125
+ if (Array.isArray(target)) {
126
+ for (const item of target) {
127
+ stripAngularKeys(item)
128
+ }
129
+
130
+ return
131
+ }
132
+
133
+ if (target && typeof target === 'object') {
134
+ delete (target as Record<string, unknown>).$$hashKey
135
+
136
+ for (const value of Object.values(target)) {
137
+ stripAngularKeys(value)
138
+ }
139
+ }
140
+ }
141
+
142
+ const minifiedActionSchema = z.intersection(baseActionSchema.partial({ $id: true }), actionTypeSchema)
143
+
144
+ const minifiedInputSchema = inputStateSchema.partial({ $cardContent: true, $invalid: true }).strict()
145
+
146
+ const minifiedContentActionItemSchema = z
147
+ .object({
148
+ action: minifiedActionSchema.optional(),
149
+ input: minifiedInputSchema.optional(),
150
+ })
151
+ .strict()
152
+
153
+ /**
154
+ * `stateSchema` minus the metadata `minifyFlow` strips. It is strict on purpose:
155
+ * minified flows are edited by hand, so an unknown key is almost always a typo
156
+ * that would otherwise be silently dropped on push.
157
+ */
158
+ export const minifiedStateSchema = stateSchema
159
+ .extend({
160
+ $enteringCustomActions: z.array(minifiedActionSchema),
161
+ $leavingCustomActions: z.array(minifiedActionSchema),
162
+ $localCustomActions: z.array(minifiedActionSchema).optional(),
163
+ $afterStateChangedActions: z.array(minifiedActionSchema).optional(),
164
+ $contentActions: z.array(minifiedContentActionItemSchema),
165
+ $conditionOutputs: z.array(conditionOutputSchema.strict()),
166
+ $defaultOutput: defaultOutputSchema.strict(),
167
+ })
168
+ .partial({
169
+ $tags: true,
170
+ $inputSuggestions: true,
171
+ $invalidContentActions: true,
172
+ $invalidOutputs: true,
173
+ $invalidCustomActions: true,
174
+ })
175
+ .strict()
176
+
177
+ /**
178
+ * A push replaces the whole flow, so everything that decides whether the runtime
179
+ * loads it — the entry point, the outputs, the loops, every condition and every
180
+ * action — is a validation error here instead of a bot that does not start.
181
+ *
182
+ * The rules run from this one check, and never per state or per action: zod skips
183
+ * a check on a value that already has issues, so nesting them would report the
184
+ * innermost problem and hide the rest of the flow's.
185
+ */
186
+ export const minifiedFlowSchema = z.record(z.string(), minifiedStateSchema).check((ctx) => {
187
+ checkFlow(ctx.value, (rule, path, message) => {
188
+ ctx.issues.push({ code: 'custom', input: ctx.value, path, message, params: { rule } })
189
+ })
190
+ })
191
+
192
+ export type MinifiedAction = z.infer<typeof minifiedActionSchema>
193
+ export type MinifiedInput = z.infer<typeof minifiedInputSchema>
194
+ export type MinifiedState = z.infer<typeof minifiedStateSchema>
195
+ export type MinifiedFlow = z.infer<typeof minifiedFlowSchema>
196
+
197
+ /**
198
+ * Strips everything the builder can regenerate, so what is left is the part of
199
+ * the flow worth reading and editing.
200
+ *
201
+ * The result is round-trippable through `expandMinifiedFlow` with one
202
+ * exception: typing indicators are discarded rather than preserved, so a custom
203
+ * typing interval is replaced by the default on the way back. See
204
+ * `createChatStateContentAction`.
205
+ */
206
+ export function minifyFlow(flow: Flow): MinifiedFlow {
207
+ const minified: MinifiedFlow = {}
208
+
209
+ for (const [stateId, state] of Object.entries(flow)) {
210
+ const minifiedState = structuredClone(state)
211
+
212
+ stripKeys(minifiedState, STATE_NOISE)
213
+ stripAngularKeys(minifiedState)
214
+ stripKeys(minifiedState.$defaultOutput, DEFAULT_OUTPUT_NOISE)
215
+
216
+ // Only the state that is the entry point, and only a plugin that is really
217
+ // configured, are worth carrying: expansion writes `false` on all the others.
218
+ if (!minifiedState.root) {
219
+ delete minifiedState.root
220
+ }
221
+
222
+ if (!minifiedState.$whiteWallIntegration) {
223
+ delete minifiedState.$whiteWallIntegration
224
+ }
225
+
226
+ for (const output of minifiedState.$conditionOutputs ?? []) {
227
+ stripKeys(output, CONDITION_OUTPUT_NOISE)
228
+ }
229
+
230
+ for (const list of CUSTOM_ACTION_LISTS) {
231
+ for (const action of minifiedState[list] ?? []) {
232
+ stripKeys(action, CUSTOM_ACTION_NOISE)
233
+ }
234
+ }
235
+
236
+ // The builder re-inserts a typing indicator before every message, so
237
+ // carrying them around would just add noise to the minified flow.
238
+ minifiedState.$contentActions = minifiedState.$contentActions
239
+ ?.filter((item) => !isChatStateSendMessage(item))
240
+ .map((item) => {
241
+ stripKeys(item, CONTENT_ACTION_ITEM_NOISE)
242
+ stripKeys(item.input, CONTENT_INPUT_NOISE)
243
+ stripKeys(item.action, CONTENT_ACTION_NOISE)
244
+ stripKeys(item.action?.settings, CONTENT_ACTION_SETTINGS_NOISE)
245
+ return item
246
+ })
247
+
248
+ minified[stateId] = minifiedState as unknown as MinifiedState
249
+ }
250
+
251
+ return minified
252
+ }
253
+
254
+ export function expandMinifiedFlow(minifiedFlow: MinifiedFlow, staleFlow?: Flow): Flow {
255
+ const flow: Flow = {}
256
+ // All or nothing: mixing the flow's own entry point with the builder's would
257
+ // leave two states marked as root, which the runtime refuses.
258
+ const declaresRoot = Object.values(minifiedFlow).some((state) => state.root)
259
+
260
+ for (const [stateId, minifiedState] of Object.entries(minifiedFlow)) {
261
+ try {
262
+ const staleState = staleFlow?.[stateId]
263
+ const state = structuredClone(minifiedState) as unknown as State
264
+
265
+ state.$invalidContentActions = false
266
+ state.$invalidOutputs = false
267
+ state.$invalidCustomActions = false
268
+ state.$invalid = false
269
+ state.id = stateId
270
+ // The entry point is carried by the flow, because `Flow.Validate`
271
+ // asserts on the flag and nothing else can express "this state, not the
272
+ // one named `onboarding`". A flow that predates it falls back to what
273
+ // the builder already knows, then to the id convention, so a first push
274
+ // still gets an entry point either way.
275
+ state.root = declaresRoot
276
+ ? Boolean(minifiedState.root)
277
+ : staleState
278
+ ? staleState.root
279
+ : stateId === ROOT_STATE_ID || undefined
280
+ state.$tags = staleState?.$tags ?? []
281
+ state.$inputSuggestions = staleState?.$inputSuggestions ?? []
282
+ state.isAiGenerated = staleState?.isAiGenerated ?? false
283
+ // A plugin's configuration is authored, not generated, so the minified
284
+ // flow is the source of truth for it. Falling back to the stale state
285
+ // keeps a plugin configured before this was carried through.
286
+ state.$whiteWallIntegration =
287
+ minifiedState.$whiteWallIntegration ?? staleState?.$whiteWallIntegration ?? false
288
+ state.deskStateVersion = staleState?.deskStateVersion
289
+ state.$defaultOutput.$invalid = false
290
+
291
+ // Reuse the ids the builder already knows so the canvas keeps its
292
+ // connections instead of redrawing them on every push.
293
+ const matchStaleConditionOutput = createStaleMatcher(staleState?.$conditionOutputs, conditionOutputKey)
294
+
295
+ for (const conditionOutput of state.$conditionOutputs ?? []) {
296
+ const staleConditionOutput = matchStaleConditionOutput(conditionOutput)
297
+
298
+ conditionOutput.$id = staleConditionOutput?.$id ?? crypto.randomUUID()
299
+ conditionOutput.$invalid = false
300
+ conditionOutput.$connId = staleConditionOutput?.$connId
301
+ conditionOutput.$isDeskOutput = staleConditionOutput?.$isDeskOutput
302
+ conditionOutput.$isDeskCustomOutput = staleConditionOutput?.$isDeskCustomOutput
303
+ conditionOutput.$isDeskDefaultOutput = staleConditionOutput?.$isDeskDefaultOutput
304
+ conditionOutput.$contentId = staleConditionOutput?.$contentId
305
+ }
306
+
307
+ for (const actionType of CUSTOM_ACTION_LISTS) {
308
+ const matchStaleAction = createStaleMatcher(staleState?.[actionType], actionKey)
309
+
310
+ for (const action of state[actionType] ?? []) {
311
+ const staleAction = matchStaleAction(action)
312
+
313
+ action.$id = staleAction?.$id ?? crypto.randomUUID()
314
+ action.$invalid = false
315
+ action.$title = staleAction?.$title ?? createActionTitle(action)
316
+ action.$typeOfContent = staleAction?.$typeOfContent
317
+ }
318
+ }
319
+
320
+ const matchStaleContentAction = createStaleMatcher(
321
+ staleState?.$contentActions?.flatMap((candidate) => candidate.action ?? []),
322
+ actionKey,
323
+ )
324
+ const matchStaleInput = createStaleMatcher(
325
+ staleState?.$contentActions?.flatMap((candidate) => candidate.input ?? []),
326
+ inputKey,
327
+ )
328
+
329
+ for (const contentAction of state.$contentActions ?? []) {
330
+ contentAction.$invalid = false
331
+
332
+ if (contentAction.input) {
333
+ const staleInput = matchStaleInput(contentAction.input)
334
+
335
+ contentAction.input.$invalid = false
336
+ contentAction.input.$cardContent = staleInput?.$cardContent ?? {
337
+ document: {
338
+ id: crypto.randomUUID(),
339
+ type: 'text/plain',
340
+ textContent: 'Entrada do usuário',
341
+ content: 'Entrada do usuário',
342
+ },
343
+ editable: false,
344
+ deletable: true,
345
+ position: 'right',
346
+ editing: false,
347
+ }
348
+ }
349
+
350
+ if (contentAction.action) {
351
+ const staleAction = matchStaleContentAction(contentAction.action)
352
+ const staleSettings = staleAction?.settings as { id?: string } | undefined
353
+ const settings = contentAction.action.settings as { id?: string; type?: string; content?: unknown }
354
+
355
+ contentAction.action.$id = staleAction?.$id ?? crypto.randomUUID()
356
+ settings.id = staleSettings?.id ?? crypto.randomUUID()
357
+ // The label the builder wrote is at least as precise as a derived
358
+ // one, but a GET can answer with an empty one.
359
+ contentAction.action.$typeOfContent =
360
+ staleAction?.$typeOfContent || getTypeOfContent(contentAction.action)
361
+ contentAction.action.$cardContent = {
362
+ deletable: true,
363
+ editable: true,
364
+ position: 'left',
365
+ document: {
366
+ id: staleAction?.$cardContent?.document.id ?? crypto.randomUUID(),
367
+ type: settings.type as (typeof MIME_TYPES)[number],
368
+ content: settings.content as string,
369
+ },
370
+ }
371
+ }
372
+ }
373
+
374
+ state.$contentActions = (state.$contentActions ?? []).flatMap((item) =>
375
+ item.action ? [createChatStateContentAction(), item] : [item],
376
+ )
377
+
378
+ flow[stateId] = state
379
+ } catch (err) {
380
+ if (err instanceof Error) {
381
+ throw new Error(`[State ${stateId}] ${err.message}`, { cause: err })
382
+ }
383
+
384
+ throw err
385
+ }
386
+ }
387
+
388
+ // Drops the keys explicitly set to undefined above.
389
+ return JSON.parse(JSON.stringify(flow))
390
+ }
391
+
392
+ /**
393
+ * Typing indicators are dropped by `minifyFlow` and rebuilt here from scratch,
394
+ * one before every message, rather than being carried through the minified flow.
395
+ *
396
+ * This is deliberate: they are pure presentation, they outnumber the messages
397
+ * they precede, and keeping them would double the size of the file a person or
398
+ * an LLM has to read and edit. The trade-off is that a typing interval tuned by
399
+ * hand in the builder does not survive a round trip and comes back as the
400
+ * default below.
401
+ */
402
+ function createChatStateContentAction(): State['$contentActions'][number] {
403
+ const content = { state: 'composing', interval: 1000 }
404
+
405
+ return {
406
+ action: {
407
+ $id: crypto.randomUUID(),
408
+ $typeOfContent: 'chat-state',
409
+ type: 'SendMessage',
410
+ settings: {
411
+ id: crypto.randomUUID(),
412
+ type: 'application/vnd.lime.chatstate+json',
413
+ content,
414
+ },
415
+ $cardContent: {
416
+ document: {
417
+ id: crypto.randomUUID(),
418
+ type: 'application/vnd.lime.chatstate+json',
419
+ content,
420
+ },
421
+ editable: true,
422
+ deletable: true,
423
+ position: 'left',
424
+ },
425
+ },
426
+ $invalid: false,
427
+ }
428
+ }
429
+
430
+ /** Derives the label the builder would have given an action it has never seen. */
431
+ function getTypeOfContent(
432
+ action: NonNullable<State['$contentActions'][number]['action']>,
433
+ ): (typeof CONTENT_TYPES)[number] {
434
+ if (action.type === 'SendRawMessage') {
435
+ return 'raw-content'
436
+ }
437
+
438
+ const settings = action.settings as {
439
+ type?: (typeof MIME_TYPES)[number]
440
+ content?: { scope?: string; type?: string }
441
+ }
442
+
443
+ switch (settings.type) {
444
+ case 'text/plain':
445
+ return 'text'
446
+ case 'application/vnd.lime.media-link+json':
447
+ return getTypeOfMedia(settings.content?.type ?? '')
448
+ case 'application/vnd.lime.select+json':
449
+ return settings.content?.scope === 'immediate' ? 'select-immediate' : 'select'
450
+ case 'application/vnd.lime.chatstate+json':
451
+ return 'chat-state'
452
+ case 'application/vnd.lime.web-link+json':
453
+ return 'web-link'
454
+ default:
455
+ throw new Error(`Content action does not have a valid action property: ${JSON.stringify(action)}`)
456
+ }
457
+ }
458
+
459
+ function getTypeOfMedia(mimeType: string): (typeof CONTENT_TYPES)[number] {
460
+ if (mimeType.startsWith('audio/')) {
461
+ return 'media-audio'
462
+ }
463
+
464
+ if (mimeType.startsWith('video/')) {
465
+ return 'media-video'
466
+ }
467
+
468
+ return mimeType.startsWith('image/') ? 'media' : 'media-document'
469
+ }
470
+
471
+ function createActionTitle(action: State['$enteringCustomActions'][number]): string | undefined {
472
+ const sanitize = (value: string | number) => value.toString().replace(/[^a-zA-Z0-9.@]/g, '')
473
+
474
+ switch (action.type) {
475
+ case 'TrackEvent':
476
+ return `Track ${sanitize(action.settings.category)}`
477
+
478
+ case 'ExecuteScript':
479
+ case 'ExecuteScriptV2':
480
+ return `Process ${action.settings.outputVariable}`
481
+
482
+ case 'Redirect':
483
+ return `Redirect to ${sanitize(action.settings.address)}`
484
+
485
+ case 'MergeContact': {
486
+ const items = Object.keys(action.settings).filter((key) => key !== 'extras')
487
+ if (Object.keys(action.settings.extras ?? {}).length > 0) {
488
+ items.push('extras')
489
+ }
490
+ return `Set ${items.join(', ')}`
491
+ }
492
+
493
+ case 'ProcessHttp':
494
+ return `[${action.settings.method}] Process ${action.settings.responseBodyVariable ?? ''}`
495
+
496
+ case 'SetVariable':
497
+ return `Set ${action.settings.variable} to ${sanitize(action.settings.value ?? '')}`
498
+
499
+ case 'ProcessCommand':
500
+ return `[${action.settings.method.toUpperCase()}] Process ${action.settings.variable}`
501
+
502
+ default:
503
+ return undefined
504
+ }
505
+ }