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