@ajuarezso/lienzo-mcp 0.0.0-stage → 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.
package/lib/lienzo.mjs ADDED
@@ -0,0 +1,1278 @@
1
+ // Operaciones de Lienzo sobre Firestore (contrato en docs/spec.md).
2
+ import { randomBytes } from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
4
+ import { basename, extname } from 'node:path';
5
+ import { Firestore, ErrorFirestore, filtro, y, marcaDeTiempo } from './firestore.mjs';
6
+ import { Sesion, ErrorSesion, abrirNavegador, ENTRAR_TIMEOUT_MS } from './sesion.mjs';
7
+ import { buscarSecretos, explicarBloqueo } from './secretos.mjs';
8
+ import { envolver, limpiarLinea, neutralizar, abreviarId, aviso, nuevoCodigo } from './envoltorio.mjs';
9
+ import { rutaCompartible, revisarNombre, ErrorArchivo } from './archivos.mjs';
10
+ import { INTERVALO_MS } from './esperar.mjs';
11
+ import { cerrarTimbre } from './escucha.mjs';
12
+
13
+ export const MAX_TEXTO = 20000;
14
+ export const MAX_ARCHIVO_BYTES = 200 * 1024;
15
+ /** Tope de caracteres de una respuesta de lienzo_leer / lienzo_esperar (lo que entra en el contexto del agente). */
16
+ export const MAX_RESPUESTA = 60000;
17
+ export const ID_TARJETA = /^[A-Za-z0-9_-]{1,64}$/;
18
+ const CONECTADO_MS = 2 * 60 * 1000;
19
+ const LATIDO_MS = 30 * 1000;
20
+ const RECARGA_PARTICIPANTES_MS = 30 * 1000;
21
+ const VISTA_ARCHIVO = 3000; // caracteres de un archivo que se muestran en una lista
22
+ const LIMITE_CONSULTA = 50;
23
+
24
+ /** El nombre de la persona: el de su cuenta en una línea (hasta 80) o, si no hay, la parte local del correo. */
25
+ export function nombreDeCuenta(cuenta) {
26
+ const n = limpiarLinea(cuenta?.nombre ?? '', 80);
27
+ if (n) return n;
28
+ const local = limpiarLinea(String(cuenta?.email ?? '').split('@')[0], 80);
29
+ return local || null;
30
+ }
31
+
32
+ /** «Agente de <primer nombre>» (o null si la cuenta no tiene nombre ni correo). */
33
+ export function nombreAgentePorDefecto(cuenta) {
34
+ const n = nombreDeCuenta(cuenta);
35
+ return n ? limpiarLinea(`Agente de ${n.split(/\s+/)[0]}`, 80) : null;
36
+ }
37
+
38
+ // ── listas de puntos (docs/producto.md §4) ──────────────────────────────────
39
+ // Topes pensados para que una lista entera quepa en UNA respuesta de lienzo_leer/lienzo_esperar
40
+ // (30 × 1 000 + 10 000 de contexto + encabezados ≈ 45 000 < 60 000).
41
+ export const MAX_PUNTOS = 30;
42
+ export const MAX_PUNTO = 1000;
43
+ export const MAX_TITULO_LISTA = 200;
44
+ export const MAX_CONTEXTO_LISTA = 10000;
45
+ export const MAX_RESPUESTA_LOTE = 1000;
46
+ export const ESTADOS = ['abierta', 'tomada', 'esperando', 'hecha', 'no_aplica'];
47
+ const CERRADOS = new Set(['hecha', 'no_aplica']);
48
+ const RE_PUNTO = /^(.+)-p(\d{2})$/;
49
+
50
+ /** id del punto n (1..30) de una lista: «<idLista>-p01». Cabe en ID_TARJETA (20 + 4). */
51
+ export const idPunto = (idLista, n) => `${idLista}-p${String(n).padStart(2, '0')}`;
52
+
53
+ /** Número del punto según su id (null si no tiene forma de punto). */
54
+ export const numeroPunto = (id) => {
55
+ const m = RE_PUNTO.exec(String(id ?? ''));
56
+ return m ? Number(m[2]) : null;
57
+ };
58
+
59
+ /**
60
+ * ¿`h` es un punto de la lista `padre`? Cualquiera de la sala puede crear una tarjeta con respondeA
61
+ * apuntando a una lista; solo cuentan las tareas hijas del MISMO autor, para el MISMO destinatario y con
62
+ * id «<lista>-pNN» (una tarea de seguimiento con respondeA no convierte a su padre en lista).
63
+ * Es el único filtro: lo usan la derivación del padre, el resumen al autor y el formato.
64
+ */
65
+ export function esPunto(h, padre) {
66
+ return Boolean(h && padre)
67
+ && h.tipo === 'tarea' && padre.tipo === 'tarea' && !padre.respondeA
68
+ && h.respondeA === padre.id && RE_PUNTO.exec(String(h.id))?.[1] === padre.id
69
+ && h.autorId === padre.autorId && (h.paraId ?? null) === (padre.paraId ?? null);
70
+ }
71
+
72
+ /** Estado de la lista según sus puntos: todos cerrados → hecha; todos abiertos → abierta; alguno esperando → esperando; si no, tomada. */
73
+ export function estadoDeLista(estados) {
74
+ if (estados.every((e) => CERRADOS.has(e))) return 'hecha';
75
+ if (estados.every((e) => e === 'abierta')) return 'abierta';
76
+ if (estados.includes('esperando')) return 'esperando';
77
+ return 'tomada';
78
+ }
79
+
80
+ /** «5 resueltos · 2 esperando a Lucho · 1 no aplica · 0 pendientes» */
81
+ export function resumenLista(estados, quienEspera = null) {
82
+ const n = (f) => estados.filter(f).length;
83
+ const resueltos = n((e) => e === 'hecha');
84
+ const esperando = n((e) => e === 'esperando');
85
+ const noAplica = n((e) => e === 'no_aplica');
86
+ const pendientes = estados.length - resueltos - esperando - noAplica;
87
+ return `${resueltos} resuelto${resueltos === 1 ? '' : 's'} · ${esperando} esperando${quienEspera ? ` a ${quienEspera}` : ''} · ` +
88
+ `${noAplica} no aplica · ${pendientes} pendiente${pendientes === 1 ? '' : 's'}`;
89
+ }
90
+
91
+ /**
92
+ * A quién espera una lista: una persona, nunca el agente («esperando a Agente de Beto» no dice a quién le
93
+ * toca). Persona → su nombre; agente → su `dueño`; sin `dueño`, `persona` (la de su cuenta, `{uid}` de
94
+ * `{uid}_agente`, si está en la sala); y si no, «la persona de <agente>» (agente sin cuenta).
95
+ */
96
+ export function quienEsperaLista(dest, persona = null) {
97
+ if (dest.tipo !== 'agente') return limpiarLinea(dest.nombre);
98
+ if (String(dest['dueño'] ?? '').trim()) return limpiarLinea(dest['dueño']);
99
+ if (persona?.tipo === 'persona' && String(persona.nombre ?? '').trim()) return limpiarLinea(persona.nombre);
100
+ return limpiarLinea(`la persona de ${dest.nombre}`);
101
+ }
102
+
103
+ /** Primera línea de un texto (el título de una lista), limpia y topada. */
104
+ const primeraLinea = (t, max = MAX_TITULO_LISTA) => limpiarLinea(String(t ?? '').split('\n')[0], max);
105
+
106
+ /** Las tarjetas que comparten la primera o la última hora de creación (un grupo nacido en un solo :commit). */
107
+ function quitarGrupo(lista, { alFinal }) {
108
+ if (!lista.length) return lista;
109
+ const borde = claveTiempo((alFinal ? lista[lista.length - 1] : lista[0]).creadaEn);
110
+ const resto = lista.filter((t) => claveTiempo(t.creadaEn) !== borde);
111
+ return resto.length ? resto : lista; // un solo grupo que llena el tope: se entrega igual (no se traba)
112
+ }
113
+
114
+ /** Para comparar nombres: sin controles, sin mayúsculas, ancho normal, espacios simples. */
115
+ export const claveNombre = (s) => limpiarLinea(s, 200).normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim();
116
+
117
+ const ALFABETO = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
118
+
119
+ export class ErrorLienzo extends Error {}
120
+
121
+ // Por qué una cuenta ya no puede usar una sala (explicarNegado). Textos exactos del contrato (docs/spec.md).
122
+ const SALIDA = 'Únete a otra con lienzo_unirse o crea una con lienzo_crear_sala.';
123
+ export const TEXTO_QUITADO = `Te quitaron de esta sala: quien la creó ya no te deja leer ni publicar ahí. ${SALIDA}`;
124
+ export const TEXTO_BORRADA = `Esta sala se borró: quien la creó la borró y ya nadie puede entrar. ${SALIDA}`;
125
+ export const textoSaliste = (salaId) => `Ya no estás en esta sala: tu agente salió o lo sacaron (p. ej. saliste desde la web). Para volver, usa lienzo_unirse con el salaId ${salaId}.`;
126
+
127
+ /**
128
+ * El ErrorFirestore de permiso negado que haya detrás de `e` (crudo, o conservado por _traducir en `cause`);
129
+ * null si el error es de otra cosa.
130
+ */
131
+ export function negadoDe(e) {
132
+ const f = e instanceof ErrorFirestore ? e : e?.cause instanceof ErrorFirestore ? e.cause : null;
133
+ return f?.negado ? f : null;
134
+ }
135
+
136
+ /** 22 caracteres base64url = 16 bytes aleatorios (~128 bits). */
137
+ export const nuevoSalaId = () => randomBytes(16).toString('base64url');
138
+
139
+ export function nuevoId(n = 20) {
140
+ const b = randomBytes(n);
141
+ let s = '';
142
+ for (let i = 0; i < n; i++) s += ALFABETO[b[i] % ALFABETO.length];
143
+ return s;
144
+ }
145
+
146
+ /** Clave ordenable de una marca de tiempo RFC 3339 (las fracciones vienen con distinta cantidad de dígitos). */
147
+ export function claveTiempo(iso) {
148
+ if (!iso) return '';
149
+ const m = /^(.*T\d\d:\d\d:\d\d)(?:\.(\d+))?Z$/.exec(iso);
150
+ if (!m) return new Date(iso).toISOString();
151
+ return `${m[1]}.${(m[2] || '').padEnd(9, '0').slice(0, 9)}Z`;
152
+ }
153
+
154
+ const MIME = {
155
+ '.md': 'text/markdown', '.txt': 'text/plain', '.json': 'application/json', '.csv': 'text/csv',
156
+ '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.mjs': 'text/javascript',
157
+ '.ts': 'text/typescript', '.py': 'text/x-python', '.yml': 'text/yaml', '.yaml': 'text/yaml',
158
+ '.xml': 'application/xml', '.svg': 'image/svg+xml', '.sh': 'text/x-shellscript', '.sql': 'text/x-sql',
159
+ };
160
+
161
+ export class Lienzo {
162
+ /**
163
+ * @param {object} config de cargarConfig()
164
+ * @param {{ fetch?: Function, raizArchivos?: string, abrir?: Function }} o raizArchivos: carpeta de la que se
165
+ * pueden compartir archivos (por defecto el directorio de trabajo del proceso); abrir: cómo se abre el navegador
166
+ */
167
+ constructor(config, { fetch: f, raizArchivos, abrir = abrirNavegador } = {}) {
168
+ this.config = config;
169
+ this.raizArchivos = raizArchivos ?? null;
170
+ this.abrir = abrir;
171
+ this.entrada = null; // login en curso: { promesa, url }
172
+ this.sesion = new Sesion(config, { fetch: f });
173
+ this.fs = new Firestore(config, this.sesion, { fetch: f });
174
+ this.salaId = null;
175
+ this.yo = null; // { id, nombre, dueño }
176
+ this.marca = null; // creadaEn más reciente ya entregada
177
+ this.marcaEdicion = null; // editadaEn más reciente ya entregada
178
+ this.uidEnSala = null; // con qué identidad se entró (si la sesión cambia, se empieza de nuevo)
179
+ this.intervaloEspera = INTERVALO_MS; // lo que lleva creciendo la espera de lienzo_esperar
180
+ this.participantes = new Map();
181
+ this.participantesEn = 0;
182
+ this.tareasPedidas = new Map(); // id → { estado, respuesta, tomadaPor }
183
+ this.tareasCargadas = false;
184
+ this.listas = new Set(); // ids de las listas que pediste (sus puntos llegan agrupados)
185
+ this.ultimoLatido = 0;
186
+ this.actividad = null; // lo último escrito en participantes.actividad ('esperando' | 'escribiendo' | null)
187
+ this.latidoFallo = null; // el error del último latido (null si anduvo)
188
+ // Salas de las que te sacaron (salaId → { uid, texto }): LIENZO_SALA no las vuelve a abrir sola. Cada marca lleva la
189
+ // cuenta a la que le pasó: si la sesión cambia de cuenta deja de valer.
190
+ this.salasPerdidas = new Map();
191
+ this.ultimaPerdida = null; // { uid, salaId, texto }: la última, para decir la causa cuando ya no hay sala
192
+ }
193
+
194
+ // ── utilidades ─────────────────────────────────────────────────────────────
195
+ /** La cuenta (la persona). */
196
+ get uid() { return this.sesion.uid; }
197
+
198
+ /** El participante de este agente en la sala: `{uid}_agente`. */
199
+ get participanteId() { return this.uid ? `${this.uid}_agente` : null; }
200
+
201
+ /** ¿Este participante es de mi cuenta (mi persona o yo, su agente)? Lo mismo que esMio() en las reglas. */
202
+ esMio(id) {
203
+ return Boolean(id) && (id === this.uid || id === this.participanteId);
204
+ }
205
+
206
+ link(salaId = this.salaId) {
207
+ return `${this.config.webUrl}/sala/${salaId}`;
208
+ }
209
+
210
+ /** Enlace directo a una tarjeta: la web abre la sala, centra el nodo, abre su hilo y la resalta. */
211
+ linkTarjeta(id, salaId = this.salaId) {
212
+ return `${this.link(salaId)}?t=${encodeURIComponent(id)}`;
213
+ }
214
+
215
+ _vigilarSecretos(textos) {
216
+ for (const [que, texto] of textos) {
217
+ if (!texto) continue;
218
+ const h = buscarSecretos(texto, { permitidas: [this.config.apiKey] });
219
+ if (h.length) throw new ErrorLienzo(explicarBloqueo(h, que));
220
+ }
221
+ }
222
+
223
+ _traducir(e, que) {
224
+ if (e instanceof ErrorSesion || e instanceof ErrorArchivo) return new ErrorLienzo(e.message);
225
+ if (e instanceof ErrorFirestore) {
226
+ // la causa viaja: el catch de cada herramienta decide si el permiso negado tiene una explicación (explicarNegado)
227
+ if (e.negado) return new ErrorLienzo(`Las reglas de la sala no permiten ${que}. (${e.message})`, { cause: e });
228
+ if (e.noExiste) return new ErrorLienzo(`No existe: ${que}.`, { cause: e });
229
+ if (e.yaExiste) return new ErrorLienzo(`Ya existe: ${que}.`, { cause: e });
230
+ }
231
+ return e;
232
+ }
233
+
234
+ async _asegurarSala() {
235
+ if (this.salaId && this.yo) {
236
+ // otra ventana con el mismo LIENZO_HOME pudo conectar otra cuenta: token() recarga sesion.json
237
+ await this.sesion.token().catch((e) => { throw this._traducir(e, 'iniciar sesión'); });
238
+ if (this.uidEnSala && this.uidEnSala !== this.uid) {
239
+ this._olvidarSala();
240
+ const quien = limpiarLinea(nombreDeCuenta(this.sesion.cuenta) || 'otra cuenta');
241
+ throw new ErrorLienzo(`La cuenta de Lienzo cambió en otra ventana (ahora ${quien}). Vuelve a unirte con lienzo_unirse.`);
242
+ }
243
+ return;
244
+ }
245
+ const { salaId } = this.config;
246
+ if (salaId) {
247
+ // te sacaron (o saliste) de esta sala: no se vuelve a entrar sola hasta un lienzo_unirse explícito
248
+ const perdida = this._perdida(salaId);
249
+ if (perdida) throw new ErrorLienzo(perdida.texto);
250
+ // unión automática con la sala configurada (sin sesión, se pide lienzo_entrar)
251
+ await this.unirse({ salaId });
252
+ return;
253
+ }
254
+ // te sacaron de la sala y todavía no entras a otra: la causa, no un genérico
255
+ if (this.ultimaPerdida?.uid === this.uid) throw new ErrorLienzo(this.ultimaPerdida.texto);
256
+ throw new ErrorLienzo(
257
+ 'Todavía no estás en una sala. Usa lienzo_unirse(salaId) o crea una con lienzo_crear_sala.',
258
+ );
259
+ }
260
+
261
+ /**
262
+ * Latido: actualiza vistoEn como mucho cada 30 s. Lo llama cada herramienta.
263
+ * `actividad` (presencia, 20-10-2026): qué ve la web en el nodo del agente. 'esperando' al entrar a
264
+ * lienzo_esperar, 'escribiendo' cuando le llegó algo nuevo, null al publicar. Solo se escribe cuando
265
+ * cambia, en el mismo latido (una escritura): la web la da por vieja cuando vistoEn envejece.
266
+ */
267
+ async latido(forzar = false, actividad = undefined) {
268
+ if (!this.salaId || !this.yo) return;
269
+ const cambia = actividad !== undefined && actividad !== this.actividad;
270
+ if (!forzar && !cambia && Date.now() - this.ultimoLatido < LATIDO_MS) return;
271
+ this.ultimoLatido = Date.now();
272
+ this.latidoFallo = null;
273
+ try {
274
+ await this.fs.escribir(`salas/${this.salaId}/participantes/${this.participanteId}`, cambia ? { actividad } : {}, {
275
+ existe: true, mascara: cambia ? ['actividad'] : [], horaServidor: ['vistoEn'],
276
+ });
277
+ if (cambia) this.actividad = actividad;
278
+ } catch (e) {
279
+ console.error(`[lienzo] latido falló: ${e.message}`);
280
+ this.latidoFallo = e;
281
+ // Con reglas anteriores a `actividad` (despliegue a medias) el campo se rechaza: el latido no se pierde.
282
+ if (cambia) {
283
+ await this.fs.escribir(`salas/${this.salaId}/participantes/${this.participanteId}`, {}, {
284
+ existe: true, mascara: [], horaServidor: ['vistoEn'],
285
+ }).then(() => { this.latidoFallo = null; }, (e2) => { this.latidoFallo = e2; });
286
+ }
287
+ }
288
+ }
289
+
290
+ /**
291
+ * ¿El último latido se negó porque ya no eres miembro? El latido en sí se traga su error (no clasifica ni olvida
292
+ * nada); lienzo_esperar lo mira DESPUÉS para enterarse sin gastar una lectura: con el timbre callado (el servidor no
293
+ * siempre corta el listener cuando pierdes el acceso) es la única señal de que te sacaron. Devuelve el texto de
294
+ * explicarNegado o null (el latido anduvo, o falló por otra cosa).
295
+ */
296
+ async explicarLatido() {
297
+ const f = this.latidoFallo;
298
+ this.latidoFallo = null; // se mira una vez por latido
299
+ if (!f || !this.salaId || !(negadoDe(f) || (f instanceof ErrorFirestore && f.noExiste))) return null;
300
+ return this.explicarNegado(this.salaId).catch(() => null);
301
+ }
302
+
303
+ /**
304
+ * ¿Lo que trajo lienzo_esperar le toca a este agente? Algo dirigido a él (paraId) o un cambio en una tarea
305
+ * que pidió. Solo entonces la web muestra «Escribiendo…»: un mensaje a toda la sala no pone a todos los
306
+ * agentes a escribir.
307
+ */
308
+ meToca(lote) {
309
+ if (!lote) return false;
310
+ const mio = (t) => t.autorId !== this.participanteId && t.paraId === this.participanteId;
311
+ return (lote.cambios?.length ?? 0) > 0 || (lote.tarjetas ?? []).some(mio) || (lote.editadas ?? []).some(mio);
312
+ }
313
+
314
+ async _cargarParticipantes(forzar = false) {
315
+ if (!forzar && Date.now() - this.participantesEn < RECARGA_PARTICIPANTES_MS && this.participantes.size) return;
316
+ const lista = await this.fs.consultar(`salas/${this.salaId}`, {
317
+ from: [{ collectionId: 'participantes' }],
318
+ limit: 500,
319
+ });
320
+ this.participantes = new Map(lista.map((p) => [p.id, p]));
321
+ this.participantesEn = Date.now();
322
+ }
323
+
324
+ async _participante(id) {
325
+ if (!id) return null;
326
+ if (!this.participantes.has(id)) await this._cargarParticipantes(true);
327
+ return this.participantes.get(id) || null;
328
+ }
329
+
330
+ /** Cuántos participantes de la sala se llaman igual que `id` (1 = nombre único). */
331
+ _homonimos(id) {
332
+ const p = this.participantes.get(id);
333
+ if (!p) return 0;
334
+ const n = claveNombre(p.nombre);
335
+ return [...this.participantes.values()].filter((q) => claveNombre(q.nombre) === n).length;
336
+ }
337
+
338
+ /**
339
+ * Acepta un id de participante o su nombre exacto (sin importar mayúsculas). Con el nombre se mira
340
+ * la lista recién leída; si dos se llaman igual, NO se elige: se devuelve la lista de ids.
341
+ */
342
+ async _resolverParticipante(ref) {
343
+ if (!ref) return null;
344
+ ref = String(ref).trim();
345
+ await this._cargarParticipantes();
346
+ if (this.participantes.has(ref)) return ref;
347
+ await this._cargarParticipantes(true);
348
+ if (this.participantes.has(ref)) return ref;
349
+ const n = claveNombre(ref);
350
+ const porNombre = [...this.participantes.values()].filter((p) => claveNombre(p.nombre) === n);
351
+ if (porNombre.length === 1) return porNombre[0].id;
352
+ if (porNombre.length > 1) {
353
+ const lista = porNombre.map((p) => `- id: ${p.id}${p['dueño'] ? ` (dueño: ${limpiarLinea(p['dueño'])})` : ''}${p.id === this.participanteId ? ' (tú)' : ''}${p.id === this.uid ? ' (tu usuario)' : ''}`).join('\n');
354
+ throw new ErrorLienzo(`Hay ${porNombre.length} participantes llamados «${limpiarLinea(ref)}»; no elijo uno por ti. Usa el id:\n${lista}`);
355
+ }
356
+ throw new ErrorLienzo(`No encontré al participante «${limpiarLinea(ref)}» en la sala. Revisa lienzo_participantes.`);
357
+ }
358
+
359
+ // ── salas ──────────────────────────────────────────────────────────────────
360
+ async crearSala({ nombre, descripcion = '' }) {
361
+ nombre = String(nombre || '').trim();
362
+ if (!nombre) throw new ErrorLienzo('La sala necesita un nombre.');
363
+ if (nombre.length > 120) throw new ErrorLienzo('El nombre de la sala admite hasta 120 caracteres.');
364
+ if (String(descripcion).length > 2000) throw new ErrorLienzo('La descripción admite hasta 2000 caracteres.');
365
+ this._vigilarSecretos([['el nombre de la sala', nombre], ['la descripción', descripcion]]);
366
+ await this.sesion.token().catch((e) => { throw this._traducir(e, 'iniciar sesión'); });
367
+ const salaId = nuevoSalaId();
368
+ try {
369
+ // creadaPor es la cuenta (no el participante): así la persona renombra en la web la sala que creó su agente
370
+ await this.fs.escribir(`salas/${salaId}`, { nombre, descripcion, creadaPor: this.uid }, {
371
+ crear: true, horaServidor: ['creadaEn'],
372
+ });
373
+ } catch (e) {
374
+ throw this._traducir(e, 'crear la sala');
375
+ }
376
+ await this._anotarEnIndice(salaId);
377
+ return { salaId, link: this.link(salaId) };
378
+ }
379
+
380
+ /** usuarios/{uid}/salas/{salaId} = { vistaEn: hora del servidor }: «Mis salas» y la última sala de la persona. */
381
+ async _anotarEnIndice(salaId) {
382
+ try {
383
+ await this.fs.escribir(`usuarios/${this.uid}/salas/${salaId}`, {}, { horaServidor: ['vistaEn'] });
384
+ } catch (e) {
385
+ // ya entraste: sin el índice solo falta la sala en «Mis salas» (como en la web)
386
+ console.error(`[lienzo] no pude anotar la sala ${salaId} en tu lista de salas: ${e.message}`);
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Entra a la sala como `{uid}_agente` (tipo agente, dueñoUid = la cuenta, dueño = el nombre de la cuenta).
392
+ * nombre: el que se pase, LIENZO_NOMBRE o «Agente de <primer nombre de la cuenta>».
393
+ */
394
+ async unirse({ salaId, nombre }) {
395
+ salaId = String(salaId || '').trim();
396
+ // aceptar también el link completo de la web
397
+ const m = /\/sala\/([A-Za-z0-9_-]{22})/.exec(salaId);
398
+ if (m) salaId = m[1];
399
+ if (!/^[A-Za-z0-9_-]{22}$/.test(salaId)) throw new ErrorLienzo('El salaId tiene 22 caracteres (letras, números, - y _). Revisa el que te pasaron.');
400
+
401
+ await this.sesion.token().catch((e) => { throw this._traducir(e, 'iniciar sesión'); });
402
+ const cuenta = this.sesion.cuenta;
403
+ const dueño = nombreDeCuenta(cuenta);
404
+ nombre = String(nombre || this.config.nombre || nombreAgentePorDefecto(cuenta) || '').trim();
405
+ if (!nombre) throw new ErrorLienzo('Falta tu nombre en la sala (p. ej. «Agente de Ana»): pásalo en nombre.');
406
+ if (nombre.length > 80) throw new ErrorLienzo('El nombre admite hasta 80 caracteres.');
407
+ if (/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(nombre)) throw new ErrorLienzo('El nombre va en una sola línea, sin caracteres de control ni invisibles.');
408
+ this._vigilarSecretos([['tu nombre', nombre]]);
409
+
410
+ const sala = await this.fs.leer(`salas/${salaId}`).catch((e) => { throw this._traducir(e, 'leer la sala'); });
411
+ if (!sala) throw new ErrorLienzo('Esa sala no existe. Revisa el salaId.');
412
+ // borrada: las reglas ya no dejan escribir ahí; se avisa sin tocar nada
413
+ if (sala.borradaEn) {
414
+ await this._sacadaDe(salaId, TEXTO_BORRADA);
415
+ throw new ErrorLienzo(TEXTO_BORRADA);
416
+ }
417
+
418
+ const ruta = `salas/${salaId}/participantes/${this.participanteId}`;
419
+ const previo = await this.fs.leer(ruta).catch(() => null);
420
+ const datos = {
421
+ nombre, tipo: 'agente', 'dueño': dueño, 'dueñoUid': this.uid, cliente: this.config.cliente,
422
+ posicion: previo?.posicion ?? null,
423
+ };
424
+ let commit;
425
+ try {
426
+ if (previo) {
427
+ datos.conectadoEn = previo.conectadoEn ? marcaDeTiempo(previo.conectadoEn) : null;
428
+ commit = await this.fs.escribir(ruta, datos, { horaServidor: previo.conectadoEn ? ['vistoEn'] : ['vistoEn', 'conectadoEn'] });
429
+ } else {
430
+ commit = await this.fs.escribir(ruta, datos, { horaServidor: ['conectadoEn', 'vistoEn'] });
431
+ }
432
+ } catch (e) {
433
+ const texto = negadoDe(e) ? await this.explicarNegado(salaId, { alUnirse: true }).catch(() => null) : null;
434
+ throw texto ? new ErrorLienzo(texto, { cause: e }) : this._traducir(e, 'unirte a la sala');
435
+ }
436
+
437
+ this.salasPerdidas.delete(salaId); // un lienzo_unirse explícito vuelve a abrir la sala
438
+ this.ultimaPerdida = null;
439
+ const cambioDeSala = this.salaId !== salaId || this.uidEnSala !== this.uid;
440
+ this.salaId = salaId;
441
+ this.uidEnSala = this.uid;
442
+ this.actividad = null; // unirse reescribe el participante entero (sin actividad)
443
+ this.yo = { id: this.participanteId, nombre, 'dueño': dueño };
444
+ this.ultimoLatido = Date.now();
445
+ if (cambioDeSala) {
446
+ this.marca = commit; // las novedades se cuentan desde que entraste
447
+ this.marcaEdicion = commit;
448
+ this.intervaloEspera = INTERVALO_MS;
449
+ this.tareasPedidas.clear();
450
+ this.tareasCargadas = false;
451
+ this.listas.clear();
452
+ this.participantes.clear();
453
+ this.participantesEn = 0;
454
+ }
455
+ await this._anotarEnIndice(salaId);
456
+ return { salaId, sala, link: this.link(salaId), yo: this.yo };
457
+ }
458
+
459
+ // ── cuenta ─────────────────────────────────────────────────────────────────
460
+ /** Olvida la sala (la sesión pasó a otra cuenta: lo de la sala era de la anterior). */
461
+ _olvidarSala() {
462
+ this.salaId = null;
463
+ this.yo = null;
464
+ this.uidEnSala = null;
465
+ }
466
+
467
+ /**
468
+ * Te sacaron de `salaId` (o saliste, o se borró): si es la sala en la que estabas, se olvida (y se cierra su
469
+ * timbre, que ya no tiene permiso) y se anota para que LIENZO_SALA no la reabra sola.
470
+ */
471
+ async _sacadaDe(salaId, texto) {
472
+ // la marca va primero: cerrar el timbre despierta a un lienzo_esperar que ya mira la sala, y tiene que ver la causa
473
+ const marca = { uid: this.uid, texto };
474
+ this.salasPerdidas.set(salaId, marca);
475
+ this.ultimaPerdida = { ...marca, salaId };
476
+ if (this.salaId === salaId) {
477
+ this._olvidarSala();
478
+ await cerrarTimbre(this).catch(() => undefined);
479
+ }
480
+ }
481
+
482
+ /** La marca de una sala perdida, si es de la cuenta de ahora; la de otra cuenta se descarta. */
483
+ _perdida(salaId) {
484
+ const m = this.salasPerdidas.get(salaId);
485
+ if (m && m.uid !== this.uid) { this.salasPerdidas.delete(salaId); return null; }
486
+ return m ?? null;
487
+ }
488
+
489
+ /** Tras conectar otra cuenta: lo que le pasó a la anterior no es de la nueva. */
490
+ _soltarPerdidasAjenas() {
491
+ for (const [id, m] of this.salasPerdidas) if (m.uid !== this.uid) this.salasPerdidas.delete(id);
492
+ if (this.ultimaPerdida && this.ultimaPerdida.uid !== this.uid) this.ultimaPerdida = null;
493
+ }
494
+
495
+ /**
496
+ * Un permiso negado en `salaId`: ¿por qué? Devuelve el texto para el agente, o null si no se sabe (la
497
+ * negativa viene de otra cosa, p. ej. el contenido de una tarjeta, o las lecturas fallaron por red: se queda el
498
+ * mensaje original). Solo corre cuando algo ya falló: el camino feliz no hace ninguna lectura de más.
499
+ * 1. estás en `expulsados` → te quitaron (borra tu índice, como la web)
500
+ * 2. la sala tiene `borradaEn` → se borró
501
+ * 3. tu participante `{uid}_agente` no existe o no se deja leer → saliste (no aplica al unirse: ahí no eres miembro todavía)
502
+ * 4. sigues siendo miembro → null
503
+ * Con 1 a 3 se olvida la sala.
504
+ */
505
+ async explicarNegado(salaId, { alUnirse = false } = {}) {
506
+ if (!salaId || !this.uid) return null;
507
+ const leer = (ruta) => this.fs.leer(ruta).then((doc) => ({ doc }), (error) => ({ error }));
508
+ const [exp, sala, yo] = await Promise.all([
509
+ leer(`salas/${salaId}/expulsados/${this.uid}`),
510
+ leer(`salas/${salaId}`),
511
+ alUnirse ? null : leer(`salas/${salaId}/participantes/${this.participanteId}`),
512
+ ]);
513
+ if (exp.error || sala.error || !sala.doc) return null; // sin red (o sin sala) no se adivina
514
+ if (exp.doc) {
515
+ await this.fs.borrar(`usuarios/${this.uid}/salas/${salaId}`).catch(() => undefined);
516
+ await this._sacadaDe(salaId, TEXTO_QUITADO);
517
+ return TEXTO_QUITADO;
518
+ }
519
+ if (sala.doc.borradaEn) {
520
+ await this._sacadaDe(salaId, TEXTO_BORRADA);
521
+ return TEXTO_BORRADA;
522
+ }
523
+ if (!yo) return null;
524
+ if (yo.error ? Boolean(negadoDe(yo.error)) : !yo.doc) {
525
+ const texto = textoSaliste(salaId);
526
+ await this._sacadaDe(salaId, texto);
527
+ return texto;
528
+ }
529
+ return null;
530
+ }
531
+
532
+ /**
533
+ * lienzo_entrar. Con una sesión que sigue valiendo no abre nada (salvo otraCuenta). Si no, abre el navegador y
534
+ * espera hasta `esperaMs` (3 min); al vencer o cancelarse, el servidor local se cierra. La URL (lleva el nonce)
535
+ * va solo a stderr: nunca vuelve al modelo. Una segunda llamada mientras tanto reusa el mismo login (no abre otra
536
+ * ventana). Si quedó conectada otra cuenta, se olvida la sala en la que estaba (no se une sola a la nueva).
537
+ * Devuelve { estado: 'ya' | 'listo' | 'vencido', cuenta?, salaOlvidada? }.
538
+ */
539
+ async entrar({ otraCuenta = false, esperaMs = ENTRAR_TIMEOUT_MS, signal, alAbrir } = {}) {
540
+ if (!otraCuenta && !this.entrada && this.sesion.cuenta) {
541
+ try {
542
+ await this.sesion.token();
543
+ return { estado: 'ya', cuenta: this.sesion.cuenta };
544
+ } catch (e) {
545
+ if (!(e instanceof ErrorSesion)) throw e; // red: no se abre el navegador por un corte
546
+ }
547
+ }
548
+ if (!this.entrada) {
549
+ const entrada = this.sesion.entrar({ abrir: this.abrir, timeoutMs: esperaMs, signal, onListening: () => alAbrir?.() })
550
+ .then((cuenta) => {
551
+ this._soltarPerdidasAjenas();
552
+ const salaOlvidada = Boolean(this.uidEnSala && this.uidEnSala !== this.uid);
553
+ if (salaOlvidada) this._olvidarSala();
554
+ return { cuenta, salaOlvidada };
555
+ })
556
+ .finally(() => { if (this.entrada === entrada) this.entrada = null; });
557
+ this.entrada = entrada;
558
+ }
559
+ try {
560
+ const { cuenta, salaOlvidada } = await this.entrada;
561
+ // con otraCuenta no se sigue en la sala anterior ni aunque se eligiera la misma cuenta: se entra a propósito
562
+ if (otraCuenta && this.salaId) { this._olvidarSala(); return { estado: 'listo', cuenta, salaOlvidada: true }; }
563
+ return { estado: 'listo', cuenta, salaOlvidada };
564
+ } catch (e) {
565
+ if (e instanceof ErrorSesion && e.codigo === 'vencido') return { estado: 'vencido' };
566
+ throw this._traducir(e, 'conectar tu cuenta');
567
+ }
568
+ }
569
+
570
+ // ── participantes ─────────────────────────────────────────────────────────
571
+ async listarParticipantes() {
572
+ await this._asegurarSala();
573
+ await this.latido();
574
+ await this._cargarParticipantes(true);
575
+ const ahora = Date.now();
576
+ return [...this.participantes.values()].map((p) => ({
577
+ ...p,
578
+ conectado: p.vistoEn ? ahora - Date.parse(p.vistoEn) < CONECTADO_MS : false,
579
+ soyYo: p.id === this.participanteId,
580
+ miPersona: p.id === this.uid,
581
+ homonimos: this._homonimos(p.id),
582
+ }));
583
+ }
584
+
585
+ // ── tarjetas ───────────────────────────────────────────────────────────────
586
+ _tarjetaCompleta(datos) {
587
+ return {
588
+ tipo: 'mensaje', autorId: this.participanteId, texto: '', archivo: null, respondeA: null, paraId: null,
589
+ estado: null, tomadaPor: null, respuesta: null, editadaEn: null, posicion: null, marcoId: null,
590
+ ...datos,
591
+ };
592
+ }
593
+
594
+ async _crearTarjeta(datos) {
595
+ const id = nuevoId();
596
+ try {
597
+ await this.fs.escribir(`salas/${this.salaId}/tarjetas/${id}`, this._tarjetaCompleta(datos), { crear: true, horaServidor: ['creadaEn'] });
598
+ } catch (e) {
599
+ throw this._traducir(e, 'publicar la tarjeta');
600
+ }
601
+ this.intervaloEspera = INTERVALO_MS; // acabas de escribir: la respuesta puede llegar pronto
602
+ return id;
603
+ }
604
+
605
+ _validarTexto(texto, que = 'el texto') {
606
+ texto = String(texto ?? '');
607
+ if (!texto.trim()) throw new ErrorLienzo(`Falta ${que}.`);
608
+ if (texto.length > MAX_TEXTO) throw new ErrorLienzo(`${que} admite hasta ${MAX_TEXTO} caracteres (tiene ${texto.length}).`);
609
+ return texto;
610
+ }
611
+
612
+ async publicar({ texto, paraId = null, respondeA = null, tipo = 'mensaje' }) {
613
+ await this._asegurarSala();
614
+ if (!['mensaje', 'nota'].includes(tipo)) throw new ErrorLienzo('tipo es «mensaje» o «nota» (para tareas usa lienzo_pedir_tarea).');
615
+ texto = this._validarTexto(texto);
616
+ this._vigilarSecretos([['el texto', texto]]);
617
+ const para = await this._resolverParticipante(paraId);
618
+ if (respondeA && !ID_TARJETA.test(respondeA)) throw new ErrorLienzo('respondeA tiene que ser el id de una tarjeta.');
619
+ const id = await this._crearTarjeta({ tipo, texto, paraId: para, respondeA: respondeA || null });
620
+ await this.latido();
621
+ return { id, paraId: para, link: this.linkTarjeta(id) };
622
+ }
623
+
624
+ async compartirArchivo({ ruta, contenido, nombre, texto, paraId = null }) {
625
+ await this._asegurarSala();
626
+ if (!ruta && contenido == null) throw new ErrorLienzo('Pasa la ruta del archivo o su contenido.');
627
+ let bytes;
628
+ if (ruta) {
629
+ let real;
630
+ let st;
631
+ try {
632
+ ({ real, st } = rutaCompartible(ruta, this.raizArchivos ?? process.cwd()));
633
+ } catch (e) {
634
+ throw this._traducir(e, 'compartir el archivo');
635
+ }
636
+ if (st.size > MAX_ARCHIVO_BYTES) throw new ErrorLienzo(`El archivo pesa ${st.size} bytes; el tope es ${MAX_ARCHIVO_BYTES} (200 KB).`);
637
+ const buf = readFileSync(real);
638
+ if (buf.includes(0)) throw new ErrorLienzo('Solo se comparten archivos de texto (este parece binario).');
639
+ try {
640
+ contenido = new TextDecoder('utf-8', { fatal: true }).decode(buf);
641
+ } catch {
642
+ throw new ErrorLienzo('Solo se comparten archivos de texto en UTF-8.');
643
+ }
644
+ nombre = nombre || basename(real);
645
+ bytes = buf.length;
646
+ } else {
647
+ contenido = String(contenido);
648
+ bytes = Buffer.byteLength(contenido, 'utf8');
649
+ if (contenido.includes('\u0000')) throw new ErrorLienzo('Solo se comparte texto.');
650
+ }
651
+ nombre = String(nombre || '').trim();
652
+ if (!nombre) throw new ErrorLienzo('Falta el nombre del archivo.');
653
+ if (nombre.length > 200) throw new ErrorLienzo('El nombre del archivo admite hasta 200 caracteres.');
654
+ try { revisarNombre(limpiarLinea(nombre, 200)); } catch (e) { throw this._traducir(e, 'compartir el archivo'); }
655
+ if (bytes > MAX_ARCHIVO_BYTES) throw new ErrorLienzo(`El contenido pesa ${bytes} bytes; el tope es ${MAX_ARCHIVO_BYTES} (200 KB).`);
656
+ texto = texto ? this._validarTexto(texto) : `Archivo: ${nombre}`;
657
+ this._vigilarSecretos([['el archivo', contenido], ['el texto', texto], ['el nombre del archivo', nombre]]);
658
+ const para = await this._resolverParticipante(paraId);
659
+ const mime = MIME[extname(nombre).toLowerCase()] || 'text/plain';
660
+ const id = await this._crearTarjeta({ tipo: 'archivo', texto, paraId: para, archivo: { nombre, mime, bytes, contenido } });
661
+ await this.latido();
662
+ return { id, nombre, bytes, mime, link: this.linkTarjeta(id) };
663
+ }
664
+
665
+ async pedirTarea({ texto, paraId }) {
666
+ await this._asegurarSala();
667
+ if (!paraId) throw new ErrorLienzo('Una tarea necesita destinatario (paraId): mira lienzo_participantes.');
668
+ texto = this._validarTexto(texto);
669
+ this._vigilarSecretos([['el texto', texto]]);
670
+ const para = await this._resolverParticipante(paraId);
671
+ const id = await this._crearTarjeta({ tipo: 'tarea', texto, paraId: para, estado: 'abierta' });
672
+ this.tareasPedidas.set(id, { estado: 'abierta', respuesta: null, tomadaPor: null });
673
+ await this.latido();
674
+ return { id, paraId: para, link: this.linkTarjeta(id) };
675
+ }
676
+
677
+ /**
678
+ * lienzo_pedir_lista: una lista de puntos para el mismo destinatario, en UN :commit (la lista y sus
679
+ * puntos nacen juntos o no nace nada). La lista es una tarea; cada punto, una tarea hija con
680
+ * respondeA = la lista, el mismo paraId e id «<lista>-pNN» (ordenan por id).
681
+ */
682
+ async pedirLista({ titulo, contexto = '', puntos, paraId }) {
683
+ await this._asegurarSala();
684
+ titulo = String(titulo ?? '').trim();
685
+ if (!titulo) throw new ErrorLienzo('La lista necesita un título.');
686
+ if (titulo.length > MAX_TITULO_LISTA) throw new ErrorLienzo(`El título admite hasta ${MAX_TITULO_LISTA} caracteres.`);
687
+ if (/[\r\n\u2028\u2029]/.test(titulo)) throw new ErrorLienzo('El título va en una sola línea (el detalle va en contexto).');
688
+ contexto = String(contexto ?? '').trim();
689
+ if (contexto.length > MAX_CONTEXTO_LISTA) throw new ErrorLienzo(`El contexto admite hasta ${MAX_CONTEXTO_LISTA} caracteres (tiene ${contexto.length}). Si es más, compártelo como archivo.`);
690
+ if (!Array.isArray(puntos) || !puntos.length) throw new ErrorLienzo('La lista necesita al menos un punto.');
691
+ if (puntos.length > MAX_PUNTOS) throw new ErrorLienzo(`Una lista admite hasta ${MAX_PUNTOS} puntos (tiene ${puntos.length}). Más que eso es un documento: compártelo como archivo.`);
692
+ puntos = puntos.map((p, i) => {
693
+ const t = String(p ?? '').trim();
694
+ if (!t) throw new ErrorLienzo(`El punto ${i + 1} está vacío.`);
695
+ if (t.length > MAX_PUNTO) throw new ErrorLienzo(`El punto ${i + 1} admite hasta ${MAX_PUNTO} caracteres (tiene ${t.length}).`);
696
+ return t;
697
+ });
698
+ if (!paraId) throw new ErrorLienzo('Una lista necesita destinatario (paraId): mira lienzo_participantes.');
699
+ this._vigilarSecretos([['el título', titulo], ['el contexto', contexto], ...puntos.map((p, i) => [`el punto ${i + 1}`, p])]);
700
+ const para = await this._resolverParticipante(paraId);
701
+
702
+ const id = nuevoId();
703
+ const ids = puntos.map((_, i) => idPunto(id, i + 1));
704
+ const base = { tipo: 'tarea', paraId: para, estado: 'abierta' };
705
+ const crear = { crear: true, horaServidor: ['creadaEn'] };
706
+ const ruta = (x) => `salas/${this.salaId}/tarjetas/${x}`;
707
+ try {
708
+ await this.fs.escribirVarios([
709
+ { ruta: ruta(id), datos: this._tarjetaCompleta({ ...base, texto: contexto ? `${titulo}\n\n${contexto}` : titulo }), opciones: crear },
710
+ ...puntos.map((texto, i) => ({ ruta: ruta(ids[i]), datos: this._tarjetaCompleta({ ...base, texto, respondeA: id }), opciones: crear })),
711
+ ]);
712
+ } catch (e) {
713
+ throw this._traducir(e, 'publicar la lista');
714
+ }
715
+ this.listas.add(id);
716
+ for (const x of [id, ...ids]) this.tareasPedidas.set(x, { estado: 'abierta', respuesta: null, tomadaPor: null });
717
+ this.intervaloEspera = INTERVALO_MS;
718
+ await this.latido();
719
+ return { id, paraId: para, ids, link: this.linkTarjeta(id) };
720
+ }
721
+
722
+ /** Qué escribe un cambio de estado en una tarea, según quién eres en ella. Lanza ErrorLienzo con `que`. */
723
+ _planTarea(t, { estado, respuesta }, { que, maxRespuesta = MAX_TEXTO, derivada = false }) {
724
+ if (t.tipo !== 'tarea') throw new ErrorLienzo(`${que} no es una tarea.`);
725
+ // «De mi cuenta»: una tarea para mi persona la puede tomar su agente (a nombre del agente), y una que pidió mi
726
+ // persona la puedo reabrir o cerrar yo (las reglas miran la cuenta, no el participante).
727
+ const soyDestinatario = this.esMio(t.paraId);
728
+ const soyAutor = this.esMio(t.autorId);
729
+ if (!soyDestinatario && !soyAutor) {
730
+ throw new ErrorLienzo(`${que}: solo su destinatario o quien la pidió pueden cambiar su estado.`);
731
+ }
732
+ const cambios = { estado };
733
+ const mascara = ['estado'];
734
+ const tomadaPor = (v) => { cambios.tomadaPor = v; mascara.push('tomadaPor'); };
735
+ if (soyDestinatario) {
736
+ if (estado === 'abierta') tomadaPor(null);
737
+ else if (estado === 'tomada' || !t.tomadaPor) tomadaPor(this.participanteId); // quien firma un estado es quien la atiende
738
+ if (!derivada && (estado === 'esperando' || estado === 'no_aplica') && !String(respuesta ?? '').trim()) {
739
+ throw new ErrorLienzo(estado === 'esperando'
740
+ ? `${que}: con «esperando» va la pregunta concreta que le haces a tu usuario, en respuesta.`
741
+ : `${que}: con «no_aplica» va el porqué, en respuesta.`);
742
+ }
743
+ if (respuesta != null) {
744
+ const r = this._validarTexto(respuesta, `la respuesta (${que})`);
745
+ if (r.length > maxRespuesta) throw new ErrorLienzo(`${que}: la respuesta admite hasta ${maxRespuesta} caracteres${maxRespuesta === MAX_RESPUESTA_LOTE ? ' en un punto de lista' : ''} (tiene ${r.length}).`);
746
+ this._vigilarSecretos([[`la respuesta (${que})`, r]]);
747
+ cambios.respuesta = r;
748
+ mascara.push('respuesta');
749
+ }
750
+ } else {
751
+ // Quien pidió la tarea no firma por el destinatario (las reglas tampoco lo dejan).
752
+ if (estado === 'tomada') throw new ErrorLienzo(`${que}: solo el destinatario toma una tarea. Como quien la pidió puedes reabrirla («abierta») o cerrarla («hecha»).`);
753
+ if (estado === 'esperando' || estado === 'no_aplica') throw new ErrorLienzo(`${que}: «${estado}» lo pone quien responde, no quien la pidió. Tú puedes reabrirla («abierta») o cerrarla («hecha»).`);
754
+ if (respuesta != null) throw new ErrorLienzo(`${que}: la respuesta la escribe el destinatario. Si quieres comentar algo, publica un mensaje con respondeA = id de la tarea.`);
755
+ if (estado === 'abierta' && t.tomadaPor) tomadaPor(null);
756
+ }
757
+ return { cambios, mascara, soyAutor, soyDestinatario };
758
+ }
759
+
760
+ /**
761
+ * lienzo_tarea: una tarea ({ id, estado, respuesta? }) o varias en UN :commit ({ puntos: [...] }).
762
+ * Atómico: si un punto no pasa, no se escribe ninguno y el error dice cuál. Si respondes puntos de una
763
+ * lista que es para ti, el estado de la lista se deriva en el mismo commit (todos cerrados → hecha).
764
+ */
765
+ async tarea({ id, estado, respuesta, puntos } = {}) {
766
+ await this._asegurarSala();
767
+ const enLote = puntos != null;
768
+ if (enLote && id != null) throw new ErrorLienzo('Pasa id (una tarea) o puntos (varias), no los dos.');
769
+ const items = enLote ? puntos : [{ id, estado, respuesta }];
770
+ if (!Array.isArray(items) || !items.length) throw new ErrorLienzo('puntos tiene que traer al menos un { id, estado, respuesta? }.');
771
+ if (items.length > MAX_PUNTOS) throw new ErrorLienzo(`Un lote admite hasta ${MAX_PUNTOS} cambios (trae ${items.length}).`);
772
+ const vistos = new Set();
773
+ const pedidos = items.map((it, i) => {
774
+ const x = String(it?.id ?? '').trim();
775
+ const que = enLote ? `el cambio ${i + 1} (id ${limpiarLinea(x, 64) || '?'})` : `la tarea ${limpiarLinea(x, 64)}`;
776
+ if (!ID_TARJETA.test(x)) throw new ErrorLienzo(`${enLote ? que : 'id'}: tiene que ser el id de una tarjeta (letras, números, - y _, hasta 64).`);
777
+ if (vistos.has(x)) throw new ErrorLienzo(`${que} está repetido en el lote.`);
778
+ vistos.add(x);
779
+ if (!ESTADOS.includes(it.estado)) throw new ErrorLienzo(`${que}: estado es ${ESTADOS.map((e) => `«${e}»`).join(', ')}.`);
780
+ return { id: x, estado: it.estado, respuesta: it.respuesta ?? null, que };
781
+ });
782
+ const ruta = (x) => `salas/${this.salaId}/tarjetas/${x}`;
783
+ const docs = await this.fs.leerVarios(pedidos.map((p) => ruta(p.id))).catch((e) => { throw this._traducir(e, 'leer las tareas'); });
784
+ const porId = new Map(docs.map((d) => [d.id, d]));
785
+ const planes = pedidos.map((p) => {
786
+ const t = porId.get(p.id);
787
+ if (!t) throw new ErrorLienzo(`No existe la tarjeta ${limpiarLinea(p.id, 64)}${enLote ? ` (${p.que})` : ''}. No se escribió nada.`);
788
+ const n = numeroPunto(p.id);
789
+ const esUnPunto = Boolean(n && t.respondeA && p.id.startsWith(`${t.respondeA}-p`));
790
+ const que = enLote && esUnPunto ? `el punto ${n} (${limpiarLinea(p.id, 64)})` : p.que;
791
+ // un punto se responde corto también de a uno: así la lista entera cabe en una respuesta
792
+ return { t, ...this._planTarea(t, p, { que, maxRespuesta: enLote || esUnPunto ? MAX_RESPUESTA_LOTE : MAX_TEXTO }) };
793
+ });
794
+
795
+ // Listas que tocas como destinatario: su estado se deriva de TODOS sus puntos, en el mismo commit.
796
+ const padres = [];
797
+ const listasTocadas = [...new Set(planes.filter((p) => p.t.respondeA).map((p) => p.t.respondeA))]
798
+ .filter((lid) => !porId.has(lid)); // si la lista viene en el lote, manda lo que pediste
799
+ for (const lid of listasTocadas) {
800
+ const padre = await this.fs.leer(ruta(lid)).catch((e) => { throw this._traducir(e, 'leer la lista'); });
801
+ if (!padre || !planes.some((p) => esPunto(p.t, padre))) continue;
802
+ const hermanos = (await this._puntosDe(padre)).map((h) => {
803
+ const plan = planes.find((p) => p.t.id === h.id);
804
+ return plan ? plan.cambios.estado : h.estado;
805
+ });
806
+ if (!hermanos.length) continue;
807
+ let estadoLista = estadoDeLista(hermanos);
808
+ if (!this.esMio(padre.paraId)) {
809
+ // quien pidió la lista solo la cierra (todos cerrados) o la reabre (si estaba cerrada y reabrió un punto)
810
+ if (!this.esMio(padre.autorId)) continue;
811
+ estadoLista = estadoLista === 'hecha' ? 'hecha' : (padre.estado === 'hecha' ? 'abierta' : padre.estado);
812
+ }
813
+ if (estadoLista === padre.estado) continue;
814
+ padres.push({ t: padre, ...this._planTarea(padre, { estado: estadoLista, respuesta: null }, { que: `la lista ${lid}`, derivada: true }) });
815
+ }
816
+
817
+ const todos = [...planes, ...padres];
818
+ try {
819
+ await this.fs.escribirVarios(todos.map((p) => ({ ruta: ruta(p.t.id), datos: p.cambios, opciones: { existe: true, mascara: p.mascara } })));
820
+ } catch (e) {
821
+ const err = this._traducir(e, enLote ? 'aplicar ese lote (no se escribió ninguno)' : 'cambiar el estado de esa tarea');
822
+ throw err;
823
+ }
824
+ for (const p of todos) {
825
+ // lo que cambiaste tú no vuelve como novedad
826
+ if (p.soyAutor && this.tareasPedidas.has(p.t.id)) this._confirmarCambio({ ...p.t, ...p.cambios });
827
+ }
828
+ this.intervaloEspera = INTERVALO_MS;
829
+ await this.latido();
830
+ const escritos = planes.map((p) => ({ id: p.t.id, ...p.cambios }));
831
+ const listas = padres.map((p) => ({ id: p.t.id, antes: p.t.estado, estado: p.cambios.estado }));
832
+ return enLote ? { escritos, listas } : { ...escritos[0], listas };
833
+ }
834
+
835
+ /** Los puntos de una lista (solo los válidos según esPunto), ordenados por id. */
836
+ async _puntosDe(padre, { signal } = {}) {
837
+ if (padre?.tipo !== 'tarea' || padre.respondeA || !padre.autorId || !padre.paraId) return [];
838
+ // Filtra por autor EN la consulta: un tercero no puede firmar como el autor (reglas), así que no puede
839
+ // llenar el límite con hijas coladas y esconder puntos reales. Dos igualdades: Firestore las resuelve
840
+ // juntando los índices de un campo, sin índice compuesto. El resto del filtro (esPunto), después.
841
+ const hijas = await this.fs.consultar(`salas/${this.salaId}`, {
842
+ from: [{ collectionId: 'tarjetas' }],
843
+ where: y(filtro('respondeA', 'EQUAL', padre.id), filtro('autorId', 'EQUAL', padre.autorId)),
844
+ limit: 100,
845
+ }, { signal });
846
+ return hijas.filter((h) => esPunto(h, padre)).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
847
+ }
848
+
849
+ // ── lectura y novedades ───────────────────────────────────────────────────
850
+ _avanzarMarca(iso) {
851
+ if (iso && claveTiempo(iso) > claveTiempo(this.marca)) this.marca = iso;
852
+ }
853
+
854
+ _avanzarEdicion(iso) {
855
+ if (iso && claveTiempo(iso) > claveTiempo(this.marcaEdicion)) this.marcaEdicion = iso;
856
+ }
857
+
858
+ async _tarjetasDesde(desde, { limite = LIMITE_CONSULTA, signal } = {}) {
859
+ const consulta = {
860
+ from: [{ collectionId: 'tarjetas' }],
861
+ orderBy: [{ field: { fieldPath: 'creadaEn' }, direction: desde ? 'ASCENDING' : 'DESCENDING' }],
862
+ limit: limite,
863
+ };
864
+ if (desde) consulta.where = filtro('creadaEn', 'GREATER_THAN', marcaDeTiempo(desde));
865
+ let lista = await this.fs.consultar(`salas/${this.salaId}`, consulta, { signal });
866
+ if (!desde) lista.reverse();
867
+ const lleno = lista.length >= limite;
868
+ // Una lista nace en un solo :commit (lista + puntos con la MISMA creadaEn). Si el tope corta ese
869
+ // grupo, la marca saltaría los que quedaron fuera (la próxima consulta es «creadaEn > marca»).
870
+ // Así que nunca se corta dentro de un grupo: el del borde se deja para la próxima lectura.
871
+ if (lleno) lista = quitarGrupo(lista, { alFinal: Boolean(desde) });
872
+ lista.lleno = lleno;
873
+ return lista;
874
+ }
875
+
876
+ /** Tarjetas editadas después de `desde` (editadaEn es la hora del servidor: lo exigen las reglas). */
877
+ async _editadasDesde(desde, { signal } = {}) {
878
+ if (!desde) return [];
879
+ return this.fs.consultar(`salas/${this.salaId}`, {
880
+ from: [{ collectionId: 'tarjetas' }],
881
+ where: filtro('editadaEn', 'GREATER_THAN', marcaDeTiempo(desde)),
882
+ orderBy: [{ field: { fieldPath: 'editadaEn' }, direction: 'ASCENDING' }],
883
+ limit: LIMITE_CONSULTA,
884
+ }, { signal });
885
+ }
886
+
887
+ async _cargarMisTareas(signal) {
888
+ if (this.tareasCargadas) return;
889
+ const mias = await this.fs.consultar(`salas/${this.salaId}`, {
890
+ from: [{ collectionId: 'tarjetas' }],
891
+ where: y(filtro('autorId', 'EQUAL', this.participanteId)),
892
+ limit: 200,
893
+ }, { signal });
894
+ this.tareasCargadas = true;
895
+ for (const t of mias) {
896
+ if (t.tipo === 'tarea' && t.respondeA && numeroPunto(t.id) && t.id.startsWith(`${t.respondeA}-p`)) this.listas.add(t.respondeA);
897
+ if (t.tipo === 'tarea' && !CERRADOS.has(t.estado) && !this.tareasPedidas.has(t.id)) {
898
+ this.tareasPedidas.set(t.id, { estado: t.estado, respuesta: t.respuesta, tomadaPor: t.tomadaPor });
899
+ }
900
+ }
901
+ }
902
+
903
+ /** Tareas que pediste y cambiaron. No las marca como vistas: eso pasa recién al entregarlas (formatearLote). */
904
+ async _cambiosEnMisTareas(signal) {
905
+ await this._cargarMisTareas(signal);
906
+ const ids = [...this.tareasPedidas.keys()];
907
+ if (!ids.length) return [];
908
+ const docs = await this.fs.leerVarios(ids.map((id) => `salas/${this.salaId}/tarjetas/${id}`), { signal });
909
+ return docs.filter((t) => {
910
+ const antes = this.tareasPedidas.get(t.id);
911
+ return antes && (t.estado !== antes.estado || t.respuesta !== antes.respuesta || t.tomadaPor !== antes.tomadaPor);
912
+ });
913
+ }
914
+
915
+ _confirmarCambio(t) {
916
+ if (CERRADOS.has(t.estado)) this.tareasPedidas.delete(t.id); // «esperando» se sigue mirando: todavía cambia
917
+ else this.tareasPedidas.set(t.id, { estado: t.estado, respuesta: t.respuesta ?? null, tomadaPor: t.tomadaPor ?? null });
918
+ }
919
+
920
+ /** lienzo_leer: una tarjeta completa (id), o las tarjetas desde una marca (o las últimas 50), con ediciones y cambios en tus tareas. */
921
+ async leer({ desde, id, desdeCaracter = 0 } = {}) {
922
+ await this._asegurarSala();
923
+ await this.latido();
924
+ if (id != null && id !== '') {
925
+ id = String(id).trim();
926
+ if (!ID_TARJETA.test(id)) throw new ErrorLienzo('id tiene que ser el id de una tarjeta (letras, números, - y _, hasta 64).');
927
+ const t = await this.fs.leer(`salas/${this.salaId}/tarjetas/${id}`).catch((e) => { throw this._traducir(e, 'leer esa tarjeta'); });
928
+ if (!t) throw new ErrorLienzo(`No existe la tarjeta ${id}.`);
929
+ // una lista se lee entera, con sus puntos
930
+ const puntos = t.tipo === 'tarea' && !t.respondeA ? await this._puntosDe(t) : [];
931
+ if (puntos.length) return { lista: { padre: t, puntos } };
932
+ return { una: t, desdeCaracter: Math.max(0, Number(desdeCaracter) || 0) };
933
+ }
934
+ let marcaDesde = desde ?? null;
935
+ if (marcaDesde) {
936
+ const d = new Date(marcaDesde);
937
+ if (Number.isNaN(d.getTime())) throw new ErrorLienzo('desde tiene que ser una fecha ISO 8601 (p. ej. 2026-10-20T15:00:00Z).');
938
+ marcaDesde = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?Z$/.test(marcaDesde) ? marcaDesde : d.toISOString();
939
+ }
940
+ const tarjetas = await this._tarjetasDesde(marcaDesde);
941
+ const editadas = await this._editadasDesde(marcaDesde);
942
+ const cambios = await this._cambiosEnMisTareas();
943
+ return { tarjetas, editadas, cambios, desde: marcaDesde, lleno: Boolean(tarjetas.lleno) };
944
+ }
945
+
946
+ /** Para lienzo_esperar: tarjetas y ediciones ajenas y cambios en tus tareas; null si no hay nada ajeno. */
947
+ async novedades({ signal } = {}) {
948
+ await this._asegurarSala();
949
+ const tarjetas = this.marca
950
+ ? await this._tarjetasDesde(this.marca, { signal })
951
+ : await this._tarjetasDesde(null, { signal }); // tope ≥ 32: un grupo de lista (≤ 31) nunca llena la consulta solo
952
+ const editadas = await this._editadasDesde(this.marcaEdicion ?? this.marca, { signal });
953
+ const cambios = await this._cambiosEnMisTareas(signal);
954
+ // lo que publica tu persona desde la web sí es novedad para ti (no lo escribiste tú)
955
+ const ajena = (t) => t.autorId !== this.participanteId;
956
+ if (!tarjetas.some(ajena) && !editadas.some(ajena) && !cambios.length) {
957
+ // solo había lo tuyo: se da por visto para no volver a pedirlo
958
+ for (const t of tarjetas) this._avanzarMarca(t.creadaEn);
959
+ for (const t of editadas) this._avanzarEdicion(t.editadaEn);
960
+ return null;
961
+ }
962
+ return { tarjetas, editadas, cambios, soloAjenas: true, lleno: Boolean(tarjetas.lleno) };
963
+ }
964
+
965
+ // ── formato de salida ─────────────────────────────────────────────────────
966
+ async _autor(id) {
967
+ const p = id ? await this._participante(id).catch(() => null) : null;
968
+ if (!p) return { id, nombre: `participante ${abreviarId(id || '?')}`, tipo: 'participante', 'dueño': null, homonimos: 0 };
969
+ return { ...p, homonimos: this._homonimos(id) };
970
+ }
971
+
972
+ /** «Nombre (id: abc12345…)», limpio y en una línea: para metadatos que van DENTRO de un sobre. */
973
+ async _describir(id) {
974
+ if (id === this.participanteId) return 'ti';
975
+ const p = await this._autor(id);
976
+ return `${limpiarLinea(p.nombre)} (id: ${abreviarId(id)}${p.homonimos > 1 ? `; OJO: ${p.homonimos} se llaman igual` : ''})`;
977
+ }
978
+
979
+ /**
980
+ * Una tarjeta, lista para el agente. Todo lo que escribió o eligió OTRO participante (su texto, su
981
+ * nombre, su respuesta) va dentro de un sobre con el código de la respuesta.
982
+ */
983
+ async formatearTarjeta(t, { completo = false, codigo = nuevoCodigo(), archivoDesde = 0, archivoMax = Infinity, editada = false } = {}) {
984
+ const id = limpiarLinea(t.id, 64);
985
+ const para = t.paraId ? await this._describir(t.paraId) : null;
986
+ const tomador = t.tomadaPor ? await this._describir(t.tomadaPor) : null;
987
+ const fechas = [t.creadaEn ? `creada: ${limpiarLinea(t.creadaEn, 40)}` : null, t.editadaEn ? `editada: ${limpiarLinea(t.editadaEn, 40)}` : null];
988
+ const estado = t.tipo === 'tarea' ? `estado: ${limpiarLinea(t.estado, 20)}` : null;
989
+
990
+ let cuerpo = String(t.texto ?? '');
991
+ let notaArchivo = null;
992
+ let datoArchivo = null;
993
+ if (t.archivo) {
994
+ const c = String(t.archivo.contenido ?? '');
995
+ const desdeC = completo ? Math.min(Math.max(0, archivoDesde), c.length) : 0;
996
+ const trozo = c.slice(desdeC, desdeC + (completo ? archivoMax : VISTA_ARCHIVO));
997
+ const hasta = desdeC + trozo.length;
998
+ const resto = c.length - hasta;
999
+ datoArchivo = `archivo: ${limpiarLinea(t.archivo.nombre, 200)} (${limpiarLinea(t.archivo.mime, 100)}, ${Number(t.archivo.bytes) || 0} bytes, ${c.length} caracteres)`;
1000
+ const rango = desdeC > 0 || resto > 0 ? ` (caracteres ${desdeC}–${hasta} de ${c.length})` : '';
1001
+ cuerpo += `\n--- contenido del archivo${rango} ---\n${trozo}`;
1002
+ if (resto > 0) {
1003
+ notaArchivo = completo
1004
+ ? `Faltan ${resto} caracteres del archivo: pide lienzo_leer(id: «${id}», desdeCaracter: ${hasta}).`
1005
+ : `Archivo recortado a ${VISTA_ARCHIVO} caracteres: pide lienzo_leer(id: «${id}») para verlo entero.`;
1006
+ }
1007
+ }
1008
+
1009
+ const salida = [];
1010
+ if (t.autorId === this.participanteId) {
1011
+ // Lo tuyo va sin sobre, pero sin nombres ajenos: esos van dentro del sobre de abajo.
1012
+ salida.push([`(tuya) tipo: ${limpiarLinea(t.tipo, 20)}`, `id: ${id}`, ...fechas, estado, datoArchivo].filter(Boolean).join(' · '));
1013
+ salida.push(neutralizar(cuerpo));
1014
+ const otros = [para && para !== 'ti' ? `para: ${para}` : null, tomador && tomador !== 'ti' ? `tomada por: ${tomador}` : null].filter(Boolean);
1015
+ if (t.respuesta && t.tipo === 'tarea') {
1016
+ const quien = await this._autor(t.tomadaPor || t.paraId);
1017
+ salida.push(envolver({
1018
+ de: quien.nombre, tipoAutor: quien.tipo, 'dueño': quien['dueño'], autorId: quien.id, homonimos: quien.homonimos,
1019
+ tipo: 'respuesta a tu tarea', id, extra: otros, texto: t.respuesta,
1020
+ }, codigo));
1021
+ } else if (otros.length) {
1022
+ salida.push(envolver({
1023
+ de: 'la sala', tipoAutor: 'nombres elegidos por otros participantes', tipo: 'datos de tu tarjeta', id, extra: otros,
1024
+ texto: t.tipo === 'tarea' ? '(sin respuesta todavía)' : '(sin contenido ajeno)',
1025
+ }, codigo));
1026
+ }
1027
+ } else {
1028
+ const autor = await this._autor(t.autorId);
1029
+ const extra = [...fechas, para ? `para: ${para}` : null, t.respondeA ? `responde a: ${limpiarLinea(t.respondeA, 64)}` : null,
1030
+ estado ? `${estado}${tomador ? ` (tomada por ${tomador})` : ''}` : null, datoArchivo].filter(Boolean);
1031
+ salida.push(envolver({
1032
+ de: autor.nombre, tipoAutor: autor.tipo, 'dueño': autor['dueño'], autorId: t.autorId, homonimos: autor.homonimos,
1033
+ tipo: `${t.tipo}${editada ? ' (editada)' : ''}`, id, extra, texto: cuerpo,
1034
+ }, codigo));
1035
+ if (t.respuesta && t.tipo === 'tarea') {
1036
+ const rid = t.tomadaPor || t.paraId;
1037
+ if (rid === this.participanteId) {
1038
+ salida.push(`Tu respuesta a esa tarea:\n${neutralizar(t.respuesta)}`);
1039
+ } else {
1040
+ const r = await this._autor(rid);
1041
+ salida.push(envolver({
1042
+ de: r.nombre, tipoAutor: r.tipo, 'dueño': r['dueño'], autorId: rid, homonimos: r.homonimos,
1043
+ tipo: 'respuesta a la tarea', id, texto: t.respuesta,
1044
+ }, codigo));
1045
+ }
1046
+ }
1047
+ }
1048
+ if (notaArchivo) salida.push(notaArchivo);
1049
+ return salida.join('\n');
1050
+ }
1051
+
1052
+ /** Una tarjeta completa (lienzo_leer con id): el archivo se entrega por tramos que entran en el tope. */
1053
+ async _formatearUna(t, { codigo, max, desdeCaracter = 0 }) {
1054
+ const cabeza = aviso(codigo);
1055
+ let archivoMax = max;
1056
+ let texto = '';
1057
+ for (let i = 0; i < 4; i++) {
1058
+ texto = await this.formatearTarjeta(t, { completo: true, codigo, archivoDesde: desdeCaracter, archivoMax });
1059
+ const sobra = cabeza.length + 2 + texto.length - max;
1060
+ if (sobra <= 0 || !t.archivo) break;
1061
+ archivoMax = Math.max(1000, archivoMax - sobra - 200);
1062
+ }
1063
+ return `${cabeza}\n\n${texto}`;
1064
+ }
1065
+
1066
+ /** Texto ajeno multilínea, con sangría, para las filas de una lista. */
1067
+ static _sangrar(texto, prefijo = ' ') {
1068
+ return String(texto ?? '').split('\n').map((l) => `${prefijo}${l}`).join('\n');
1069
+ }
1070
+
1071
+ /**
1072
+ * Una lista con sus puntos, en UN sobre. Si la pediste tú: tu título fuera y, dentro del sobre de quien
1073
+ * responde, el resumen y las filas de los puntos que cambiaron (`cambiados`, o todas si es null).
1074
+ * Si es de otro: el sobre del autor con título, contexto y los puntos numerados con sus ids.
1075
+ */
1076
+ async _formatearLista(padre, puntos, { codigo, cambiados = null, max = MAX_RESPUESTA } = {}) {
1077
+ const id = limpiarLinea(padre.id, 64);
1078
+ const titulo = primeraLinea(padre.texto);
1079
+ const recortar = (t, n, nota = '') => { const u = String(t ?? ''); return u.length > n ? `${u.slice(0, Math.max(0, n - 1))}…${nota}` : u; };
1080
+ const contexto = recortar(String(padre.texto ?? '').split('\n').slice(1).join('\n').trim(), MAX_CONTEXTO_LISTA, ' (recortado)');
1081
+ const dest = padre.paraId ? await this._autor(padre.paraId) : null;
1082
+ // Sin `dueño`, la persona de su cuenta: las reglas atan `{uid}_agente` a `uid` (solo entonces se lee).
1083
+ const sinDueño = dest?.tipo === 'agente' && !String(dest['dueño'] ?? '').trim() && String(dest.id ?? '').endsWith('_agente');
1084
+ const persona = sinDueño ? await this._autor(dest.id.slice(0, -'_agente'.length)) : null;
1085
+ const quienEspera = dest ? quienEsperaLista(dest, persona) : null;
1086
+ const resumen = `lista: ${resumenLista(puntos.map((p) => p.estado), this.esMio(padre.paraId) ? 'tu usuario' : quienEspera)}`;
1087
+ const numero = (p) => numeroPunto(p.id) ?? '?';
1088
+ const unaLinea = (t, n) => recortar(String(t ?? '').replace(/\s+/g, ' ').trim(), n);
1089
+ const mostrados = puntos.filter((p) => !cambiados || cambiados.has(p.id));
1090
+ // Presupuesto: la lista entera tiene que caber en UNA respuesta aunque alguien haya escrito de más por REST.
1091
+ // Cada texto de un punto y cada respuesta se topan a 1 000 y, si aun así no entra, se reparten lo que queda.
1092
+ const campos = mostrados.reduce((n, p) => n + 1 + (p.respuesta ? 1 : 0), 0) || 1;
1093
+ const tope = Math.max(120, Math.min(MAX_PUNTO, Math.floor((max - 4000 - contexto.length - mostrados.length * 120) / campos)));
1094
+ const NOTA = ' (recortado: lienzo_leer con el id del punto)';
1095
+ const corto = (t) => {
1096
+ // tope de caracteres y de líneas (cada línea suma sangría y «| » dentro del sobre)
1097
+ const lineas = recortar(t, tope, NOTA).split('\n');
1098
+ return lineas.length > 12 ? `${lineas.slice(0, 12).join('\n')}…${NOTA}` : lineas.join('\n');
1099
+ };
1100
+ const sangrado = (t) => Lienzo._sangrar(corto(t));
1101
+ // «3 · esperando · ¿token nuevo…?» y, si la respuesta tiene más líneas, debajo con sangría
1102
+ const filaRespuesta = (p) => {
1103
+ const [primera, ...resto] = corto(p.respuesta ?? '').split('\n');
1104
+ return [`${numero(p)} · ${p.estado}${p.respuesta ? ` · ${primera}` : ''}`, ...resto.map((l) => ` ${l}`)].join('\n');
1105
+ };
1106
+ const quien = dest ?? { nombre: 'destinatario', tipo: 'participante' };
1107
+ const sobreRespuestas = (filas, tipo, extra = []) => envolver({
1108
+ de: quien.nombre, tipoAutor: quien.tipo, 'dueño': quien['dueño'], autorId: padre.paraId, homonimos: quien.homonimos,
1109
+ tipo, id, extra, texto: filas.length ? filas.join('\n') : '(sin cambios en los puntos)',
1110
+ }, codigo);
1111
+
1112
+ if (padre.autorId === this.participanteId) {
1113
+ // La pediste tú: tu título fuera; las respuestas, en el sobre de quien responde.
1114
+ const filas = mostrados.map((p) => `${filaRespuesta(p)}\n (tu punto: ${unaLinea(p.texto, 120)})`);
1115
+ const cabeza = `(tuya) lista: ${neutralizar(titulo)} · id: ${id} · ${puntos.length} puntos · estado: ${limpiarLinea(padre.estado, 20)}`;
1116
+ return `${cabeza}\n${sobreRespuestas(filas, 'respuestas a tu lista', [resumen])}`;
1117
+ }
1118
+
1119
+ // De otro: los puntos en el sobre de quien los escribió; las respuestas, en el de quien responde
1120
+ // (cada texto firmado por su autor), salvo que las respuestas sean tuyas.
1121
+ const autor = await this._autor(padre.autorId);
1122
+ const para = padre.paraId ? await this._describir(padre.paraId) : null;
1123
+ const filas = mostrados.map((p) => [`${numero(p)} · id: ${p.id} · ${p.estado}`, sangrado(p.texto)].join('\n'));
1124
+ const salida = [envolver({
1125
+ de: autor.nombre, tipoAutor: autor.tipo, 'dueño': autor['dueño'], autorId: padre.autorId, homonimos: autor.homonimos,
1126
+ tipo: `lista de puntos (${puntos.length})`, id,
1127
+ extra: [padre.creadaEn ? `creada: ${limpiarLinea(padre.creadaEn, 40)}` : null, para ? `para: ${para}` : null,
1128
+ `estado: ${limpiarLinea(padre.estado, 20)}`, resumen].filter(Boolean),
1129
+ texto: [titulo, contexto ? `\n${contexto}` : null, '', 'Puntos:', ...filas].filter((x) => x != null).join('\n'),
1130
+ }, codigo)];
1131
+ const respondidos = mostrados.filter((p) => p.respuesta);
1132
+ if (respondidos.length) {
1133
+ const filasR = respondidos.map(filaRespuesta);
1134
+ salida.push(this.esMio(padre.paraId)
1135
+ ? `Tus respuestas:\n${neutralizar(filasR.join('\n'))}`
1136
+ : sobreRespuestas(filasR, 'respuestas a la lista'));
1137
+ }
1138
+ if (this.esMio(padre.paraId)) {
1139
+ salida.push('Esta lista es para ti. Respóndela entera en UNA llamada: lienzo_tarea con puntos: [{ id, estado, respuesta }] ' +
1140
+ '(«hecha» o «no_aplica» solo si tu usuario te dio permiso en esta sesión y se contesta leyendo; todo lo demás, «esperando» ' +
1141
+ 'con la pregunta para tu usuario; con «esperando» y «no_aplica» la respuesta es obligatoria).');
1142
+ }
1143
+ return salida.join('\n');
1144
+ }
1145
+
1146
+ /** Cambios en tus tareas: los puntos de una misma lista se juntan en un bloque (con el resumen arriba). */
1147
+ async _bloquesDeCambios(cambios) {
1148
+ const suelto = (t) => ({
1149
+ seccion: 'cambios', piezas: 1, entregar: () => this._confirmarCambio(t),
1150
+ texto: (codigo) => this.formatearTarjeta(t, { codigo }),
1151
+ });
1152
+ const grupos = new Map();
1153
+ const bloques = [];
1154
+ for (const c of cambios) {
1155
+ const lid = this.listas.has(c.id) ? c.id : (c.respondeA && this.listas.has(c.respondeA) ? c.respondeA : null);
1156
+ if (!lid) { bloques.push(suelto(c)); continue; }
1157
+ if (!grupos.has(lid)) grupos.set(lid, []);
1158
+ grupos.get(lid).push(c);
1159
+ }
1160
+ for (const [lid, miembros] of grupos) {
1161
+ const padre = miembros.find((m) => m.id === lid)
1162
+ ?? await this.fs.leer(`salas/${this.salaId}/tarjetas/${lid}`).catch(() => null);
1163
+ const validos = padre ? miembros.filter((m) => m.id === lid || esPunto(m, padre)) : [];
1164
+ bloques.push(...miembros.filter((m) => !validos.includes(m)).map(suelto));
1165
+ if (!validos.length) continue;
1166
+ const puntos = await this._puntosDe(padre).catch(() => validos.filter((m) => m.id !== lid));
1167
+ const cambiados = new Set(validos.map((m) => m.id));
1168
+ bloques.push({
1169
+ seccion: 'cambios', piezas: 1,
1170
+ entregar: () => { for (const m of validos) this._confirmarCambio(m); },
1171
+ texto: (codigo, max) => this._formatearLista(padre, puntos, { codigo, cambiados, max }),
1172
+ });
1173
+ }
1174
+ return bloques;
1175
+ }
1176
+
1177
+ /**
1178
+ * Tarjetas nuevas, en bloques que no se parten: las que nacieron en el mismo :commit (misma creadaEn)
1179
+ * se entregan juntas o no se entregan, y una lista con sus puntos va en un solo sobre.
1180
+ */
1181
+ _bloquesDeTarjetas(tarjetas, { soloAjenas }) {
1182
+ const grupos = [];
1183
+ for (const t of tarjetas) {
1184
+ const ultimo = grupos[grupos.length - 1];
1185
+ if (ultimo && claveTiempo(ultimo[0].creadaEn) === claveTiempo(t.creadaEn)) ultimo.push(t);
1186
+ else grupos.push([t]);
1187
+ }
1188
+ return grupos.map((grupo) => {
1189
+ const enLista = new Set();
1190
+ const piezas = [];
1191
+ for (const t of grupo) {
1192
+ if (enLista.has(t.id)) continue;
1193
+ const puntos = t.tipo === 'tarea' && !t.respondeA ? grupo.filter((h) => esPunto(h, t)) : [];
1194
+ if (puntos.length) {
1195
+ for (const p of puntos) enLista.add(p.id);
1196
+ enLista.add(t.id);
1197
+ piezas.push({ t, puntos });
1198
+ }
1199
+ }
1200
+ for (const t of grupo) if (!enLista.has(t.id)) piezas.push({ t, puntos: null });
1201
+ piezas.sort((a, b) => grupo.indexOf(a.t) - grupo.indexOf(b.t));
1202
+ const visibles = piezas.filter((p) => !(soloAjenas && p.t.autorId === this.participanteId));
1203
+ return {
1204
+ seccion: 'tarjetas', piezas: visibles.length, silencioso: !visibles.length,
1205
+ entregar: () => { for (const t of grupo) this._avanzarMarca(t.creadaEn); },
1206
+ ultimaCreada: grupo[grupo.length - 1].creadaEn,
1207
+ texto: async (codigo, max) => (await Promise.all(visibles.map((p) => (p.puntos
1208
+ ? this._formatearLista(p.t, [...p.puntos].sort((a, b) => (a.id < b.id ? -1 : 1)), { codigo, max })
1209
+ : this.formatearTarjeta(p.t, { codigo }))))).join('\n\n'),
1210
+ };
1211
+ });
1212
+ }
1213
+
1214
+ /**
1215
+ * Arma la respuesta de lienzo_leer / lienzo_esperar con un tope de caracteres. Entrega en este orden:
1216
+ * cambios en tus tareas, ediciones, tarjetas nuevas (de la más vieja a la más nueva). Las marcas
1217
+ * (creadaEn, editadaEn y el estado visto de tus tareas) avanzan SOLO hasta lo que se entregó, y un
1218
+ * bloque (una lista, o lo que nació en el mismo :commit) se entrega entero o no se entrega.
1219
+ */
1220
+ async formatearLote(lote, { codigo = nuevoCodigo(), max = MAX_RESPUESTA } = {}) {
1221
+ if (lote.una) return this._formatearUna(lote.una, { codigo, max, desdeCaracter: lote.desdeCaracter });
1222
+ if (lote.lista) return `${aviso(codigo)}\n\n${await this._formatearLista(lote.lista.padre, lote.lista.puntos, { codigo, max })}`;
1223
+ const { tarjetas = [], editadas = [], cambios = [], soloAjenas = false, lleno = false } = lote;
1224
+ const nuevas = new Set(tarjetas.map((t) => t.id));
1225
+ const items = [
1226
+ ...(await this._bloquesDeCambios(cambios)),
1227
+ ...editadas.map((t) => ({
1228
+ seccion: 'editadas', piezas: 1, entregar: () => this._avanzarEdicion(t.editadaEn),
1229
+ texto: (codigo) => this.formatearTarjeta(t, { codigo, editada: true }),
1230
+ // la edición de una tarjeta que llega nueva en esta misma respuesta ya viene con el texto al día
1231
+ silencioso: nuevas.has(t.id) || (soloAjenas && t.autorId === this.participanteId),
1232
+ })),
1233
+ ...this._bloquesDeTarjetas(tarjetas, { soloAjenas }),
1234
+ ];
1235
+ const cabeza = aviso(codigo);
1236
+ const bloques = { cambios: [], editadas: [], tarjetas: [] };
1237
+ const piezas = { cambios: 0, editadas: 0, tarjetas: 0 };
1238
+ const quedan = { cambios: 0, editadas: 0, tarjetas: 0 };
1239
+ let usado = cabeza.length + 600; // aviso, títulos y pie
1240
+ let entregados = 0;
1241
+ let cortado = false;
1242
+ let ultimaCreada = null;
1243
+ for (const it of items) {
1244
+ if (cortado) { if (!it.silencioso) quedan[it.seccion] += it.piezas; continue; }
1245
+ if (it.silencioso) { it.entregar(); if (it.ultimaCreada) ultimaCreada = it.ultimaCreada; continue; }
1246
+ const txt = await it.texto(codigo, max);
1247
+ if (entregados > 0 && usado + txt.length + 2 > max) { cortado = true; quedan[it.seccion] += it.piezas; continue; }
1248
+ bloques[it.seccion].push(txt);
1249
+ piezas[it.seccion] += it.piezas;
1250
+ usado += txt.length + 2;
1251
+ entregados++;
1252
+ it.entregar();
1253
+ if (it.ultimaCreada) ultimaCreada = it.ultimaCreada;
1254
+ }
1255
+
1256
+ const partes = [];
1257
+ if (entregados) partes.push(cabeza);
1258
+ if (bloques.cambios.length) partes.push(`Cambios en tareas que pediste (${piezas.cambios}):`, ...bloques.cambios);
1259
+ if (bloques.editadas.length) partes.push(`Tarjetas editadas (${piezas.editadas}):`, ...bloques.editadas);
1260
+ if (bloques.tarjetas.length) partes.push(`Tarjetas (${piezas.tarjetas}):`, ...bloques.tarjetas);
1261
+ if (!entregados && !cortado) partes.push('No hay tarjetas nuevas.');
1262
+ if (cortado) {
1263
+ const total = quedan.cambios + quedan.editadas + quedan.tarjetas;
1264
+ const desglose = [
1265
+ quedan.cambios && `${quedan.cambios} cambios en tus tareas`,
1266
+ quedan.editadas && `${quedan.editadas} ediciones`,
1267
+ quedan.tarjetas && `${quedan.tarjetas} tarjetas`,
1268
+ ].filter(Boolean).join(', ');
1269
+ partes.push(`Respuesta recortada a ${max} caracteres: quedan ${total} sin entregar (${desglose})${lleno ? ', y puede haber más' : ''}. ` +
1270
+ (soloAjenas ? 'Llama otra vez a lienzo_esperar para recibir el resto.' : 'Pide el resto con lienzo_leer(desde: marca).'));
1271
+ } else if (lleno) {
1272
+ partes.push(`Había más de ${LIMITE_CONSULTA} tarjetas nuevas: pide el resto con lienzo_leer(desde: marca).`);
1273
+ }
1274
+ const marca = soloAjenas ? this.marca : (ultimaCreada ?? lote.desde ?? this.marca);
1275
+ partes.push(`marca: ${marca ?? '(sin marca)'} — pásala como «desde» para pedir solo lo posterior.`);
1276
+ return partes.join('\n\n');
1277
+ }
1278
+ }