@ganju/utils 0.0.20 → 0.0.22

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 (72) hide show
  1. package/dist/constants.d.ts +16 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +43 -0
  4. package/dist/data.d.ts +7 -5
  5. package/dist/data.d.ts.map +1 -1
  6. package/dist/data.js +27 -3
  7. package/dist/dataApi.d.ts +82 -6
  8. package/dist/dataApi.d.ts.map +1 -1
  9. package/dist/dataApi.js +47 -4
  10. package/dist/dataAttachment.d.ts +8 -0
  11. package/dist/dataAttachment.d.ts.map +1 -0
  12. package/dist/dataAttachment.js +19 -0
  13. package/dist/dataCompiler.d.ts +4 -0
  14. package/dist/dataCompiler.d.ts.map +1 -1
  15. package/dist/dataCompiler.js +27 -2
  16. package/dist/dataDdl.d.ts.map +1 -1
  17. package/dist/dataDdl.js +5 -0
  18. package/dist/dataEngine.d.ts +25 -3
  19. package/dist/dataEngine.d.ts.map +1 -1
  20. package/dist/dataEngine.js +508 -14
  21. package/dist/dataImport.d.ts +3 -0
  22. package/dist/dataImport.d.ts.map +1 -1
  23. package/dist/dataImport.js +12 -2
  24. package/dist/dataIndexAdvice.d.ts +36 -0
  25. package/dist/dataIndexAdvice.d.ts.map +1 -0
  26. package/dist/dataIndexAdvice.js +89 -0
  27. package/dist/dataRpc.d.ts +20 -1
  28. package/dist/dataRpc.d.ts.map +1 -1
  29. package/dist/dataSheet.d.ts +34 -0
  30. package/dist/dataSheet.d.ts.map +1 -0
  31. package/dist/dataSheet.js +100 -0
  32. package/dist/dataTools.js +3 -3
  33. package/dist/dataTriggers.d.ts +66 -4
  34. package/dist/dataTriggers.d.ts.map +1 -1
  35. package/dist/dataTriggers.js +446 -20
  36. package/dist/dataTypes.d.ts +69 -0
  37. package/dist/dataTypes.d.ts.map +1 -1
  38. package/dist/dataTypes.js +21 -1
  39. package/dist/index.d.ts +91 -7
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +8 -0
  42. package/dist/localizeDataError.d.ts.map +1 -1
  43. package/dist/localizeDataError.js +109 -3
  44. package/dist/schema.d.ts +67 -6
  45. package/dist/schema.d.ts.map +1 -1
  46. package/dist/schema.js +57 -2
  47. package/dist/untrustedContent.d.ts +7 -0
  48. package/dist/untrustedContent.d.ts.map +1 -1
  49. package/dist/untrustedContent.js +13 -1
  50. package/dist/whatsappWebhook.d.ts +28 -0
  51. package/dist/whatsappWebhook.d.ts.map +1 -0
  52. package/dist/whatsappWebhook.js +30 -0
  53. package/package.json +1 -1
  54. package/src/constants.ts +46 -0
  55. package/src/data.ts +33 -3
  56. package/src/dataApi.ts +54 -4
  57. package/src/dataAttachment.ts +23 -0
  58. package/src/dataCompiler.ts +41 -1
  59. package/src/dataDdl.ts +7 -0
  60. package/src/dataEngine.ts +680 -15
  61. package/src/dataImport.ts +16 -1
  62. package/src/dataIndexAdvice.ts +127 -0
  63. package/src/dataRpc.ts +27 -2
  64. package/src/dataSheet.ts +147 -0
  65. package/src/dataTools.ts +3 -3
  66. package/src/dataTriggers.ts +599 -20
  67. package/src/dataTypes.ts +108 -0
  68. package/src/index.ts +15 -0
  69. package/src/localizeDataError.ts +183 -4
  70. package/src/schema.ts +60 -2
  71. package/src/untrustedContent.ts +15 -0
  72. package/src/whatsappWebhook.ts +55 -0
package/src/dataTypes.ts CHANGED
@@ -65,9 +65,43 @@ export interface DataTableInput {
65
65
  access?: DataAccess;
66
66
  customerScoped?: boolean;
67
67
  history?: boolean;
68
+ source?: DataTableSource | null;
68
69
  columns: DataColumnInput[];
69
70
  }
70
71
 
72
+ // A table kept in step with a spreadsheet or CSV in the project's knowledge:
73
+ // `key` is the unique column that names a row in both, and the sheet wins.
74
+ // `title` is the file's name when the source was saved, for messages.
75
+ export interface DataTableSource {
76
+ uri: string;
77
+ sheet?: string | null;
78
+ key: string;
79
+ title?: string | null;
80
+ }
81
+
82
+ // How the last sync went. A refused one changed nothing.
83
+ export interface DataTableSync {
84
+ at: string;
85
+ status: 'ok' | 'failed';
86
+ inserted: number;
87
+ updated: number;
88
+ deleted: number;
89
+ unchanged: number;
90
+ error: string | null;
91
+ // The version of the file (its stored time) the rows were last made from.
92
+ // A sync of an older one is skipped, so two that overlap can't leave the
93
+ // older sheet's rows.
94
+ version?: string | null;
95
+ // Only in a sync's answer: skipped, an older file than the rows already are.
96
+ stale?: boolean;
97
+ }
98
+
99
+ // Who a sync's changes are made by, in row history and trigger events.
100
+ export const DATA_SHEET_SYNC_ACTOR = 'system:sheet-sync';
101
+ // A source file is read whole, so it stays small.
102
+ export const DATA_SHEET_MAX_BYTES = 5 * 1024 * 1024;
103
+ export const DATA_SHEET_MAX_ROWS = 20_000;
104
+
71
105
  // A column as the database object stores it: the input plus its stable id,
72
106
  // which names its indexes and constraints so a rename never orphans them.
73
107
  export interface DataColumn {
@@ -100,6 +134,9 @@ export interface DataTable {
100
134
  history: boolean;
101
135
  position: number;
102
136
  columns: DataColumn[];
137
+ // Set while the table is synced from a sheet: its rows are then read-only.
138
+ source?: DataTableSource | null;
139
+ sync?: DataTableSync | null;
103
140
  }
104
141
 
105
142
  export interface DataSchema {
@@ -138,11 +175,33 @@ export interface DataCallerLimits {
138
175
  readOnly?: boolean;
139
176
  }
140
177
 
178
+ // What one query on a table did: the columns it filtered and sorted on, and
179
+ // how many rows it read to return how many. The API sums them per column for
180
+ // a week to suggest indexes.
181
+ export interface DataQueryShape {
182
+ table: string;
183
+ filtered: string[];
184
+ sorted: string[];
185
+ rowsRead: number;
186
+ returned: number;
187
+ }
188
+
189
+ // Rows one write operation wrote to a table, which tells a write-heavy table.
190
+ export interface DataWriteShape {
191
+ table: string;
192
+ rowsWritten: number;
193
+ }
194
+
141
195
  export interface DataUsage {
142
196
  rowsRead: number;
143
197
  rowsWritten: number;
198
+ queries?: DataQueryShape[];
199
+ writes?: DataWriteShape[];
144
200
  }
145
201
 
202
+ // At most this many shapes travel back with one call.
203
+ export const DATA_MAX_USAGE_SHAPES = 20;
204
+
146
205
  export const DATA_ERROR_CODES = [
147
206
  'invalid',
148
207
  'not_found',
@@ -278,6 +337,46 @@ export interface DataDeleteImpact {
278
337
  truncated: boolean;
279
338
  }
280
339
 
340
+ // A file column links a knowledge item by its uri. Reads return it as
341
+ // { uri }, which the workers fill with the item's title and type.
342
+ export const DATA_FILE_URI_MAX = 2048;
343
+ export const DATA_FILE_LINKS_MAX_URIS = 500;
344
+
345
+ export interface DataFileValue {
346
+ uri: string;
347
+ // The item's id and size: only in the dashboard's reads, for previews.
348
+ id?: string;
349
+ size?: number;
350
+ title?: string;
351
+ mimeType?: string;
352
+ // No knowledge item has this uri any more.
353
+ missing?: true;
354
+ }
355
+
356
+ // The rows whose file columns point at some uris, per column.
357
+ export interface DataFileLinks {
358
+ links: {
359
+ table: string;
360
+ tableLabel: string | null;
361
+ column: string;
362
+ columnLabel: string | null;
363
+ required: boolean;
364
+ rows: number;
365
+ }[];
366
+ }
367
+
368
+ export interface DataFileLinkChange {
369
+ from: string;
370
+ // null clears the link.
371
+ to: string | null;
372
+ }
373
+
374
+ export interface DataFileLinksReplaced {
375
+ updated: number;
376
+ // Required columns can't be emptied: their links stay, and show as missing.
377
+ kept: { table: string; column: string; rows: number }[];
378
+ }
379
+
281
380
  // A table copy running in the background, as callers see it.
282
381
  export interface DataTableCopy {
283
382
  table: string;
@@ -302,6 +401,15 @@ export const toTableInput = (table: DataTable): DataTableInput => ({
302
401
  access: table.access,
303
402
  customerScoped: table.customerScoped,
304
403
  history: table.history,
404
+ ...(table.source
405
+ ? {
406
+ source: {
407
+ uri: table.source.uri,
408
+ ...(table.source.sheet ? { sheet: table.source.sheet } : {}),
409
+ key: table.source.key
410
+ }
411
+ }
412
+ : {}),
305
413
  columns: table.columns.map(column => {
306
414
  const out: DataColumnInput = { name: column.name, type: column.type };
307
415
  if (column.label) out.label = column.label;
package/src/index.ts CHANGED
@@ -129,6 +129,8 @@ import { oauthProviders } from './oauthProviders';
129
129
  import type { OAuthProviderConfig } from './oauthProviders';
130
130
  import { resolveAttachment } from './attachment';
131
131
  import { isExposedResource } from './exposedResource';
132
+ import { dataAttachmentOf, skipsIndexing } from './dataAttachment';
133
+ import type { DataAttachment } from './dataAttachment';
132
134
  import type { ExposableResource } from './exposedResource';
133
135
  import {
134
136
  isSyncableResource,
@@ -270,9 +272,11 @@ import type {
270
272
  import {
271
273
  wrapUntrustedContent,
272
274
  unwrapUntrustedContent,
275
+ stripUntrustedMarks,
273
276
  isUntrustedToolResult,
274
277
  findInstructionLikeText,
275
278
  UNTRUSTED_CONTENT_TAG,
279
+ UNTRUSTED_CONTENT_MARK_PATTERN,
276
280
  UNTRUSTED_CONTENT_META_KEY,
277
281
  UNTRUSTED_CONTENT_NOTE,
278
282
  INSTRUCTION_WARNING_METADATA_KEY,
@@ -391,6 +395,10 @@ import {
391
395
  cleanWhatsappTemplateParam,
392
396
  whatsappSendError
393
397
  } from './whatsappTemplates';
398
+ import {
399
+ whatsappMessageChange,
400
+ whatsappAccountChanges
401
+ } from './whatsappWebhook';
394
402
  import type {
395
403
  WhatsappTemplateCategory,
396
404
  WhatsappTemplateDraft,
@@ -428,6 +436,8 @@ export const utils = {
428
436
  whatsappTemplateSendable,
429
437
  cleanWhatsappTemplateParam,
430
438
  whatsappSendError,
439
+ whatsappMessageChange,
440
+ whatsappAccountChanges,
431
441
  getEnv,
432
442
  fetcher,
433
443
  constants,
@@ -485,6 +495,8 @@ export const utils = {
485
495
  oauthProviders,
486
496
  resolveAttachment,
487
497
  isExposedResource,
498
+ dataAttachmentOf,
499
+ skipsIndexing,
488
500
  isSyncableResource,
489
501
  isResourceSyncInProgress,
490
502
  resourceSyncProvider,
@@ -566,9 +578,11 @@ export const utils = {
566
578
  stableJson,
567
579
  wrapUntrustedContent,
568
580
  unwrapUntrustedContent,
581
+ stripUntrustedMarks,
569
582
  isUntrustedToolResult,
570
583
  findInstructionLikeText,
571
584
  UNTRUSTED_CONTENT_TAG,
585
+ UNTRUSTED_CONTENT_MARK_PATTERN,
572
586
  UNTRUSTED_CONTENT_META_KEY,
573
587
  UNTRUSTED_CONTENT_NOTE,
574
588
  INSTRUCTION_WARNING_METADATA_KEY,
@@ -730,6 +744,7 @@ export type {
730
744
  AttachmentResource,
731
745
  ResolvedAttachment,
732
746
  ResolveAttachmentResult,
747
+ DataAttachment,
733
748
  ExposableResource,
734
749
  SyncableResource,
735
750
  ResourceSyncProvider,
@@ -39,6 +39,21 @@ const guardSuffix = (suffix: string): string => {
39
39
  return suffix;
40
40
  };
41
41
 
42
+ // Meta's other template statuses, as the refusal writes them.
43
+ const TEMPLATE_STATUS_ES: Record<string, string> = {
44
+ paused: 'pausada',
45
+ disabled: 'desactivada',
46
+ flagged: 'marcada',
47
+ 'in appeal': 'en apelación',
48
+ 'pending deletion': 'pendiente de eliminación',
49
+ deleted: 'eliminada',
50
+ 'limit exceeded': 'por encima del límite'
51
+ };
52
+
53
+ // "The WhatsApp template x (es)", as a channel's refusal names it.
54
+ const templateLabel = (label: string): string =>
55
+ label.replace(/^The WhatsApp template /, 'La plantilla de WhatsApp ');
56
+
42
57
  export const DATA_ERROR_MESSAGES_ES: Record<string, string | Render> = {
43
58
  'Unknown column "{0}" in {1}. Columns: {2}.':
44
59
  'Columna desconocida "{0}" en {1}. Columnas: {2}.',
@@ -124,8 +139,22 @@ export const DATA_ERROR_MESSAGES_ES: Record<string, string | Render> = {
124
139
  '{0}: "min" is a number.': '{0}: "min" es un número.',
125
140
  "{0}: a {1} column can't be unique.":
126
141
  '{0}: una columna {1} no puede ser única.',
127
- '{0}: only text columns can be searchable (text, long_text, email, url, phone, enum).':
128
- '{0}: solo las columnas de texto pueden ser buscables (text, long_text, email, url, phone, enum).',
142
+ '{0}: only text and file columns can be searchable (text, long_text, email, url, phone, enum, file).':
143
+ '{0}: solo las columnas de texto y de archivo pueden ser buscables (text, long_text, email, url, phone, enum, file).',
144
+ "{0} takes a knowledge item's uri, e.g. resource://menu-photo.":
145
+ '{0} lleva el uri de un elemento del conocimiento, p. ej. resource://menu-photo.',
146
+ '{0}: a uri is at most {1} characters.':
147
+ '{0}: un uri tiene como máximo {1} caracteres.',
148
+ '"uris" lists 1 to {0} knowledge item uris.':
149
+ '"uris" lista de 1 a {0} uris de elementos del conocimiento.',
150
+ 'No resource in this project has the uri {0}; link an existing resource by its uri.':
151
+ 'Ningún recurso de este proyecto tiene el uri {0}; enlaza un recurso que exista por su uri.',
152
+ 'File links are for the owner.':
153
+ 'Los enlaces a archivos son para el dueño.',
154
+ '"changes" lists 1 to {0} { from, to } pairs.':
155
+ '"changes" lista de 1 a {0} pares { from, to }.',
156
+ 'Each change is { from, to }; to is null to clear.':
157
+ 'Cada cambio es { from, to }; to es null para quitar el enlace.',
129
158
  '{0}: a column named "rank" can\'t be searchable; rename it (e.g. "ranking").':
130
159
  '{0}: una columna llamada "rank" no puede ser buscable; cámbiale el nombre (p. ej. "ranking").',
131
160
  "{0}: a {1} column can't be filterable; mark it searchable instead.":
@@ -395,7 +424,24 @@ export const DATA_ERROR_MESSAGES_ES: Record<string, string | Render> = {
395
424
  'A trigger is an object.': 'Un disparador es un objeto.',
396
425
  'There is no table named {0}.': 'No hay ninguna tabla llamada {0}.',
397
426
  'A trigger needs "table".': 'Un disparador necesita "table".',
398
- '"on" is insert, update or delete.': '"on" es insert, update o delete.',
427
+ '"on" is insert, update, delete or time.':
428
+ '"on" es insert, update, delete o time.',
429
+ // Time triggers.
430
+ '"after" is for "on": "time" only.': '"after" es solo para "on": "time".',
431
+ 'A time trigger needs "after": { "column", "minutes" }, such as { "column": "created_at", "minutes": 30 }.':
432
+ 'Un disparador por tiempo necesita "after": { "column", "minutes" }, por ejemplo { "column": "created_at", "minutes": 30 }.',
433
+ '"after.column" is created_at, updated_at or a date or datetime column of {0}.':
434
+ '"after.column" es created_at, updated_at o una columna de fecha o de fecha y hora de {0}.',
435
+ '"after.minutes" is a whole number of minutes from {0} to {1} (30 days), negative for before the column\'s time.':
436
+ '"after.minutes" es un número entero de minutos de {0} a {1} (30 días), negativo para antes de la hora de la columna.',
437
+ '"after.minutes" can\'t be negative on {0}: that time has already passed when the row is written. Use a date or datetime column for a reminder before.':
438
+ '"after.minutes" no puede ser negativo en {0}: esa hora ya pasó cuando se escribe la fila. Usa una columna de fecha o de fecha y hora para un recordatorio antes.',
439
+ '"when".{0}.{1}: a time trigger reads the row as it is when it comes due, so its tests are "equals" only.':
440
+ '"when".{0}.{1}: un disparador por tiempo lee la fila tal como está cuando se cumple el plazo, así que sus pruebas son solo "equals".',
441
+ 'A database holds up to {0} time triggers.':
442
+ 'Una base de datos tiene hasta {0} disparadores por tiempo.',
443
+ '{0}.{1} stays a date or datetime while time triggers count from it ({2}); change or delete them first.':
444
+ '{0}.{1} debe seguir siendo de fecha o de fecha y hora mientras haya disparadores por tiempo que cuenten desde ella ({2}); cámbialos o elimínalos primero.',
399
445
  "A trigger's name is lowercase letters, digits, _ and -, starting with a letter.":
400
446
  'El nombre de un disparador usa minúsculas, dígitos, _ y -, empezando por una letra.',
401
447
  '"rateLimit" is 1 to {0} runs a minute.':
@@ -469,6 +515,9 @@ export const DATA_ERROR_MESSAGES_ES: Record<string, string | Render> = {
469
515
  'Type the table\'s name, "{0}", to drop it.':
470
516
  'Escribe el nombre de la tabla, "{0}", para eliminarla.',
471
517
  'There is no such run.': 'No existe esa ejecución.',
518
+ 'There is no such index suggestion.': 'No existe esa sugerencia de índice.',
519
+ 'An unused index is only flagged; remove it from the column if you want to, or dismiss the suggestion.':
520
+ 'Un índice sin uso solo se señala; quítalo de la columna si quieres, o descarta la sugerencia.',
472
521
  'Only a failed or skipped run can be tried again.':
473
522
  'Solo se puede reintentar una ejecución fallida u omitida.',
474
523
  'This run kept no event to send again.':
@@ -516,7 +565,137 @@ export const DATA_ERROR_MESSAGES_ES: Record<string, string | Render> = {
516
565
  '"{0}" is reserved by SQLite; pick another column name (e.g. "{1}_ref").':
517
566
  '"{0}" está reservado por SQLite; elige otro nombre de columna (p. ej. "{1}_ref").',
518
567
  '"{0}" is a column every table already has.':
519
- '"{0}" es una columna que ya tienen todas las tablas.'
568
+ '"{0}" es una columna que ya tienen todas las tablas.',
569
+ // A test send of a trigger's customer message.
570
+ "You haven't linked a chat with this project's bots: write to the bot from your own phone and send /link.":
571
+ 'No has vinculado un chat con los bots de este proyecto: escríbele al bot desde tu propio teléfono y envía /link.',
572
+ "{0} isn't one of your linked chats with this project's bots.":
573
+ '{0} no es uno de tus chats vinculados con los bots de este proyecto.',
574
+ 'Several of your chats are linked to this project\'s bots; pick one with "participantId".':
575
+ 'Varios de tus chats están vinculados con los bots de este proyecto; elige uno con "participantId".',
576
+ "This organization's plan no longer includes Data, so its triggers are paused and can't be tested. Upgrade to Pro to send a test.":
577
+ 'El plan de esta organización ya no incluye Datos, así que sus disparadores están en pausa y no se pueden probar. Pasa a Pro para enviar una prueba.',
578
+ '{0} has no rows yet; add one to build the test from.':
579
+ '{0} aún no tiene filas; agrega una para armar la prueba.',
580
+ 'Reading a row as its trigger event is for the project owner.':
581
+ 'Leer una fila como su evento de disparador es solo para el dueño del proyecto.',
582
+ // The channel's refusals, which a test send answers with.
583
+ "This channel's credentials can't be read; connect it again.":
584
+ 'No se pueden leer las credenciales de este canal; conéctalo de nuevo.',
585
+ 'A message needs "text".': 'Un mensaje necesita "text".',
586
+ 'A message is at most {0} characters.':
587
+ 'Un mensaje tiene como máximo {0} caracteres.',
588
+ '{0} refused the message: {1}': '{0} rechazó el mensaje: {1}',
589
+ '{0} refused the message.': '{0} rechazó el mensaje.',
590
+ "Ganju can't send messages on {0} channels.":
591
+ 'Ganju no puede enviar mensajes en canales de {0}.',
592
+ 'WhatsApp: {0}': 'WhatsApp: {0}',
593
+ 'The {0} channel is paused.': 'El canal de {0} está en pausa.',
594
+ "{0} isn't on this channel's WhatsApp Business account. Create it in the channel's Message templates, or check its name and language.":
595
+ a =>
596
+ `${templateLabel(a[0])} no está en la cuenta de WhatsApp Business de este canal. Créala en Plantillas de mensajes del canal, o revisa su nombre e idioma.`,
597
+ '{0} {1}; only approved templates can be sent.': a => {
598
+ const whole = `${a[0]} ${a[1]}`;
599
+ const m =
600
+ /^(The WhatsApp template [\s\S]+? \([^)]*\)) (is still in Meta's review|was rejected by Meta|is ([\s\S]+?) by Meta)( \([\s\S]*\))?$/.exec(
601
+ whole
602
+ );
603
+ if (!m) return `${whole}; solo se pueden enviar plantillas aprobadas.`;
604
+ const why = m[2].startsWith('is still')
605
+ ? 'sigue en revisión de Meta'
606
+ : m[2].startsWith('was rejected')
607
+ ? 'fue rechazada por Meta'
608
+ : `está ${TEMPLATE_STATUS_ES[m[3]] ?? `en el estado ${m[3]}`} por Meta`;
609
+ return `${templateLabel(m[1])} ${why}${m[4] ?? ''}; solo se pueden enviar plantillas aprobadas.`;
610
+ },
611
+ "{0} has a media header, a header variable or a button that takes a value; Ganju only fills the message's variables. Make a version without them.":
612
+ a =>
613
+ `${templateLabel(a[0])} tiene un encabezado con archivo, una variable en el encabezado o un botón que recibe un valor; Ganju solo llena las variables del mensaje. Haz una versión sin ellos.`,
614
+ "There is no customer {0} on this project's channels.":
615
+ 'No hay ningún cliente {0} en los canales de este proyecto.',
616
+ 'A message needs "text", a "template", or both.':
617
+ 'Un mensaje necesita "text", una "template", o ambos.',
618
+ 'This customer has never written to the bot privately, so there is no chat to message them in.':
619
+ 'Este cliente nunca le ha escrito al bot en privado, así que no hay un chat donde escribirle.',
620
+ "This customer already got the channel's {0} messages for the last 24 hours.":
621
+ 'Este cliente ya recibió los {0} mensajes del canal de las últimas 24 horas.',
622
+ "Outside WhatsApp's 24-hour window (this customer last wrote more than 24 hours ago); use an approved template.":
623
+ 'Fuera de la ventana de 24 horas de WhatsApp (este cliente escribió por última vez hace más de 24 horas); usa una plantilla aprobada.',
624
+ 'Templates are for WhatsApp; {0} takes "text".':
625
+ 'Las plantillas son para WhatsApp; {0} recibe "text".',
626
+ 'The WhatsApp template {0} has {1} variable; {2} value was given.':
627
+ 'La plantilla de WhatsApp {0} tiene {1} variable; se dio {2} valor.',
628
+ 'The WhatsApp template {0} has {1} variable; {2} values were given.':
629
+ 'La plantilla de WhatsApp {0} tiene {1} variable; se dieron {2} valores.',
630
+ 'The WhatsApp template {0} has {1} variables; {2} value was given.':
631
+ 'La plantilla de WhatsApp {0} tiene {1} variables; se dio {2} valor.',
632
+ 'The WhatsApp template {0} has {1} variables; {2} values were given.':
633
+ 'La plantilla de WhatsApp {0} tiene {1} variables; se dieron {2} valores.',
634
+ // Tables synced from a sheet
635
+ '{0} is synced from "{1}"; change the sheet, or stop syncing it.':
636
+ '{0} se sincroniza desde "{1}"; cambia la hoja, o deja de sincronizarla.',
637
+ '"source" is { "uri", "sheet"?, "key" }, or null to stop syncing.':
638
+ '"source" es { "uri", "sheet"?, "key" }, o null para dejar de sincronizar.',
639
+ '"source" takes uri, sheet and key, not "{0}".':
640
+ '"source" recibe uri, sheet y key, no "{0}".',
641
+ '"source.uri" is the uri of a spreadsheet or CSV in the project\'s knowledge.':
642
+ '"source.uri" es el uri de una hoja de cálculo o un CSV en el conocimiento del proyecto.',
643
+ '"source.sheet" is the name of a sheet in the file, up to 100 characters.':
644
+ '"source.sheet" es el nombre de una hoja del archivo, de hasta 100 caracteres.',
645
+ '"source.key" is the column that names a row in both the sheet and {0}. Columns: {1}.':
646
+ '"source.key" es la columna que identifica una fila tanto en la hoja como en {0}. Columnas: {1}.',
647
+ '{0}.{1} must be unique to be the sync key.':
648
+ '{0}.{1} debe ser única para ser la clave de la sincronización.',
649
+ '{0}.{1} is the key the sheet sync matches rows by, so it stays unique.':
650
+ '{0}.{1} es la clave con la que la sincronización empareja las filas, así que sigue siendo única.',
651
+ "{0}.{1} is the sheet sync's key: rename it on its own, then make the other changes.":
652
+ '{0}.{1} es la clave de la sincronización: cámbiale el nombre por separado, y luego haz los demás cambios.',
653
+ '{0}.{1} is the key the sheet sync matches rows by; stop syncing the table, or pick another key, first.':
654
+ '{0}.{1} es la clave con la que la sincronización empareja las filas; primero deja de sincronizar la tabla, o elige otra clave.',
655
+ "{0} isn't synced from a sheet.": '{0} no se sincroniza desde una hoja.',
656
+ 'A sync takes "columns" and "rows".':
657
+ 'Una sincronización recibe "columns" y "rows".',
658
+ "The sheet has no column for {0}'s key, {1}.":
659
+ 'La hoja no tiene una columna para la clave de {0}, {1}.',
660
+ 'The sheet has no column for {0}, which is required.':
661
+ 'La hoja no tiene una columna para {0}, que es obligatoria.',
662
+ 'The sheet has no column for {0}, which are required.':
663
+ 'La hoja no tiene columnas para {0}, que son obligatorias.',
664
+ 'The sheet has {0} rows; a synced table takes up to {1}.':
665
+ 'La hoja tiene {0} filas; una tabla sincronizada recibe hasta {1}.',
666
+ 'Line {0}: {1}': (a, t) => `Línea ${a[0]}: ${t(a[1])}`,
667
+ 'A row is an object of column: value.':
668
+ 'Una fila es un objeto de columna: valor.',
669
+ 'The key, {0}, is empty.': 'La clave, {0}, está vacía.',
670
+ '{0} {1} is already on line {2}; each line of the sheet needs its own key.':
671
+ '{0} {1} ya está en la línea {2}; cada línea de la hoja necesita su propia clave.',
672
+ 'Rows in {0} still point at a row the sheet no longer has ({1}.{2}); put them back in the sheet, or change those rows first.':
673
+ 'Filas de {0} todavía apuntan a una fila que la hoja ya no tiene ({1}.{2}); vuelve a ponerla en la hoja, o cambia esas filas primero.',
674
+ 'Rows in {0} still point at {1} rows the sheet no longer has ({2}.{3}); put them back in the sheet, or change those rows first.':
675
+ 'Filas de {0} todavía apuntan a {1} filas que la hoja ya no tiene ({2}.{3}); vuelve a ponerlas en la hoja, o cambia esas filas primero.',
676
+ "There is no file {0} in the project's knowledge.":
677
+ 'No hay ningún archivo {0} en el conocimiento del proyecto.',
678
+ '{0} is {1}; a table syncs from a spreadsheet or CSV.':
679
+ '{0} es {1}; una tabla se sincroniza desde una hoja de cálculo o un CSV.',
680
+ "{0} is no longer in the project's knowledge; pick the file again, or stop syncing {1}.":
681
+ '{0} ya no está en el conocimiento del proyecto; vuelve a elegir el archivo, o deja de sincronizar {1}.',
682
+ '{0} is over {1} MB; a synced table reads files up to that size.':
683
+ '{0} pesa más de {1} MB; una tabla sincronizada lee archivos de hasta ese tamaño.',
684
+ 'The sheet could not be read; try "Sync now" again in a few minutes.':
685
+ 'No se pudo leer la hoja; vuelve a intentar "Sincronizar ahora" en unos minutos.',
686
+ "{0} can't be both customer-scoped and synced from a sheet: synced rows have no customer, so the bots wouldn't see them.":
687
+ '{0} no puede estar limitada al cliente y a la vez sincronizarse desde una hoja: las filas sincronizadas no tienen cliente, así que los bots no las verían.',
688
+ 'Can\'t delete: it would change a row of {0} ({1}.{2}), which is synced from "{3}"; change the sheet first, or stop syncing it.':
689
+ 'No se puede eliminar: cambiaría una fila de {0} ({1}.{2}), que se sincroniza desde "{3}"; primero cambia la hoja, o deja de sincronizarla.',
690
+ 'Can\'t delete: it would change {0} rows of {1} ({2}.{3}), which is synced from "{4}"; change the sheet first, or stop syncing it.':
691
+ 'No se puede eliminar: cambiaría {0} filas de {1} ({2}.{3}), que se sincroniza desde "{4}"; primero cambia la hoja, o deja de sincronizarla.',
692
+ '{0} has no sheet "{1}". Sheets: {2}.': '{0} no tiene una hoja "{1}". Hojas: {2}.',
693
+ '{0} is over {1} MB.': '{0} pesa más de {1} MB.',
694
+ '{0} is {1}; a table reads CSV and Excel files.':
695
+ '{0} es {1}; una tabla lee archivos CSV y de Excel.',
696
+ 'A post needs "chatId".': 'Una publicación necesita "chatId".',
697
+ 'WhatsApp has no group chats for bots; message a customer with send().':
698
+ 'WhatsApp no tiene chats de grupo para bots; escríbele a un cliente con send().'
520
699
  };
521
700
 
522
701
  interface Compiled {
package/src/schema.ts CHANGED
@@ -418,6 +418,14 @@ const ARTIFACT_SYNC_ONE_DRIVE = z.object({
418
418
  organizationId: z.uuid()
419
419
  });
420
420
 
421
+ // A knowledge item uploaded from a Data row: which file column it was for,
422
+ // and whether it is indexed for search too (the column's searchable switch).
423
+ const DATA_ATTACHMENT_METADATA = z.object({
424
+ table: z.string().min(1).max(64),
425
+ column: z.string().min(1).max(64),
426
+ index: z.boolean()
427
+ });
428
+
421
429
  const ARTIFACT_CREATE_RESOURCE = z.object({
422
430
  title: z.string().min(3).max(200),
423
431
  uri: z.string(),
@@ -529,6 +537,20 @@ const ARTIFACT_GET_RESOURCE_BY_ID = z.object({
529
537
  organizationId: z.uuid()
530
538
  });
531
539
 
540
+ // The knowledge items a Data file column can link, newest first.
541
+ const ARTIFACT_PICK_RESOURCE = z.object({
542
+ projectId: z.uuid(),
543
+ userId: z.uuid(),
544
+ organizationId: z.uuid(),
545
+ q: z.string().trim().max(200).optional(),
546
+ limit: z.coerce.number().int().min(1).max(50).default(20),
547
+ // Only spreadsheets and CSV, for a table's sheet source.
548
+ tabular: z
549
+ .enum(['1', 'true'])
550
+ .optional()
551
+ .transform(v => v !== undefined)
552
+ });
553
+
532
554
  const ARTIFACT_REMOVE_RESOURCE = z.object({
533
555
  resourceId: z.uuid(),
534
556
  projectId: z.uuid(),
@@ -585,6 +607,14 @@ const DATA_COLUMN_INPUT = z.strictObject({
585
607
  });
586
608
 
587
609
  // The CLI's schema file holds a list of these.
610
+ // A spreadsheet or CSV in the project's knowledge the table's rows come
611
+ // from, matched by `key`. Null stops syncing.
612
+ const DATA_TABLE_SOURCE = z.strictObject({
613
+ uri: z.string().min(1).max(2048),
614
+ sheet: z.string().trim().min(1).max(100).nullable().optional(),
615
+ key: DATA_IDENTIFIER
616
+ });
617
+
588
618
  const DATA_TABLE_INPUT = z.strictObject({
589
619
  name: DATA_IDENTIFIER,
590
620
  label: DATA_TEXT(DATA_MAX_LABEL),
@@ -593,6 +623,7 @@ const DATA_TABLE_INPUT = z.strictObject({
593
623
  access: z.enum(DATA_ACCESS_LEVELS).optional(),
594
624
  customerScoped: z.boolean().optional(),
595
625
  history: z.boolean().optional(),
626
+ source: DATA_TABLE_SOURCE.nullable().optional(),
596
627
  columns: z.array(DATA_COLUMN_INPUT).max(DATA_MAX_COLUMNS)
597
628
  });
598
629
 
@@ -798,9 +829,18 @@ const DATA_TRIGGER_INPUT = z.strictObject({
798
829
  name: DATA_TRIGGER_NAME.optional(),
799
830
  description: z.string().max(300).nullish(),
800
831
  table: DATA_IDENTIFIER,
801
- on: z.enum(['insert', 'update', 'delete']),
832
+ on: z.enum(['insert', 'update', 'delete', 'time']),
833
+ // With "time": fires this many minutes after the column's time (negative:
834
+ // before it). The column is created_at, updated_at or a date / datetime.
835
+ after: z
836
+ .strictObject({
837
+ column: DATA_IDENTIFIER,
838
+ minutes: z.number().int().min(-43_200).max(43_200)
839
+ })
840
+ .nullish(),
802
841
  // { column: { changedTo | changedFrom | equals: value, changed: true } },
803
- // combined with { all: [...] } or { any: [...] }.
842
+ // combined with { all: [...] } or { any: [...] }. A time trigger takes
843
+ // equals only.
804
844
  when: z.record(z.string(), z.unknown()).nullish(),
805
845
  enabled: z.boolean().optional(),
806
846
  // false: refuse to save over an existing trigger of this name.
@@ -834,6 +874,17 @@ const DATA_TRIGGER_PATCH = z.strictObject({ enabled: z.boolean() });
834
874
  const DATA_TRIGGER_DEPLOY_BODY = z.strictObject({
835
875
  triggers: z.array(DATA_TRIGGER_INPUT).max(50)
836
876
  });
877
+ // A test of a "customer" action as the dialog has it, sent to the signed-in
878
+ // owner's own chat with the bot, built from one row (the newest by default).
879
+ const DATA_TRIGGER_TEST_SEND_BODY = z.strictObject({
880
+ table: DATA_IDENTIFIER,
881
+ customer: DATA_TRIGGER_INPUT.shape.customer.unwrap(),
882
+ rowId: z.number().int().min(1).optional(),
883
+ // Which of the owner's linked chats, when there is more than one.
884
+ participantId: z.string().min(1).max(64).optional(),
885
+ // Send the template even inside WhatsApp's 24-hour window.
886
+ asTemplate: z.boolean().optional()
887
+ });
837
888
  const DATA_TRIGGER_RUN_LIST_QUERY = z.object({
838
889
  limit: z.coerce.number().int().min(1).max(50).optional()
839
890
  });
@@ -848,6 +899,8 @@ const DATA_TRIGGER_PATH_PARAMS = DATA_DATABASE_GET.extend({ name: DATA_TRIGGER_N
848
899
  const DATA_TRIGGER_RUN_PARAMS = DATA_TRIGGER_PATH_PARAMS.extend({
849
900
  runId: z.string().min(1).max(64)
850
901
  });
902
+ // An index suggestion, by its id.
903
+ const DATA_INDEX_ADVICE_PARAMS = DATA_DATABASE_GET.extend({ adviceId: z.uuid() });
851
904
 
852
905
  const ARTIFACT_UPLOAD_RESOURCE_FILE = z.object({
853
906
  resourceId: z.uuid(),
@@ -2150,6 +2203,8 @@ export const Schema = {
2150
2203
  ARTIFACT_GET_PROMPT,
2151
2204
  ARTIFACT_REMOVE_PROMPT,
2152
2205
  ARTIFACT_CREATE_RESOURCE,
2206
+ DATA_ATTACHMENT_METADATA,
2207
+ ARTIFACT_PICK_RESOURCE,
2153
2208
  ARTIFACT_CREATE_RESOURCE_VIEW,
2154
2209
  ARTIFACT_CREATE_WEBSITE,
2155
2210
  ARTIFACT_CREATE_WEBSITE_VIEW,
@@ -2169,6 +2224,7 @@ export const Schema = {
2169
2224
  DATA_DATABASE_CREATE,
2170
2225
  DATA_DATABASE_DELETE,
2171
2226
  DATA_COLUMN_INPUT,
2227
+ DATA_TABLE_SOURCE,
2172
2228
  DATA_TABLE_INPUT,
2173
2229
  DATA_TABLE_PATCH,
2174
2230
  DATA_COLUMN_PATCH,
@@ -2196,9 +2252,11 @@ export const Schema = {
2196
2252
  DATA_TRIGGER_INPUT,
2197
2253
  DATA_TRIGGER_PATCH,
2198
2254
  DATA_TRIGGER_DEPLOY_BODY,
2255
+ DATA_TRIGGER_TEST_SEND_BODY,
2199
2256
  DATA_TRIGGER_RUN_LIST_QUERY,
2200
2257
  DATA_TRIGGER_PATH_PARAMS,
2201
2258
  DATA_TRIGGER_RUN_PARAMS,
2259
+ DATA_INDEX_ADVICE_PARAMS,
2202
2260
  DATA_COLUMN_PARAMS,
2203
2261
  DATA_ROW_PARAMS,
2204
2262
  ARTIFACT_REMOVE_RESOURCE,
@@ -121,6 +121,21 @@ export const unwrapUntrustedContent = (text: string): string => {
121
121
  return match ? match[2] : text;
122
122
  };
123
123
 
124
+ // Either line wrapUntrustedContent adds around a block, with the newline that
125
+ // joins it to the body. A `<` of ours inside a body was escaped, so every
126
+ // match is a real mark. A source string so SQL can strip the same marks.
127
+ export const UNTRUSTED_CONTENT_MARK_PATTERN =
128
+ `\\n?<${UNTRUSTED_CONTENT_TAG} id="[0-9a-f]+" source="[^"]*">\\n?` +
129
+ `|\\n?</${UNTRUSTED_CONTENT_TAG} id="[0-9a-f]+">`;
130
+
131
+ /**
132
+ * Text with the marks around every wrapped block removed and the bodies kept,
133
+ * for showing a recorded result to a person: an error that quotes an
134
+ * endpoint's answer reads as the message and the answer.
135
+ */
136
+ export const stripUntrustedMarks = (text: string): string =>
137
+ text.replace(new RegExp(UNTRUSTED_CONTENT_MARK_PATTERN, 'g'), '\n').trim();
138
+
124
139
  /** Whether a tool result carries the flag set by the server that ran it. */
125
140
  export const isUntrustedToolResult = (result: unknown): boolean => {
126
141
  if (!result || typeof result !== 'object') return false;
@@ -0,0 +1,55 @@
1
+ // What one WhatsApp channel takes from a webhook. A Meta app sends every
2
+ // number's events to each of its callbacks, signed with the same app secret,
3
+ // so a channel keeps only what was addressed to its own number and business
4
+ // account; otherwise another channel's bot answers its messages.
5
+
6
+ export interface WhatsappWebhookChange<V> {
7
+ field?: string;
8
+ value?: V;
9
+ }
10
+
11
+ export interface WhatsappWebhookEnvelope<V> {
12
+ entry?: Array<{ id?: string; changes?: Array<WhatsappWebhookChange<V>> }>;
13
+ }
14
+
15
+ export interface WhatsappAddressedValue {
16
+ metadata?: { phone_number_id?: string };
17
+ messages?: unknown[];
18
+ }
19
+
20
+ /**
21
+ * The change carrying a message for this channel's number, wherever Meta put
22
+ * it in the batch. A change that doesn't say which number it's for is kept.
23
+ */
24
+ export const whatsappMessageChange = <V extends WhatsappAddressedValue>(
25
+ payload: WhatsappWebhookEnvelope<V>,
26
+ phoneNumberId: string
27
+ ): V | undefined =>
28
+ (payload.entry ?? [])
29
+ .flatMap(entry => entry.changes ?? [])
30
+ .find(change => {
31
+ const value = change.value;
32
+ if (!value?.messages?.length) return false;
33
+ const to = value.metadata?.phone_number_id;
34
+ return !to || to === phoneNumberId;
35
+ })?.value;
36
+
37
+ /**
38
+ * The changes with one of `fields` from this channel's business account. An
39
+ * entry without an id, or a channel that hasn't saved its account, keeps
40
+ * them: template events only touch the channel's own stored templates.
41
+ */
42
+ export const whatsappAccountChanges = <V>(
43
+ payload: WhatsappWebhookEnvelope<V>,
44
+ businessAccountId: string | undefined,
45
+ fields: readonly string[]
46
+ ): WhatsappWebhookChange<V>[] =>
47
+ (payload.entry ?? [])
48
+ .filter(
49
+ entry => !entry.id || !businessAccountId || entry.id === businessAccountId
50
+ )
51
+ .flatMap(entry =>
52
+ (entry.changes ?? []).filter(
53
+ change => !!change.field && fields.includes(change.field)
54
+ )
55
+ );