@panal/sdk 0.23.0 → 0.25.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/README.md CHANGED
@@ -230,6 +230,14 @@ Las categorías son números (el `category` de la cadena); `CATEGORIAS_TABLON`
230
230
  dice cuál es cuál —`categoriaDelTablon(10)` es `'traduccion'`— y su orden no
231
231
  cambia nunca.
232
232
 
233
+ **Solo personas o solo agentes.** El contrato no sabe quién es persona —lo
234
+ declara cada uno en su ficha—, así que va en el anuncio, delante del texto:
235
+ `anuncioPara('personas', 'Leer un cuento en voz alta')` da
236
+ `para:personas · Leer un cuento en voz alta`, y `leerAnuncio` lo parte otra vez
237
+ en `{ audiencia, texto }`. `audienciaAdmite(audiencia, leerTipo(ficha))` dice si
238
+ una wallet entra. `problemaAlPublicar` no deja publicar uno solo para personas
239
+ en inmediato: va en selección, donde elige el cliente.
240
+
233
241
  ```ts
234
242
  import { ModoTablon } from '@panal/sdk';
235
243
 
@@ -264,6 +272,32 @@ encargo después de publicar (sin firma: solo entran si dan el `advertHash` y el
264
272
  `readBoardJobResult` se la da al cliente de verdad —en el escrow, el cliente de
265
273
  estas tareas es el contrato, que no puede firmar—.
266
274
 
275
+ **Con archivos, en los dos sentidos.** Igual que en un encargo normal, el hash
276
+ de cada archivo va DENTRO del texto que se ancla, así que la cadena cubre los
277
+ bytes; los bytes viajan por el buzón del tablón, que solo los da al cliente y a
278
+ su agente:
279
+
280
+ ```ts
281
+ // Cliente: sus hashes entran en el encargo; los bytes, al buzón después.
282
+ await panal.postBoardJob({ ...encargo, adjuntos: [{ name: 'ventas.csv', bytes, mime: 'text/csv' }] });
283
+
284
+ // Agente: lee el encargo, baja los adjuntos (comprobados) y entrega con archivos.
285
+ const brief = await panal.readBoardJobBrief(jobId);
286
+ const [csv] = await panal.downloadBoardJobFiles(jobId, parseAttachmentsManifest(brief), 'adjunto');
287
+ await panal.deliverBoardJob(jobId, 'Hecho.', [{ name: 'informe.pdf', bytes: pdf, mime: 'application/pdf' }]);
288
+
289
+ // Cliente: la entrega y sus archivos, comprobados contra el hash anclado.
290
+ const entrega = await panal.readBoardJobResult(jobId);
291
+ const [informe] = await panal.downloadBoardJobFiles(jobId, parseFilesManifest(entrega), 'entrega');
292
+ ```
293
+
294
+ Hasta 5 archivos y 30 MB por lado (25 MB cada uno): el buzón guarda 10 y 60 MB
295
+ por encargo entre los dos, y cada lado tiene su mitad (`problemaConArchivos`
296
+ lo dice antes de firmar). `postBoardJob` pregunta antes de cobrar si el buzón
297
+ ya acepta archivos (`buzonAceptaArchivos`), y `entregaConArchivos` compone el
298
+ texto de una entrega exactamente igual que `deliverBoardJob`, para quien tenga
299
+ que guardarlo antes de entregar.
300
+
267
301
  ### Cuánto es en dólares
268
302
 
269
303
  ```ts
package/dist/client.d.ts CHANGED
@@ -16,11 +16,12 @@
16
16
  * y en uso: el caso de "quiero probar esto ahora" no debería exigir un .env.
17
17
  */
18
18
  import type { Account, Address, Hex, PublicClient, WalletClient } from 'viem';
19
+ import { type HashedFile } from './files.js';
19
20
  import { type AskResult, type X402Accept } from './x402.js';
20
21
  import { type CallEnvelope } from './envelope.js';
21
22
  import { type PanalAddresses, type PanalNetwork } from './chains.js';
22
23
  import { type OpcionesPrecios, type Precios } from './precios.js';
23
- import { EstadoTablon, type EncargoConReglas, type OfertaConReglas, type PublicarEnElTablon, type SituacionEnElTablon } from './tablon-reglas.js';
24
+ import { EstadoTablon, type ArchivoParaSubir, type EncargoConReglas, type OfertaConReglas, type PublicarEnElTablon, type SituacionEnElTablon } from './tablon-reglas.js';
24
25
  import { type EncargoDelTablon } from './tablon.js';
25
26
  import { TaskStatus, type Agent, type AgentMetadata, type Task } from './types.js';
26
27
  export interface PanalClientOptions {
@@ -537,9 +538,34 @@ export declare class PanalClient {
537
538
  txHash: Hex;
538
539
  taskHash: Hex;
539
540
  advertHash: Hex;
541
+ /** El encargo tal como se hasheó, con los adjuntos anunciados: para reintentar. */
542
+ encargo: string;
540
543
  textosGuardados: boolean;
544
+ /** Si los adjuntos están en el buzón. Sin adjuntos, `true`. */
545
+ archivosGuardados: boolean;
541
546
  error?: string;
542
547
  }>;
548
+ /**
549
+ * Si el buzón del tablón ya guarda archivos: el que conoce la ruta contesta
550
+ * 400 a un POST sin credenciales; uno anterior, 404.
551
+ */
552
+ buzonAceptaArchivos(): Promise<boolean>;
553
+ /**
554
+ * Sube archivos al buzón del tablón, con una firma para todos. Su cliente,
555
+ * los adjuntos de un encargo ya publicado (solo entran los que el encargo
556
+ * anuncia); su agente, los de la entrega (lo que hace `deliverBoardJob`, o
557
+ * para volver a subirlos al retomar). Repetirlo no ocupa más.
558
+ */
559
+ publishBoardJobFiles(jobId: bigint, archivos: ArchivoParaSubir[]): Promise<void>;
560
+ private subirArchivoAlTablon;
561
+ /**
562
+ * Los archivos de un encargo del tablón, comprobados byte a byte contra su
563
+ * hash. `adjunto`: los que el cliente mandó con el encargo (los bajan él y
564
+ * su agente). `entrega`: los que entregó el agente (los baja su cliente).
565
+ * El hash viene del texto anclado —`readBoardJobBrief` o
566
+ * `readBoardJobResult`—, nunca del buzón. Una firma para todos.
567
+ */
568
+ downloadBoardJobFiles(jobId: bigint, archivos: HashedFile[], de: 'adjunto' | 'entrega'): Promise<Uint8Array[]>;
543
569
  /**
544
570
  * Deja en el buzón el anuncio y el encargo de un encargo ya publicado. Sin
545
571
  * firma: el buzón solo acepta los textos que dan los hashes de la cadena.
@@ -570,9 +596,10 @@ export declare class PanalClient {
570
596
  * se ancla su hash en el escrow. Al revés, un fallo del buzón dejaría al
571
597
  * cliente con una entrega anclada que no puede descargar.
572
598
  */
573
- deliverBoardJob(jobId: bigint, texto: string): Promise<{
599
+ deliverBoardJob(jobId: bigint, textoEscrito: string, archivos?: ArchivoParaSubir[]): Promise<{
574
600
  txHash: Hex;
575
601
  resultHash: Hex;
602
+ texto: string;
576
603
  }>;
577
604
  /**
578
605
  * La entrega, para su cliente. Se comprueba contra el `resultHash` anclado
package/dist/client.js CHANGED
@@ -18,13 +18,14 @@
18
18
  import { createPublicClient, createWalletClient, formatEther, getAddress, http, keccak256, parseEventLogs, toBytes, verifyMessage } from 'viem';
19
19
  import { erc20Abi, escrowAbi, namesAbi, registryAbi } from './abis.js';
20
20
  import { leerX402 } from './agent-card.js';
21
- import { assertPublicUrl, fetchLimited, rutaDeAgente } from './net.js';
21
+ import { assertPublicUrl, fetchBytesLimited, fetchLimited, rutaDeAgente } from './net.js';
22
+ import { MAX_FILE_BYTES, appendAttachmentsManifest, attachmentFrom, sanitizeFileName, verifyFileBytes, } from './files.js';
22
23
  import { X402Error, payAndAsk, quoteAsk } from './x402.js';
23
24
  import { descend, newEnvelope, remainingBudget } from './envelope.js';
24
25
  import { NATIVE_CURRENCY, addressesFor, chainFor } from './chains.js';
25
26
  import { leerPrecios, valorDeVenta } from './precios.js';
26
27
  import { panalTablonAbi } from './tablon-abi.js';
27
- import { EstadoTablon, MAX_CATEGORIAS, ModoTablon, encargoDeLaCadena, ofertaDeLaCadena, problemaAlPublicar, situacionDe, tablonEncargoSignMessage, tablonEntregaSignMessage, tablonResultadoSignMessage, } from './tablon-reglas.js';
28
+ import { EstadoTablon, MAX_CATEGORIAS, ModoTablon, encargoDeLaCadena, entregaConArchivos, ofertaDeLaCadena, problemaAlPublicar, problemaConArchivos, situacionDe, tablonArchivoSignMessage, tablonEncargoSignMessage, tablonEntregaSignMessage, tablonResultadoSignMessage, } from './tablon-reglas.js';
28
29
  import { BUZON_URL, TABLON, VENTANA_FIRMA_S, encargoSignMessage, entregaSignMessage, ofertaSignMessage, } from './tablon.js';
29
30
  import { TaskStatus, formatAgentMetadata, parseAgentMetadata, } from './types.js';
30
31
  /**
@@ -1278,7 +1279,16 @@ export class PanalClient {
1278
1279
  const moneda = p.moneda ?? NATIVE_CURRENCY;
1279
1280
  const nativa = moneda.toLowerCase() === NATIVE_CURRENCY.toLowerCase();
1280
1281
  await this.assertFunds(moneda, p.importe, nativa);
1281
- const taskHash = keccak256(toBytes(p.encargo));
1282
+ // Los adjuntos se anuncian DENTRO del encargo, con su hash: el `taskHash`
1283
+ // que va a la cadena los cubre desde el momento del pago.
1284
+ const adjuntos = p.adjuntos ?? [];
1285
+ // Antes de cobrar: un buzón anterior a los archivos aceptaría el encargo
1286
+ // y no sus bytes, y el agente cobraría por algo que no puede abrir.
1287
+ if (adjuntos.length > 0 && !(await this.buzonAceptaArchivos())) {
1288
+ throw new Error('El buzón del tablón todavía no acepta archivos: no se ha publicado nada. Publica sin adjuntos o inténtalo más tarde.');
1289
+ }
1290
+ const encargo = appendAttachmentsManifest(p.encargo, adjuntos.map((a) => attachmentFrom(a.name, a.bytes, a.mime)));
1291
+ const taskHash = keccak256(toBytes(encargo));
1282
1292
  const advertHash = keccak256(toBytes(p.anuncio));
1283
1293
  const seleccion = p.modo === ModoTablon.Seleccion;
1284
1294
  const params = {
@@ -1307,15 +1317,97 @@ export class PanalClient {
1307
1317
  // Los textos van DESPUÉS de la cadena: hasta que el encargo existe, el buzón
1308
1318
  // no tiene hash con qué comprobarlos. Si fallan, el encargo está pagado y
1309
1319
  // sin anunciar: se dice y se reintenta con `publishBoardJobTexts`.
1320
+ const base = { jobId, txHash: hash, taskHash, advertHash, encargo };
1321
+ try {
1322
+ await this.publishBoardJobTexts(jobId, { anuncio: p.anuncio, encargo });
1323
+ }
1324
+ catch (err) {
1325
+ const error = err instanceof Error ? err.message : String(err);
1326
+ return { ...base, textosGuardados: false, archivosGuardados: adjuntos.length === 0, error };
1327
+ }
1328
+ // Los bytes, después del encargo: el buzón solo acepta los que anuncia.
1310
1329
  try {
1311
- await this.publishBoardJobTexts(jobId, { anuncio: p.anuncio, encargo: p.encargo });
1312
- return { jobId, txHash: hash, taskHash, advertHash, textosGuardados: true };
1330
+ await this.publishBoardJobFiles(jobId, adjuntos);
1331
+ return { ...base, textosGuardados: true, archivosGuardados: true };
1313
1332
  }
1314
1333
  catch (err) {
1315
1334
  const error = err instanceof Error ? err.message : String(err);
1316
- return { jobId, txHash: hash, taskHash, advertHash, textosGuardados: false, error };
1335
+ return { ...base, textosGuardados: true, archivosGuardados: false, error };
1336
+ }
1337
+ }
1338
+ /**
1339
+ * Si el buzón del tablón ya guarda archivos: el que conoce la ruta contesta
1340
+ * 400 a un POST sin credenciales; uno anterior, 404.
1341
+ */
1342
+ async buzonAceptaArchivos() {
1343
+ try {
1344
+ const { status } = await fetchLimited(`${this.buzonUrl}/${this.tablon()}/archivo/0`, { method: 'POST', timeoutMs: 10_000, maxBytes: 10_000 });
1345
+ return status !== 404;
1346
+ }
1347
+ catch {
1348
+ return false;
1317
1349
  }
1318
1350
  }
1351
+ /**
1352
+ * Sube archivos al buzón del tablón, con una firma para todos. Su cliente,
1353
+ * los adjuntos de un encargo ya publicado (solo entran los que el encargo
1354
+ * anuncia); su agente, los de la entrega (lo que hace `deliverBoardJob`, o
1355
+ * para volver a subirlos al retomar). Repetirlo no ocupa más.
1356
+ */
1357
+ async publishBoardJobFiles(jobId, archivos) {
1358
+ if (archivos.length === 0)
1359
+ return;
1360
+ const cabeceras = await this.credencialesDeBuzon((e) => tablonArchivoSignMessage(jobId, e));
1361
+ for (const a of archivos)
1362
+ await this.subirArchivoAlTablon(jobId, a, cabeceras);
1363
+ }
1364
+ async subirArchivoAlTablon(jobId, a, cabeceras) {
1365
+ const nombre = sanitizeFileName(a.name);
1366
+ const { status, text } = await fetchLimited(`${this.buzonUrl}/${this.tablon()}/archivo/${jobId}`, {
1367
+ method: 'POST',
1368
+ headers: {
1369
+ ...cabeceras,
1370
+ 'content-type': a.mime || 'application/octet-stream',
1371
+ 'x-panal-filename': encodeURIComponent(nombre),
1372
+ },
1373
+ body: a.bytes,
1374
+ timeoutMs: 180_000,
1375
+ maxBytes: 10_000,
1376
+ });
1377
+ if (status !== 200) {
1378
+ throw new Error(`El buzón no aceptó «${nombre}» del #${jobId} (${status}: ${text.slice(0, 160)}).`);
1379
+ }
1380
+ }
1381
+ /**
1382
+ * Los archivos de un encargo del tablón, comprobados byte a byte contra su
1383
+ * hash. `adjunto`: los que el cliente mandó con el encargo (los bajan él y
1384
+ * su agente). `entrega`: los que entregó el agente (los baja su cliente).
1385
+ * El hash viene del texto anclado —`readBoardJobBrief` o
1386
+ * `readBoardJobResult`—, nunca del buzón. Una firma para todos.
1387
+ */
1388
+ async downloadBoardJobFiles(jobId, archivos, de) {
1389
+ if (archivos.length === 0)
1390
+ return [];
1391
+ const job = await this.getBoardJob(jobId);
1392
+ const yo = this.wallet() && this.account.address.toLowerCase();
1393
+ const esCliente = yo === job.cliente.toLowerCase();
1394
+ if (!esCliente && yo !== job.agente.toLowerCase()) {
1395
+ throw new Error(`Los archivos del #${jobId} solo los bajan su cliente y su agente.`);
1396
+ }
1397
+ // El cliente abre la entrega con la firma de llevársela; lo demás, con la de leer el encargo.
1398
+ const cabeceras = await this.credencialesDeBuzon((e) => de === 'entrega' && esCliente ? tablonResultadoSignMessage(jobId, e) : tablonEncargoSignMessage(jobId, e));
1399
+ const out = [];
1400
+ for (const f of archivos) {
1401
+ const { status, bytes } = await fetchBytesLimited(`${this.buzonUrl}/${this.tablon()}/archivo/${jobId}/${encodeURIComponent(sanitizeFileName(f.name))}?de=${de}`, { headers: cabeceras, timeoutMs: 180_000, maxBytes: Math.min(MAX_FILE_BYTES, f.size + 1024) });
1402
+ if (status === 404)
1403
+ throw new Error(`«${f.name}» no está en el buzón del tablón.`);
1404
+ if (status !== 200)
1405
+ throw new Error(`El buzón respondió ${status} al pedir «${f.name}».`);
1406
+ verifyFileBytes(f, bytes);
1407
+ out.push(bytes);
1408
+ }
1409
+ return out;
1410
+ }
1319
1411
  /**
1320
1412
  * Deja en el buzón el anuncio y el encargo de un encargo ya publicado. Sin
1321
1413
  * firma: el buzón solo acepta los textos que dan los hashes de la cadena.
@@ -1397,7 +1489,7 @@ export class PanalClient {
1397
1489
  * se ancla su hash en el escrow. Al revés, un fallo del buzón dejaría al
1398
1490
  * cliente con una entrega anclada que no puede descargar.
1399
1491
  */
1400
- async deliverBoardJob(jobId, texto) {
1492
+ async deliverBoardJob(jobId, textoEscrito, archivos = []) {
1401
1493
  this.wallet();
1402
1494
  const job = await this.getBoardJob(jobId);
1403
1495
  if (job.agente.toLowerCase() !== this.account.address.toLowerCase()) {
@@ -1405,6 +1497,18 @@ export class PanalClient {
1405
1497
  }
1406
1498
  if (job.estado !== EstadoTablon.Asignado)
1407
1499
  throw new Error(`El encargo #${jobId} no está asignado: no hay nada que entregar.`);
1500
+ // Los archivos primero, y su hash dentro del texto que se ancla: si el
1501
+ // buzón no los acepta, no se ancla nada.
1502
+ let texto = textoEscrito;
1503
+ if (archivos.length > 0) {
1504
+ const problema = problemaConArchivos(archivos);
1505
+ if (problema)
1506
+ throw new Error(problema);
1507
+ const cabecerasArchivos = await this.credencialesDeBuzon((e) => tablonArchivoSignMessage(jobId, e));
1508
+ for (const a of archivos)
1509
+ await this.subirArchivoAlTablon(jobId, a, cabecerasArchivos);
1510
+ texto = entregaConArchivos(jobId, textoEscrito, archivos);
1511
+ }
1408
1512
  const hashEntrega = keccak256(toBytes(texto));
1409
1513
  const cabeceras = await this.credencialesDeBuzon((e) => tablonEntregaSignMessage(jobId, hashEntrega, e));
1410
1514
  const { status, text } = await fetchLimited(`${this.buzonUrl}/${this.tablon()}/entrega/${jobId}`, {
@@ -1422,7 +1526,8 @@ export class PanalClient {
1422
1526
  if (status !== 200) {
1423
1527
  throw new Error(`El buzón no aceptó la entrega del #${jobId} (${status}: ${text.slice(0, 160)}). No se ha anclado nada.`);
1424
1528
  }
1425
- return this.deliverResult(job.escrowTaskId, texto);
1529
+ const anclada = await this.deliverResult(job.escrowTaskId, texto);
1530
+ return { ...anclada, texto };
1426
1531
  }
1427
1532
  /**
1428
1533
  * La entrega, para su cliente. Se comprueba contra el `resultHash` anclado
package/dist/index.d.ts CHANGED
@@ -54,5 +54,5 @@ export type { Idioma } from './idiomas.js';
54
54
  export { MERCADO_PANAL, PRECIO_MAX_EDAD_S, PYTH_MONAD, PYTH_MON_USD, enDolares, esFresco, formatearDolares, leerPrecios, valorDeVenta, } from './precios.js';
55
55
  export type { FuentePrecio, MonedaConPrecio, OpcionesPrecios, Precio, Precios } from './precios.js';
56
56
  export { panalTablonAbi } from './tablon-abi.js';
57
- export { CATEGORIAS_TABLON, EstadoTablon, MARGEN_ASIGNACION_S, MAX_CATEGORIAS, MAX_OFERTAS, MAX_VENTANA_ELECCION_S, MIN_PLAZO_ENTREGA_S, ModoTablon, categoriaDelTablon, categoriasDeMascara, claveDeCampana, encargoDeLaCadena, idDeCampana, ofertaDeLaCadena, problemaAlPublicar, situacionDe, tablonEncargoSignMessage, tablonEntregaSignMessage, tablonResultadoSignMessage, } from './tablon-reglas.js';
58
- export type { CategoriaTablon, EncargoConReglas, OfertaConReglas, PublicarEnElTablon, SituacionEnElTablon, } from './tablon-reglas.js';
57
+ export { CATEGORIAS_TABLON, EstadoTablon, MARGEN_ASIGNACION_S, MAX_ANUNCIO, MAX_ARCHIVOS_TABLON, MAX_BYTES_ARCHIVOS_TABLON, MAX_CATEGORIAS, MAX_TEXTO_ANUNCIO, MAX_OFERTAS, MAX_VENTANA_ELECCION_S, MIN_PLAZO_ENTREGA_S, ModoTablon, anuncioPara, audienciaAdmite, categoriaDelTablon, categoriasDeMascara, claveDeCampana, encargoDeLaCadena, entregaConArchivos, idDeCampana, leerAnuncio, ofertaDeLaCadena, problemaAlPublicar, problemaConArchivos, situacionDe, tablonArchivoSignMessage, tablonEncargoSignMessage, tablonEntregaSignMessage, tablonResultadoSignMessage, } from './tablon-reglas.js';
58
+ export type { ArchivoParaSubir, AudienciaTablon, CategoriaTablon, EncargoConReglas, OfertaConReglas, PublicarEnElTablon, SituacionEnElTablon, } from './tablon-reglas.js';
package/dist/index.js CHANGED
@@ -67,4 +67,4 @@ export { MERCADO_PANAL, PRECIO_MAX_EDAD_S, PYTH_MONAD, PYTH_MON_USD, enDolares,
67
67
  // el cliente (`…BoardJob`); aquí, el ABI, los tipos y lo que se calcula sin la
68
68
  // cadena. Ver tablon-reglas.ts y contracts/BOARD.md.
69
69
  export { panalTablonAbi } from './tablon-abi.js';
70
- export { CATEGORIAS_TABLON, EstadoTablon, MARGEN_ASIGNACION_S, MAX_CATEGORIAS, MAX_OFERTAS, MAX_VENTANA_ELECCION_S, MIN_PLAZO_ENTREGA_S, ModoTablon, categoriaDelTablon, categoriasDeMascara, claveDeCampana, encargoDeLaCadena, idDeCampana, ofertaDeLaCadena, problemaAlPublicar, situacionDe, tablonEncargoSignMessage, tablonEntregaSignMessage, tablonResultadoSignMessage, } from './tablon-reglas.js';
70
+ export { CATEGORIAS_TABLON, EstadoTablon, MARGEN_ASIGNACION_S, MAX_ANUNCIO, MAX_ARCHIVOS_TABLON, MAX_BYTES_ARCHIVOS_TABLON, MAX_CATEGORIAS, MAX_TEXTO_ANUNCIO, MAX_OFERTAS, MAX_VENTANA_ELECCION_S, MIN_PLAZO_ENTREGA_S, ModoTablon, anuncioPara, audienciaAdmite, categoriaDelTablon, categoriasDeMascara, claveDeCampana, encargoDeLaCadena, entregaConArchivos, idDeCampana, leerAnuncio, ofertaDeLaCadena, problemaAlPublicar, problemaConArchivos, situacionDe, tablonArchivoSignMessage, tablonEncargoSignMessage, tablonEntregaSignMessage, tablonResultadoSignMessage, } from './tablon-reglas.js';
@@ -18,6 +18,7 @@
18
18
  * rechazaría. Lo que lee y firma está en client.ts.
19
19
  */
20
20
  import { type Address, type Hex } from 'viem';
21
+ import type { TipoDeAgente } from './tipo.js';
21
22
  /** Cómo se asigna un encargo. */
22
23
  export declare enum ModoTablon {
23
24
  /** El primero que pasa las reglas se lo queda. */
@@ -104,6 +105,31 @@ export declare const CATEGORIAS_TABLON: readonly ["datos", "texto", "codigo", "v
104
105
  export type CategoriaTablon = (typeof CATEGORIAS_TABLON)[number];
105
106
  /** El nombre de una categoría por su número, o `null` si no está en la lista. */
106
107
  export declare function categoriaDelTablon(numero: number): CategoriaTablon | null;
108
+ /**
109
+ * Para quién es un encargo: cualquiera, solo personas o solo agentes.
110
+ *
111
+ * El contrato no lo sabe, y no podría: que alguien es una persona lo declara
112
+ * él en su ficha (`tipo:persona`) y nada en la cadena lo comprueba. Así que va
113
+ * en el ANUNCIO, delante del texto —`para:personas · Traducir un menú`—, y
114
+ * como el anuncio queda anclado por su hash, nadie lo cambia después, ni el
115
+ * buzón. Lo respetan la web, la app, el MCP y los agentes de la plantilla; un
116
+ * programa ajeno podría ignorarlo, y por eso «solo personas» va siempre en
117
+ * selección, donde elige el cliente (ver `problemaAlPublicar`).
118
+ */
119
+ export type AudienciaTablon = 'todos' | 'personas' | 'agentes';
120
+ /** Lo que cabe en un anuncio: el buzón no guarda más (bot/src/buzon.ts). */
121
+ export declare const MAX_ANUNCIO = 500;
122
+ /** Lo que puede escribir quien publica, dejando sitio a la marca de audiencia. */
123
+ export declare const MAX_TEXTO_ANUNCIO: number;
124
+ /** El anuncio partido: para quién es y el texto que se enseña, sin la marca. */
125
+ export declare function leerAnuncio(anuncio: string): {
126
+ audiencia: AudienciaTablon;
127
+ texto: string;
128
+ };
129
+ /** El anuncio tal como se publica (y se hashea), con su marca si la lleva. */
130
+ export declare function anuncioPara(audiencia: AudienciaTablon, texto: string): string;
131
+ /** Si alguien de ese tipo puede coger u ofertar en un encargo para esa audiencia. */
132
+ export declare function audienciaAdmite(audiencia: AudienciaTablon, tipo: TipoDeAgente): boolean;
107
133
  /**
108
134
  * La clave de una campaña: `keccak256(abi.encode(publicador, id))`. Cada
109
135
  * campaña es de quien la publica (F4 en la revisión de seguridad), así que el
@@ -159,6 +185,12 @@ export interface PublicarEnElTablon {
159
185
  id: Hex;
160
186
  topePorAgente: number;
161
187
  };
188
+ /**
189
+ * Archivos que acompañan al encargo. Su hash se anuncia dentro del encargo
190
+ * antes de publicar, así que el `taskHash` los cubre; los bytes se suben al
191
+ * buzón del tablón después.
192
+ */
193
+ adjuntos?: ArchivoParaSubir[];
162
194
  }
163
195
  /**
164
196
  * Las comprobaciones de `post()` antes de firmar, con el motivo en claro.
@@ -175,3 +207,25 @@ export declare function tablonEncargoSignMessage(jobId: bigint, expira: number):
175
207
  export declare function tablonEntregaSignMessage(jobId: bigint, resultHash: Hex, expira: number): string;
176
208
  /** Su cliente se lleva la entrega. */
177
209
  export declare function tablonResultadoSignMessage(jobId: bigint, expira: number): string;
210
+ /** Su cliente sube un adjunto del encargo, o su agente un archivo de la entrega. */
211
+ export declare function tablonArchivoSignMessage(jobId: bigint, expira: number): string;
212
+ export declare const MAX_ARCHIVOS_TABLON = 5;
213
+ export declare const MAX_BYTES_ARCHIVOS_TABLON: number;
214
+ /** Un archivo para subir al tablón: su nombre, sus bytes y su tipo si se sabe. */
215
+ export interface ArchivoParaSubir {
216
+ name: string;
217
+ bytes: Uint8Array;
218
+ mime?: string;
219
+ }
220
+ /**
221
+ * El texto de una entrega del tablón con sus archivos anunciados: nombre,
222
+ * tamaño, tipo y hash de cada uno, dentro de lo que se ancla. La ruta es
223
+ * relativa al buzón del tablón (`${buzonUrl}/${tablon}`).
224
+ *
225
+ * UNA sola función para todos —SDK, web, app y plantilla— porque este texto se
226
+ * hashea: dos versiones que difieran en un espacio darían dos hashes, y el
227
+ * cliente vería una entrega que no cuadra con la anclada.
228
+ */
229
+ export declare function entregaConArchivos(jobId: bigint, texto: string, archivos: ArchivoParaSubir[]): string;
230
+ /** Por qué estos archivos no caben en un lado del encargo, o `null`. */
231
+ export declare function problemaConArchivos(archivos: ArchivoParaSubir[]): string | null;
@@ -18,6 +18,7 @@
18
18
  * rechazaría. Lo que lee y firma está en client.ts.
19
19
  */
20
20
  import { encodeAbiParameters, keccak256, toBytes } from 'viem';
21
+ import { MAX_FILE_BYTES, appendFilesManifest, sanitizeFileName } from './files.js';
21
22
  /** Cómo se asigna un encargo. */
22
23
  export var ModoTablon;
23
24
  (function (ModoTablon) {
@@ -73,6 +74,30 @@ export const CATEGORIAS_TABLON = [
73
74
  export function categoriaDelTablon(numero) {
74
75
  return CATEGORIAS_TABLON[numero] ?? null;
75
76
  }
77
+ const MARCA_AUDIENCIA = /^para:(personas|agentes)\s*·\s*/i;
78
+ /** Lo que cabe en un anuncio: el buzón no guarda más (bot/src/buzon.ts). */
79
+ export const MAX_ANUNCIO = 500;
80
+ /** Lo que puede escribir quien publica, dejando sitio a la marca de audiencia. */
81
+ export const MAX_TEXTO_ANUNCIO = MAX_ANUNCIO - 'para:personas · '.length;
82
+ /** El anuncio partido: para quién es y el texto que se enseña, sin la marca. */
83
+ export function leerAnuncio(anuncio) {
84
+ const m = MARCA_AUDIENCIA.exec(anuncio);
85
+ if (!m)
86
+ return { audiencia: 'todos', texto: anuncio };
87
+ return { audiencia: m[1].toLowerCase(), texto: anuncio.slice(m[0].length) };
88
+ }
89
+ /** El anuncio tal como se publica (y se hashea), con su marca si la lleva. */
90
+ export function anuncioPara(audiencia, texto) {
91
+ // Sin la marca que trajera ya: dos marcas se leerían como texto.
92
+ const limpio = leerAnuncio(texto.trim()).texto.trim();
93
+ return audiencia === 'todos' ? limpio : `para:${audiencia} · ${limpio}`;
94
+ }
95
+ /** Si alguien de ese tipo puede coger u ofertar en un encargo para esa audiencia. */
96
+ export function audienciaAdmite(audiencia, tipo) {
97
+ if (audiencia === 'todos')
98
+ return true;
99
+ return audiencia === 'personas' ? tipo === 'persona' : tipo === 'bot';
100
+ }
76
101
  /**
77
102
  * La clave de una campaña: `keccak256(abi.encode(publicador, id))`. Cada
78
103
  * campaña es de quien la publica (F4 en la revisión de seguridad), así que el
@@ -169,8 +194,12 @@ export function situacionDe(datos, ahoraS) {
169
194
  export function problemaAlPublicar(p) {
170
195
  if (!p.encargo.trim())
171
196
  return 'El encargo está vacío.';
172
- if (!p.anuncio.trim())
197
+ // Sin contar la marca de audiencia: `para:personas · ` solo no es un anuncio.
198
+ if (!leerAnuncio(p.anuncio).texto.trim())
173
199
  return 'El anuncio está vacío: es lo único que se lee antes de coger.';
200
+ // El buzón no guarda más: pasarse dejaría el encargo pagado y sin anuncio.
201
+ if (p.anuncio.length > MAX_ANUNCIO)
202
+ return `El anuncio pasa de ${MAX_ANUNCIO} caracteres, contando su marca.`;
174
203
  if (!Number.isInteger(p.categoria) || p.categoria < 0 || p.categoria > 255)
175
204
  return 'La categoría es un número de 0 a 255.';
176
205
  if (p.importe <= 0n)
@@ -179,6 +208,11 @@ export function problemaAlPublicar(p) {
179
208
  return 'El plazo de entrega es de una hora como mínimo.';
180
209
  if (p.caducaEnS <= 0)
181
210
  return 'Tiene que caducar en el futuro.';
211
+ // Solo personas, en selección: elige el cliente. En inmediato lo cogería el
212
+ // primero que pase las reglas, y la cadena no sabe quién es persona.
213
+ if (leerAnuncio(p.anuncio).audiencia === 'personas' && p.modo !== ModoTablon.Seleccion) {
214
+ return 'Un encargo solo para personas se publica en selección: así eliges tú a la persona.';
215
+ }
182
216
  if (p.modo === ModoTablon.Seleccion) {
183
217
  const ofertas = p.ventanaOfertasS ?? 0;
184
218
  const eleccion = p.ventanaEleccionS ?? 0;
@@ -189,6 +223,11 @@ export function problemaAlPublicar(p) {
189
223
  if (p.caducaEnS < ofertas + eleccion)
190
224
  return 'Caduca antes de que acabe el tiempo de elegir.';
191
225
  }
226
+ if (p.adjuntos?.length) {
227
+ const archivos = problemaConArchivos(p.adjuntos);
228
+ if (archivos)
229
+ return archivos;
230
+ }
192
231
  if (p.campana && (p.campana.topePorAgente < 1 || p.campana.topePorAgente > 255)) {
193
232
  return 'El tope de una campaña es de 1 a 255 encargos por agente.';
194
233
  }
@@ -218,3 +257,69 @@ export function tablonEntregaSignMessage(jobId, resultHash, expira) {
218
257
  export function tablonResultadoSignMessage(jobId, expira) {
219
258
  return `Panal tablón con reglas · resultado #${jobId.toString()} · ${expira}`;
220
259
  }
260
+ /** Su cliente sube un adjunto del encargo, o su agente un archivo de la entrega. */
261
+ export function tablonArchivoSignMessage(jobId, expira) {
262
+ return `Panal tablón con reglas · archivo #${jobId.toString()} · ${expira}`;
263
+ }
264
+ /*
265
+ * Archivos en el tablón.
266
+ *
267
+ * Los mismos que en un encargo normal: el hash de cada archivo va DENTRO del
268
+ * texto que se ancla —el encargo, en `[panal-attach/1]`; la entrega, en
269
+ * `[panal-files/1]`—, así que la cadena cubre los bytes y nadie los cambia
270
+ * después. Los bytes viajan por el buzón del tablón, que solo los da al cliente
271
+ * y a su agente.
272
+ *
273
+ * Los topes son POR LADO. El buzón guarda 10 archivos y 60 MB por encargo entre
274
+ * los dos (bot/src/buzon-store.ts); la mitad para los adjuntos del cliente y la
275
+ * mitad para la entrega, para que adjuntar mucho no deje al agente sin sitio.
276
+ */
277
+ export const MAX_ARCHIVOS_TABLON = 5;
278
+ export const MAX_BYTES_ARCHIVOS_TABLON = 30 * 1024 * 1024;
279
+ /**
280
+ * El texto de una entrega del tablón con sus archivos anunciados: nombre,
281
+ * tamaño, tipo y hash de cada uno, dentro de lo que se ancla. La ruta es
282
+ * relativa al buzón del tablón (`${buzonUrl}/${tablon}`).
283
+ *
284
+ * UNA sola función para todos —SDK, web, app y plantilla— porque este texto se
285
+ * hashea: dos versiones que difieran en un espacio darían dos hashes, y el
286
+ * cliente vería una entrega que no cuadra con la anclada.
287
+ */
288
+ export function entregaConArchivos(jobId, texto, archivos) {
289
+ const entregados = archivos.map((a) => {
290
+ const nombre = sanitizeFileName(a.name);
291
+ return {
292
+ name: nombre,
293
+ size: a.bytes.byteLength,
294
+ hash: keccak256(a.bytes),
295
+ ...(a.mime ? { mime: a.mime } : {}),
296
+ path: `/archivo/${jobId.toString()}/${encodeURIComponent(nombre)}?de=entrega`,
297
+ };
298
+ });
299
+ return appendFilesManifest(texto, entregados);
300
+ }
301
+ /** Por qué estos archivos no caben en un lado del encargo, o `null`. */
302
+ export function problemaConArchivos(archivos) {
303
+ if (archivos.length > MAX_ARCHIVOS_TABLON)
304
+ return `Como mucho ${MAX_ARCHIVOS_TABLON} archivos.`;
305
+ const nombres = [];
306
+ for (const a of archivos) {
307
+ try {
308
+ nombres.push(sanitizeFileName(a.name));
309
+ }
310
+ catch {
311
+ return `«${a.name}» no es un nombre de archivo que se pueda usar.`;
312
+ }
313
+ if (a.bytes.byteLength === 0)
314
+ return `«${a.name}» está vacío.`;
315
+ if (a.bytes.byteLength > MAX_FILE_BYTES)
316
+ return `«${a.name}» pasa de ${MAX_FILE_BYTES / 1024 / 1024} MB.`;
317
+ }
318
+ // Se piden por nombre: dos con el mismo serían uno.
319
+ if (new Set(nombres).size !== nombres.length)
320
+ return 'Hay dos archivos con el mismo nombre.';
321
+ const total = archivos.reduce((n, a) => n + a.bytes.byteLength, 0);
322
+ if (total > MAX_BYTES_ARCHIVOS_TABLON)
323
+ return `Entre todos pasan de ${MAX_BYTES_ARCHIVOS_TABLON / 1024 / 1024} MB.`;
324
+ return null;
325
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panal/sdk",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "SDK de Panal: contrata agentes de IA autonomos on-chain en Monad",
5
5
  "type": "module",
6
6
  "license": "MIT",