@ajuarezso/lienzo-mcp 0.4.0 → 0.6.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 +139 -35
- package/README.md +156 -40
- package/index.mjs +133 -22
- package/lib/canal.mjs +13 -8
- package/lib/config.mjs +14 -5
- package/lib/envoltorio.mjs +23 -0
- package/lib/instalacion.mjs +322 -0
- package/lib/lienzo.mjs +425 -49
- package/lib/menciones.mjs +200 -0
- package/lib/salir.mjs +83 -0
- package/lib/secretos.mjs +5 -0
- package/lib/sesion.mjs +77 -22
- package/lib/version.mjs +48 -0
- package/package.json +1 -1
package/lib/lienzo.mjs
CHANGED
|
@@ -6,17 +6,24 @@ import { basename, extname, join } from 'node:path';
|
|
|
6
6
|
import { Firestore, ErrorFirestore, filtro, y, marcaDeTiempo } from './firestore.mjs';
|
|
7
7
|
import { Sesion, ErrorSesion, abrirNavegador, ENTRAR_TIMEOUT_MS } from './sesion.mjs';
|
|
8
8
|
import { buscarSecretos, explicarBloqueo } from './secretos.mjs';
|
|
9
|
-
import { envolver, limpiarLinea, neutralizar, abreviarId, aviso, nuevoCodigo } from './envoltorio.mjs';
|
|
9
|
+
import { envolver, limpiarLinea, neutralizar, abreviarId, aviso, nuevoCodigo, tieneInvisibles, tieneLetraONumero } from './envoltorio.mjs';
|
|
10
10
|
import { rutaCompartible, revisarNombre, ErrorArchivo } from './archivos.mjs';
|
|
11
11
|
import { INTERVALO_MS } from './esperar.mjs';
|
|
12
12
|
import { cerrarTimbre } from './escucha.mjs';
|
|
13
|
-
import { idDeAgente, cuentaDe, esAgenteDe, TOPE_AGENTES } from './agentes.mjs';
|
|
13
|
+
import { idDeAgente, cuentaDe, esAgenteDe, TOPE_AGENTES, RE_SLUG } from './agentes.mjs';
|
|
14
|
+
import {
|
|
15
|
+
prepararInstalacion, resolverAgente, clienteDeMcp, nombreDeCliente, nombresAplicados, nombreDeConfigManda, anotarNombreAplicado,
|
|
16
|
+
} from './instalacion.mjs';
|
|
14
17
|
import { Storage, ErrorStorage } from './storage.mjs';
|
|
15
18
|
import {
|
|
16
19
|
ErrorAdjunto, detectarTipo, revisarTamano, sha256, nombreEnStorage, mimeParaStorage, disposicion, carpetaDescargas,
|
|
17
20
|
nombreDescarga, nombreDeTipo, revisarBajado, revisarZip, imagenVisible, MAX_ADJUNTO_BYTES, MAX_IMAGEN_VISIBLE,
|
|
18
21
|
IMAGENES_VISIBLES, MB,
|
|
19
22
|
} from './adjuntos.mjs';
|
|
23
|
+
import {
|
|
24
|
+
referenciasDeMenciones, mencionesDe, menciona, RACHA_VACIA, sumarTarjeta, rachaDeRecientes, pasaElTope, textoTope, lineaTope,
|
|
25
|
+
vaAOtroAgente, esPersona, PAGINA_TURNOS, PAGINAS_TURNOS, TOPE_TURNOS,
|
|
26
|
+
} from './menciones.mjs';
|
|
20
27
|
|
|
21
28
|
export const MAX_TEXTO = 20000;
|
|
22
29
|
export const MAX_ARCHIVO_BYTES = 200 * 1024;
|
|
@@ -29,21 +36,48 @@ const RECARGA_PARTICIPANTES_MS = 30 * 1000;
|
|
|
29
36
|
const VISTA_ARCHIVO = 3000; // caracteres de un archivo que se muestran en una lista
|
|
30
37
|
const LIMITE_CONSULTA = 50;
|
|
31
38
|
|
|
32
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* El nombre de la persona: el de su cuenta en una línea (hasta 80) o, si no hay (o no tiene ni una letra ni un número
|
|
41
|
+
* visibles, que las reglas rechazan), la parte local del correo.
|
|
42
|
+
*/
|
|
33
43
|
export function nombreDeCuenta(cuenta) {
|
|
34
44
|
const n = limpiarLinea(cuenta?.nombre ?? '', 80);
|
|
35
|
-
if (n) return n;
|
|
45
|
+
if (n && tieneLetraONumero(n)) return n;
|
|
36
46
|
const local = limpiarLinea(String(cuenta?.email ?? '').split('@')[0], 80);
|
|
37
|
-
return local
|
|
47
|
+
return local && tieneLetraONumero(local) ? local : null;
|
|
38
48
|
}
|
|
39
49
|
|
|
40
50
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
51
|
+
* «<Cliente> de <primer nombre>» («Claude Code de Ana»; un cliente desconocido, «Agente de Ana»), o null si la cuenta
|
|
52
|
+
* no tiene nombre ni correo. Con un perfil de Claude Code que no es el de siempre (CLAUDE_CONFIG_DIR=~/.claude-ontop),
|
|
53
|
+
* «Claude Code (ontop) de Ana»: así la persona sabe en la web cuál es cuál. Con LIENZO_AGENTE explícito, «… · <slug>».
|
|
54
|
+
* Un slug automático no se muestra (no le dice nada a nadie): si choca, unirse() suma « · 2».
|
|
43
55
|
*/
|
|
44
|
-
export function nombreAgentePorDefecto(cuenta, slug = null) {
|
|
56
|
+
export function nombreAgentePorDefecto(cuenta, slug = null, cliente = 'claude-code', perfil = null) {
|
|
45
57
|
const n = nombreDeCuenta(cuenta);
|
|
46
|
-
|
|
58
|
+
const deCliente = `${nombreDeCliente(cliente)}${perfil && cliente === 'claude-code' ? ` (${perfil})` : ''}`;
|
|
59
|
+
return n ? limpiarLinea(`${deCliente} de ${n.split(/\s+/)[0]}${slug ? ` · ${slug}` : ''}`, 80) : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* ¿Sirve como nombre de participante? Una línea, sin invisibles (también los que no son de formato: U+034F, rellenos
|
|
64
|
+
* Hangul, braille en blanco, un selector de variante suelto), con al menos una letra o un número, 1..80. Lo mismo que
|
|
65
|
+
* exigen las reglas (nombreVisible en firestore.rules).
|
|
66
|
+
*/
|
|
67
|
+
export const nombreValido = (n) => typeof n === 'string' && n.trim().length > 0 && n.length <= 80 && !tieneInvisibles(n)
|
|
68
|
+
&& tieneLetraONumero(n);
|
|
69
|
+
|
|
70
|
+
/** «hace un momento», «hace 5 min», «hace 3 h», «hace 2 días» o la fecha (AAAA-MM-DD). */
|
|
71
|
+
export function haceCuanto(iso, ahora = Date.now()) {
|
|
72
|
+
const t = Date.parse(iso ?? '');
|
|
73
|
+
if (!Number.isFinite(t)) return 'sin fecha';
|
|
74
|
+
const s = Math.max(0, Math.round((ahora - t) / 1000));
|
|
75
|
+
if (s < 60) return 'hace un momento';
|
|
76
|
+
if (s < 3600) return `hace ${Math.floor(s / 60)} min`;
|
|
77
|
+
if (s < 86400) return `hace ${Math.floor(s / 3600)} h`;
|
|
78
|
+
const d = Math.floor(s / 86400);
|
|
79
|
+
if (d < 30) return `hace ${d} día${d === 1 ? '' : 's'}`;
|
|
80
|
+
return new Date(t).toISOString().slice(0, 10);
|
|
47
81
|
}
|
|
48
82
|
|
|
49
83
|
// ── listas de puntos (docs/producto.md §4) ──────────────────────────────────
|
|
@@ -122,8 +156,26 @@ function quitarGrupo(lista, { alFinal }) {
|
|
|
122
156
|
return resto.length ? resto : lista; // un solo grupo que llena el tope: se entrega igual (no se traba)
|
|
123
157
|
}
|
|
124
158
|
|
|
125
|
-
|
|
126
|
-
|
|
159
|
+
// Letras cirílicas y griegas que se ven igual que una latina (las más comunes). Solo para COMPARAR nombres: «Claude Code
|
|
160
|
+
// de Аna» (A cirílica) cuenta como homónimo de «Claude Code de Ana» (revisión de seguridad, 21-10-2026).
|
|
161
|
+
const PARECIDAS = new Map(Object.entries({
|
|
162
|
+
А: 'A', В: 'B', Е: 'E', К: 'K', М: 'M', Н: 'H', О: 'O', Р: 'P', С: 'C', Т: 'T', Х: 'X', У: 'Y', І: 'I', Ј: 'J', Ѕ: 'S',
|
|
163
|
+
а: 'a', е: 'e', о: 'o', р: 'p', с: 'c', у: 'y', х: 'x', і: 'i', ј: 'j', ѕ: 's', ԁ: 'd', һ: 'h', ӏ: 'l', ɡ: 'g', ı: 'i',
|
|
164
|
+
Α: 'A', Β: 'B', Ε: 'E', Ζ: 'Z', Η: 'H', Ι: 'I', Κ: 'K', Μ: 'M', Ν: 'N', Ο: 'O', Ρ: 'P', Τ: 'T', Υ: 'Y', Χ: 'X',
|
|
165
|
+
ο: 'o', ν: 'v', ρ: 'p', ι: 'i', κ: 'k', υ: 'u', χ: 'x', α: 'a',
|
|
166
|
+
}));
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Para comparar nombres: sin controles ni invisibles, sin tildes ni marcas combinantes (NFKD sin \p{Mn}), con las
|
|
170
|
+
* letras cirílicas o griegas que parecen latinas pasadas a latinas, sin mayúsculas y con espacios simples. No sirve para
|
|
171
|
+
* mostrar: solo para decidir si dos nombres se confunden a la vista.
|
|
172
|
+
*/
|
|
173
|
+
export function claveNombre(s) {
|
|
174
|
+
const t = limpiarLinea(s, 200).normalize('NFKD').replace(/\p{Mn}/gu, '');
|
|
175
|
+
let r = '';
|
|
176
|
+
for (const c of t) r += PARECIDAS.get(c) ?? c;
|
|
177
|
+
return r.normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
178
|
+
}
|
|
127
179
|
|
|
128
180
|
const ALFABETO = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
129
181
|
|
|
@@ -196,6 +248,9 @@ export class Lienzo {
|
|
|
196
248
|
this.tareasPedidas = new Map(); // id → { estado, respuesta, tomadaPor }
|
|
197
249
|
this.tareasCargadas = false;
|
|
198
250
|
this.listas = new Set(); // ids de las listas que pediste (sus puntos llegan agrupados)
|
|
251
|
+
// turnos agente↔agente de lo que ya se entregó (menciones.mjs); `hasta` = creadaEn de la última sumada, así una
|
|
252
|
+
// tarjeta que vuelve a llegar (canal y lienzo_esperar) no suma dos veces
|
|
253
|
+
this.racha = { ...RACHA_VACIA, hasta: '' };
|
|
199
254
|
this.ultimoLatido = 0;
|
|
200
255
|
this.actividad = null; // lo último escrito en participantes.actividad ('esperando' | 'escribiendo' | null)
|
|
201
256
|
this.latidoFallo = null; // el error del último latido (null si anduvo)
|
|
@@ -203,14 +258,44 @@ export class Lienzo {
|
|
|
203
258
|
// cuenta a la que le pasó: si la sesión cambia de cuenta deja de valer.
|
|
204
259
|
this.salasPerdidas = new Map();
|
|
205
260
|
this.ultimaPerdida = null; // { uid, salaId, texto }: la última, para decir la causa cuando ya no hay sala
|
|
261
|
+
// Id automático por instalación (lib/instalacion.mjs): agente.json se prepara al arrancar (ahí se fija si este home
|
|
262
|
+
// ya tenía sesión, para heredar `{uid}_agente`); el slug se resuelve en el primer unirse.
|
|
263
|
+
prepararInstalacion(config.home);
|
|
264
|
+
this.slugInstalacion = undefined; // undefined = sin resolver; null = el de siempre; string = slug automático
|
|
265
|
+
this.clienteMcp = null; // clientInfo.name del handshake (lo pone index.mjs al inicializar)
|
|
206
266
|
}
|
|
207
267
|
|
|
208
268
|
// ── utilidades ─────────────────────────────────────────────────────────────
|
|
209
269
|
/** La cuenta (la persona). */
|
|
210
270
|
get uid() { return this.sesion.uid; }
|
|
211
271
|
|
|
212
|
-
/**
|
|
213
|
-
|
|
272
|
+
/**
|
|
273
|
+
* El slug de este puente: LIENZO_AGENTE si viene; si no, el automático de la instalación (resuelto en unirse); null =
|
|
274
|
+
* el agente de siempre (`{uid}_agente`: la instalación que lo heredó, o antes de resolver).
|
|
275
|
+
*/
|
|
276
|
+
get slug() { return this.config.agente ?? this.slugInstalacion ?? null; }
|
|
277
|
+
|
|
278
|
+
/** El cliente: LIENZO_CLIENTE si viene; si no, el del handshake MCP si se reconoce; si no, 'claude-code'. */
|
|
279
|
+
get cliente() {
|
|
280
|
+
if (this.config.clienteExplicito) return this.config.cliente;
|
|
281
|
+
return clienteDeMcp(this.clienteMcp) ?? this.config.cliente ?? 'claude-code';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** La instalación: `<cliente>@<CLAUDE_CONFIG_DIR o predeterminada>` (clave en agente.json). */
|
|
285
|
+
get claveInstalacion() {
|
|
286
|
+
return `${this.cliente}@${this.config.carpetaConfig ?? 'predeterminada'}`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Fija el slug automático de esta instalación (una vez por proceso; estable entre arranques por agente.json). */
|
|
290
|
+
async _resolverInstalacion() {
|
|
291
|
+
if (this.config.agente || this.slugInstalacion !== undefined) return;
|
|
292
|
+
const { agente, origen } = await resolverAgente(this.config.home, this.claveInstalacion);
|
|
293
|
+
// otra llamada pudo resolverlo mientras tanto: gana la primera (el id no cambia a mitad del proceso)
|
|
294
|
+
if (this.slugInstalacion === undefined) {
|
|
295
|
+
this.slugInstalacion = agente;
|
|
296
|
+
console.error(`[lienzo] instalación ${this.claveInstalacion}: ${agente ? `agente ${agente}` : 'el agente de siempre'} (${origen})`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
214
299
|
|
|
215
300
|
/** El participante de este agente en la sala: `{uid}_agente` o, con LIENZO_AGENTE, `{uid}_agente_{slug}`. */
|
|
216
301
|
get participanteId() { return this.uid ? idDeAgente(this.uid, this.slug) : null; }
|
|
@@ -291,7 +376,8 @@ export class Lienzo {
|
|
|
291
376
|
// te sacaron de la sala y todavía no entras a otra: la causa, no un genérico
|
|
292
377
|
if (this.ultimaPerdida?.uid === this.uid) throw new ErrorLienzo(this.ultimaPerdida.texto);
|
|
293
378
|
throw new ErrorLienzo(
|
|
294
|
-
'Todavía no estás en una sala. Usa lienzo_unirse
|
|
379
|
+
'Todavía no estás en una sala. Usa lienzo_mis_salas para ver tus salas y lienzo_unirse para entrar (con su nombre, ' +
|
|
380
|
+
'su link o su salaId), o crea una con lienzo_crear_sala.',
|
|
295
381
|
);
|
|
296
382
|
}
|
|
297
383
|
|
|
@@ -338,16 +424,22 @@ export class Lienzo {
|
|
|
338
424
|
}
|
|
339
425
|
|
|
340
426
|
/**
|
|
341
|
-
* ¿Lo que trajo lienzo_esperar le toca a este agente? Algo dirigido a él (paraId) o un cambio en una tarea
|
|
427
|
+
* ¿Lo que trajo lienzo_esperar le toca a este agente? Algo dirigido a él (paraId), que lo menciona, o un cambio en una tarea
|
|
342
428
|
* que pidió. Solo entonces la web muestra «Escribiendo…»: un mensaje a toda la sala no pone a todos los
|
|
343
429
|
* agentes a escribir.
|
|
344
430
|
*/
|
|
345
431
|
meToca(lote) {
|
|
346
432
|
if (!lote) return false;
|
|
347
|
-
|
|
433
|
+
// para mí (paraId) o me mencionan (menciones): la convención es contestar solo en esos casos
|
|
434
|
+
const mio = (t) => t.autorId !== this.participanteId && (t.paraId === this.participanteId || menciona(t, this.participanteId));
|
|
348
435
|
return (lote.cambios?.length ?? 0) > 0 || (lote.tarjetas ?? []).some(mio) || (lote.editadas ?? []).some(mio);
|
|
349
436
|
}
|
|
350
437
|
|
|
438
|
+
/** ¿Una tarjeta ajena te menciona a ti (este agente)? */
|
|
439
|
+
_meMencionan(t) {
|
|
440
|
+
return Boolean(t) && t.autorId !== this.participanteId && menciona(t, this.participanteId);
|
|
441
|
+
}
|
|
442
|
+
|
|
351
443
|
async _cargarParticipantes(forzar = false) {
|
|
352
444
|
if (!forzar && Date.now() - this.participantesEn < RECARGA_PARTICIPANTES_MS && this.participantes.size) return;
|
|
353
445
|
const lista = await this.fs.consultar(`salas/${this.salaId}`, {
|
|
@@ -436,27 +528,132 @@ export class Lienzo {
|
|
|
436
528
|
}
|
|
437
529
|
|
|
438
530
|
/**
|
|
439
|
-
*
|
|
440
|
-
*
|
|
531
|
+
* Las salas de tu cuenta (índice `usuarios/{uid}/salas`, las de `vistaEn` más nuevo primero), sin las borradas:
|
|
532
|
+
* [{ salaId, nombre, vistaEn, archivada, creadaPorMi, aqui, link }]. 1 consulta + 1 batchGet.
|
|
533
|
+
*/
|
|
534
|
+
async misSalas({ limite = 30 } = {}) {
|
|
535
|
+
await this.sesion.token().catch((e) => { throw this._traducir(e, 'iniciar sesión'); });
|
|
536
|
+
let indice;
|
|
537
|
+
let salas;
|
|
538
|
+
try {
|
|
539
|
+
indice = (await this.fs.consultar(`usuarios/${this.uid}`, {
|
|
540
|
+
from: [{ collectionId: 'salas' }],
|
|
541
|
+
orderBy: [{ field: { fieldPath: 'vistaEn' }, direction: 'DESCENDING' }],
|
|
542
|
+
limit: limite,
|
|
543
|
+
})).filter((f) => /^[A-Za-z0-9_-]{22}$/.test(f.id));
|
|
544
|
+
salas = indice.length ? await this.fs.leerVarios(indice.map((f) => `salas/${f.id}`)) : [];
|
|
545
|
+
} catch (e) {
|
|
546
|
+
throw this._traducir(e, 'leer tus salas');
|
|
547
|
+
}
|
|
548
|
+
const porId = new Map(salas.map((s) => [s.id, s]));
|
|
549
|
+
return indice.flatMap((f) => {
|
|
550
|
+
const s = porId.get(f.id);
|
|
551
|
+
if (!s || s.borradaEn) return [];
|
|
552
|
+
return [{
|
|
553
|
+
salaId: f.id, nombre: String(s.nombre ?? ''), vistaEn: f.vistaEn ?? null, archivada: Boolean(s.archivadaEn),
|
|
554
|
+
creadaPorMi: s.creadaPor === this.uid, aqui: f.id === this.salaId, link: this.link(f.id),
|
|
555
|
+
}];
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** La sala de lo que pasó el agente: link, salaId o el nombre de una de tus salas (no se adivina entre varias). */
|
|
560
|
+
async _salaDe(entrada) {
|
|
561
|
+
const m = /\/sala\/([A-Za-z0-9_-]{22})(?:[/?#]|$)/.exec(entrada);
|
|
562
|
+
if (m) return m[1];
|
|
563
|
+
if (/^[A-Za-z0-9_-]{22}$/.test(entrada)) return entrada; // si no existe, unirse() lo prueba como nombre
|
|
564
|
+
if (/^https?:\/\//i.test(entrada) || /\/sala\//.test(entrada)) {
|
|
565
|
+
throw new ErrorLienzo('Ese link no es de una sala de Lienzo (…/sala/<salaId de 22 caracteres>). Revisa el que te pasaron.');
|
|
566
|
+
}
|
|
567
|
+
const salaId = await this._salaPorNombre(entrada);
|
|
568
|
+
if (salaId) return salaId;
|
|
569
|
+
throw new ErrorLienzo(`No encontré una sala tuya que se llame «${limpiarLinea(entrada, 120)}». Por nombre solo busco entre tus ` +
|
|
570
|
+
'salas (las que abriste tú o tu agente): lienzo_mis_salas las lista. Si es de otra persona, pídele el link.');
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Por nombre, entre tus salas. Entra sola SOLO si hay una única coincidencia (exacta o parcial, juntas) y es una sala
|
|
575
|
+
* que creó tu usuario: el nombre de una sala ajena lo pone quien la creó y puede renombrarla para que calce con lo
|
|
576
|
+
* que vas a escribir (revisión de seguridad, 21-10-2026). En cualquier otro caso lista y no elige. Un nombre con
|
|
577
|
+
* caracteres invisibles nunca calza. null si no hay ninguna.
|
|
578
|
+
*/
|
|
579
|
+
async _salaPorNombre(entrada) {
|
|
580
|
+
const salas = (await this.misSalas()).filter((s) => !tieneInvisibles(s.nombre));
|
|
581
|
+
const clave = claveNombre(entrada);
|
|
582
|
+
if (!clave) return null;
|
|
583
|
+
const exactas = salas.filter((s) => claveNombre(s.nombre) === clave);
|
|
584
|
+
const parciales = clave.length >= 3 ? salas.filter((s) => claveNombre(s.nombre) !== clave && claveNombre(s.nombre).includes(clave)) : [];
|
|
585
|
+
const hallo = [...exactas, ...parciales];
|
|
586
|
+
if (!hallo.length) return null;
|
|
587
|
+
if (hallo.length === 1 && hallo[0].creadaPorMi) return hallo[0].salaId;
|
|
588
|
+
const filas = hallo.map((s) => `- salaId: ${s.salaId} · ${s.creadaPorMi ? 'la creó tu usuario' : 'de otra persona'} · ` +
|
|
589
|
+
`tu última vez: ${haceCuanto(s.vistaEn)}${s.archivada ? ' · archivada' : ''}`);
|
|
590
|
+
throw new ErrorLienzo(`${hallo.length === 1 ? 'Hay 1 sala que coincide' : `Tienes ${hallo.length} salas que coinciden`} con ` +
|
|
591
|
+
`«${limpiarLinea(entrada, 120)}»${hallo.length === 1 ? ', pero no la creó tu usuario' : ''}: no elijo por ti. ` +
|
|
592
|
+
`Pregúntale a tu usuario cuál (lienzo_mis_salas las muestra con su nombre) y usa su salaId:\n${filas.join('\n')}`);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Los nombres (normalizados) de los otros agentes de mi cuenta en la sala. Solo lee si ya hay alguno (si no, []). */
|
|
596
|
+
async _nombresDeHermanos(salaId) {
|
|
597
|
+
try {
|
|
598
|
+
const idx = await this.fs.leer(`salas/${salaId}/cuentas/${this.uid}`).catch(() => null);
|
|
599
|
+
const slugs = (Array.isArray(idx?.agentes) ? idx.agentes : []).filter((x) => RE_SLUG.test(String(x)));
|
|
600
|
+
const ids = [idDeAgente(this.uid), ...slugs.map((x) => idDeAgente(this.uid, x))].filter((id) => id !== this.participanteId);
|
|
601
|
+
const docs = await this.fs.leerVarios(ids.map((id) => `salas/${salaId}/participantes/${id}`));
|
|
602
|
+
return docs.filter((d) => d.tipo === 'agente').map((d) => claveNombre(d.nombre ?? ''));
|
|
603
|
+
} catch {
|
|
604
|
+
return []; // sin permiso todavía (la cuenta no está en la sala): no hay hermanos que mirar
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Nombre por defecto para un agente que entra por primera vez: con « · 2», « · 3»… si otro de mis agentes ya se llama así. */
|
|
609
|
+
async _nombreNuevo(salaId, cuenta) {
|
|
610
|
+
const base = nombreAgentePorDefecto(cuenta, this.config.agente, this.cliente, this.config.perfil);
|
|
611
|
+
if (!base) return null;
|
|
612
|
+
const usados = new Set(await this._nombresDeHermanos(salaId));
|
|
613
|
+
if (!usados.has(claveNombre(base))) return base;
|
|
614
|
+
for (let n = 2; n < 100; n++) {
|
|
615
|
+
const candidato = limpiarLinea(`${base.slice(0, 74)} · ${n}`, 80);
|
|
616
|
+
if (!usados.has(claveNombre(candidato))) return candidato;
|
|
617
|
+
}
|
|
618
|
+
return base;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Entra a la sala como el agente de esta instalación: `{uid}_agente_{slug}` (slug de LIENZO_AGENTE o el automático
|
|
623
|
+
* de la instalación) o `{uid}_agente` (la instalación que heredó el de siempre). Tipo agente, dueñoUid = la cuenta,
|
|
624
|
+
* dueño = el nombre de la cuenta. `salaId` acepta el id, el link o el nombre de una de tus salas.
|
|
625
|
+
* Nombre: el que se pase o LIENZO_NOMBRE (mandan siempre); si no, el que ya tenga el agente en la sala (p. ej. el que
|
|
626
|
+
* le puso su persona desde la web: no se pisa); si entra por primera vez, «<Cliente> de <primer nombre>» (+ « · 2»
|
|
627
|
+
* si otro agente de la cuenta ya se llama así).
|
|
441
628
|
*/
|
|
442
629
|
async unirse({ salaId, nombre }) {
|
|
443
630
|
this._exigirAgenteValido();
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
631
|
+
const entrada = String(salaId || '').trim();
|
|
632
|
+
if (!entrada) throw new ErrorLienzo('Falta la sala: pasa su link, su salaId o su nombre (lienzo_mis_salas lista las tuyas).');
|
|
633
|
+
// `nombre` (lo pidió tu usuario en esta llamada) manda siempre; LIENZO_NOMBRE no pisa un nombre puesto desde la web
|
|
634
|
+
const pedido = String(nombre || '').trim();
|
|
635
|
+
const deConfig = pedido ? '' : String(this.config.nombre || '').trim();
|
|
636
|
+
const explicito = pedido || deConfig;
|
|
637
|
+
if (explicito.length > 80) throw new ErrorLienzo('El nombre admite hasta 80 caracteres.');
|
|
638
|
+
if (tieneInvisibles(explicito)) throw new ErrorLienzo('El nombre va en una sola línea, sin caracteres de control ni invisibles.');
|
|
639
|
+
if (explicito && !tieneLetraONumero(explicito)) throw new ErrorLienzo('El nombre necesita al menos una letra o un número.');
|
|
640
|
+
if (explicito) this._vigilarSecretos([['tu nombre', explicito]]);
|
|
449
641
|
|
|
450
642
|
await this.sesion.token().catch((e) => { throw this._traducir(e, 'iniciar sesión'); });
|
|
643
|
+
salaId = await this._salaDe(entrada);
|
|
644
|
+
await this._resolverInstalacion();
|
|
451
645
|
const cuenta = this.sesion.cuenta;
|
|
452
646
|
const dueño = nombreDeCuenta(cuenta);
|
|
453
|
-
nombre = String(nombre || this.config.nombre || nombreAgentePorDefecto(cuenta, this.slug) || '').trim();
|
|
454
|
-
if (!nombre) throw new ErrorLienzo('Falta tu nombre en la sala (p. ej. «Agente de Ana»): pásalo en nombre.');
|
|
455
|
-
if (nombre.length > 80) throw new ErrorLienzo('El nombre admite hasta 80 caracteres.');
|
|
456
|
-
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.');
|
|
457
|
-
this._vigilarSecretos([['tu nombre', nombre]]);
|
|
458
647
|
|
|
459
|
-
|
|
648
|
+
let sala = await this.fs.leer(`salas/${salaId}`).catch((e) => { throw this._traducir(e, 'leer la sala'); });
|
|
649
|
+
// 22 letras y números que no son el id de ninguna sala: puede ser el NOMBRE de una de tus salas
|
|
650
|
+
if (!sala && salaId === entrada) {
|
|
651
|
+
const porNombre = await this._salaPorNombre(entrada);
|
|
652
|
+
if (porNombre) {
|
|
653
|
+
salaId = porNombre;
|
|
654
|
+
sala = await this.fs.leer(`salas/${salaId}`).catch((e) => { throw this._traducir(e, 'leer la sala'); });
|
|
655
|
+
}
|
|
656
|
+
}
|
|
460
657
|
if (!sala) throw new ErrorLienzo('Esa sala no existe. Revisa el salaId.');
|
|
461
658
|
// borrada: las reglas ya no dejan escribir ahí; se avisa sin tocar nada
|
|
462
659
|
if (sala.borradaEn) {
|
|
@@ -466,8 +663,13 @@ export class Lienzo {
|
|
|
466
663
|
|
|
467
664
|
const ruta = `salas/${salaId}/participantes/${this.participanteId}`;
|
|
468
665
|
const previo = await this.fs.leer(ruta).catch(() => null);
|
|
666
|
+
const enSala = nombreValido(previo?.nombre) ? previo.nombre : '';
|
|
667
|
+
const clave = this.claveInstalacion;
|
|
668
|
+
const configManda = deConfig && nombreDeConfigManda(deConfig, enSala, nombresAplicados(this.config.home, clave));
|
|
669
|
+
nombre = pedido || (configManda ? deConfig : '') || enSala || deConfig || (await this._nombreNuevo(salaId, cuenta)) || '';
|
|
670
|
+
if (!nombre) throw new ErrorLienzo('Falta tu nombre en la sala (p. ej. «Claude Code de Ana»): pásalo en nombre.');
|
|
469
671
|
const datos = {
|
|
470
|
-
nombre, tipo: 'agente', 'dueño': dueño, 'dueñoUid': this.uid, cliente: this.
|
|
672
|
+
nombre, tipo: 'agente', 'dueño': dueño, 'dueñoUid': this.uid, cliente: this.cliente,
|
|
471
673
|
posicion: previo?.posicion ?? null,
|
|
472
674
|
};
|
|
473
675
|
let commit;
|
|
@@ -492,6 +694,7 @@ export class Lienzo {
|
|
|
492
694
|
throw texto ? new ErrorLienzo(texto, { cause: e }) : this._traducir(e, 'unirte a la sala');
|
|
493
695
|
}
|
|
494
696
|
|
|
697
|
+
if (deConfig) await anotarNombreAplicado(this.config.home, clave, deConfig);
|
|
495
698
|
this.salasPerdidas.delete(salaId); // un lienzo_unirse explícito vuelve a abrir la sala
|
|
496
699
|
this.ultimaPerdida = null;
|
|
497
700
|
// accesos/{uid} se vuelve a escribir: pudo borrarse al salir desde la web, o la cuenta es otra
|
|
@@ -512,6 +715,7 @@ export class Lienzo {
|
|
|
512
715
|
this.participantes.clear();
|
|
513
716
|
this.participantesEn = 0;
|
|
514
717
|
this._ausentes.clear();
|
|
718
|
+
this.racha = { ...RACHA_VACIA, hasta: '' };
|
|
515
719
|
}
|
|
516
720
|
await this._anotarEnIndice(salaId);
|
|
517
721
|
return { salaId, sala, link: this.link(salaId), yo: this.yo };
|
|
@@ -523,8 +727,11 @@ export class Lienzo {
|
|
|
523
727
|
const idx = await this.fs.leer(`salas/${salaId}/cuentas/${this.uid}`);
|
|
524
728
|
const agentes = Array.isArray(idx?.agentes) ? idx.agentes : [];
|
|
525
729
|
if (agentes.length < TOPE_AGENTES || agentes.includes(this.slug)) return null;
|
|
526
|
-
return `Tu cuenta ya tiene ${agentes.length} agentes
|
|
527
|
-
|
|
730
|
+
return `Tu cuenta ya tiene ${agentes.length} agentes en esta sala (${agentes.map((a) => limpiarLinea(a, 24)).join(', ')}), ` +
|
|
731
|
+
'que es el tope. Cada instalación del puente (cada perfil de Claude Code, cada cliente o cada máquina) es un agente ' +
|
|
732
|
+
'aparte. Pídele a tu usuario que abra la sala en la web, vaya a ⋮ → Participantes y use «Quitar» en el agente suyo ' +
|
|
733
|
+
'que ya no usa (sale solo ese agente; él y los demás siguen en la sala). Después vuelve a llamar lienzo_unirse. O ' +
|
|
734
|
+
'usa en LIENZO_AGENTE uno de esos.';
|
|
528
735
|
}
|
|
529
736
|
|
|
530
737
|
// ── cuenta ─────────────────────────────────────────────────────────────────
|
|
@@ -603,12 +810,21 @@ export class Lienzo {
|
|
|
603
810
|
|
|
604
811
|
/**
|
|
605
812
|
* lienzo_entrar. Con una sesión que sigue valiendo no abre nada (salvo otraCuenta). Si no, abre el navegador y
|
|
606
|
-
* espera hasta `esperaMs` (3 min); al vencer o cancelarse, el servidor local se cierra.
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
813
|
+
* espera hasta `esperaMs` (3 min); al vencer o cancelarse, el servidor local se cierra. Una segunda llamada mientras
|
|
814
|
+
* tanto reusa el mismo login (no abre otra ventana). Si quedó conectada otra cuenta, se olvida la sala en la que
|
|
815
|
+
* estaba (no se une sola a la nueva).
|
|
816
|
+
*
|
|
817
|
+
* La URL (lleva el nonce) va a stderr y vuelve al modelo SOLO si la pide con `sinNavegador: true`: `{ estado:
|
|
818
|
+
* 'link', url }`, sin esperar (sirve también para una entrada que ya estaba en curso). Si el navegador no se pudo
|
|
819
|
+
* abrir y no se pidió el link, devuelve `{ estado: 'noAbrio' }` SIN la URL: el agente decide pedirla con
|
|
820
|
+
* sinNavegador (revisión de integra-06; antes la URL volvía al modelo cada vez que fallaba `abrir`). La decisión de
|
|
821
|
+
* fondo (a: nunca al modelo / b: con sinNavegador) sigue siendo del dueño (docs/pendientes.md, «Link de /conectar
|
|
822
|
+
* solo en stderr»). En los dos casos la espera sigue en segundo plano y la próxima lienzo_entrar se engancha a ella
|
|
823
|
+
* y devuelve 'listo' o 'vencido'. Sin esto, Codex en su sandbox o Cursor sin navegador no tenían cómo conectar.
|
|
824
|
+
*
|
|
825
|
+
* Devuelve { estado: 'ya' | 'listo' | 'vencido' | 'noAbrio' | 'link', cuenta?, salaOlvidada?, url? }.
|
|
610
826
|
*/
|
|
611
|
-
async entrar({ otraCuenta = false, esperaMs = ENTRAR_TIMEOUT_MS, signal, alAbrir } = {}) {
|
|
827
|
+
async entrar({ otraCuenta = false, esperaMs = ENTRAR_TIMEOUT_MS, signal, alAbrir, sinNavegador = false } = {}) {
|
|
612
828
|
if (!otraCuenta && !this.entrada && this.sesion.cuenta) {
|
|
613
829
|
try {
|
|
614
830
|
await this.sesion.token();
|
|
@@ -617,25 +833,63 @@ export class Lienzo {
|
|
|
617
833
|
if (!(e instanceof ErrorSesion)) throw e; // red: no se abre el navegador por un corte
|
|
618
834
|
}
|
|
619
835
|
}
|
|
836
|
+
// La espera tiene su propio AbortController: la llamada que espera la cancela si la cancelan a ella, pero una que
|
|
837
|
+
// ya devolvió (el link o «no se abrió») no se la lleva (la espera sigue para la próxima lienzo_entrar).
|
|
620
838
|
if (!this.entrada) {
|
|
621
|
-
const
|
|
839
|
+
const control = new AbortController();
|
|
840
|
+
let avisarUrl;
|
|
841
|
+
let avisarNoAbrio;
|
|
842
|
+
const info = {
|
|
843
|
+
url: new Promise((r) => { avisarUrl = r; }), // la URL de esta entrada, apenas escucha el servidor local
|
|
844
|
+
noAbrio: new Promise((r) => { avisarNoAbrio = r; }),
|
|
845
|
+
avisada: false, // ya se le dijo al modelo que siga esperando (le dio el link o le dijo que no se abrió)
|
|
846
|
+
};
|
|
847
|
+
const entrada = this.sesion.entrar({
|
|
848
|
+
abrir: sinNavegador ? null : this.abrir,
|
|
849
|
+
timeoutMs: esperaMs,
|
|
850
|
+
signal: control.signal,
|
|
851
|
+
onListening: ({ url }) => { avisarUrl(url); alAbrir?.(); },
|
|
852
|
+
onNoAbrio: () => avisarNoAbrio(),
|
|
853
|
+
})
|
|
622
854
|
.then((cuenta) => {
|
|
623
855
|
this._soltarPerdidasAjenas();
|
|
624
|
-
|
|
856
|
+
let salaOlvidada = Boolean(this.uidEnSala && this.uidEnSala !== this.uid);
|
|
857
|
+
// con otraCuenta no se sigue en la sala anterior ni aunque se eligiera la misma cuenta: se entra a propósito
|
|
858
|
+
if (otraCuenta && this.salaId) salaOlvidada = true;
|
|
625
859
|
if (salaOlvidada) this._olvidarSala();
|
|
626
860
|
return { cuenta, salaOlvidada };
|
|
627
861
|
})
|
|
628
862
|
.finally(() => { if (this.entrada === entrada) this.entrada = null; });
|
|
863
|
+
entrada.catch(() => undefined); // si nadie la espera (se devolvió antes), que no quede una promesa sin manejar
|
|
629
864
|
this.entrada = entrada;
|
|
865
|
+
this.entradaControl = control;
|
|
866
|
+
this.entradaInfo = info;
|
|
630
867
|
}
|
|
868
|
+
const info = this.entradaInfo;
|
|
869
|
+
const control = this.entradaControl;
|
|
870
|
+
const cancelar = () => control?.abort();
|
|
871
|
+
signal?.addEventListener('abort', cancelar, { once: true });
|
|
631
872
|
try {
|
|
632
|
-
const
|
|
633
|
-
|
|
634
|
-
if (
|
|
873
|
+
const espera = this.entrada.then((r) => ({ hecho: r }));
|
|
874
|
+
let r;
|
|
875
|
+
if (sinNavegador) r = await Promise.race([espera, info.url.then((url) => ({ url }))]);
|
|
876
|
+
else if (info.avisada) r = await espera;
|
|
877
|
+
else r = await Promise.race([espera, info.noAbrio.then(() => ({ noAbrio: true }))]);
|
|
878
|
+
if (r.url) {
|
|
879
|
+
info.avisada = true;
|
|
880
|
+
return { estado: 'link', url: r.url };
|
|
881
|
+
}
|
|
882
|
+
if (r.noAbrio) {
|
|
883
|
+
info.avisada = true;
|
|
884
|
+
return { estado: 'noAbrio' };
|
|
885
|
+
}
|
|
886
|
+
const { cuenta, salaOlvidada } = r.hecho;
|
|
635
887
|
return { estado: 'listo', cuenta, salaOlvidada };
|
|
636
888
|
} catch (e) {
|
|
637
889
|
if (e instanceof ErrorSesion && e.codigo === 'vencido') return { estado: 'vencido' };
|
|
638
890
|
throw this._traducir(e, 'conectar tu cuenta');
|
|
891
|
+
} finally {
|
|
892
|
+
signal?.removeEventListener('abort', cancelar);
|
|
639
893
|
}
|
|
640
894
|
}
|
|
641
895
|
|
|
@@ -681,16 +935,115 @@ export class Lienzo {
|
|
|
681
935
|
return texto;
|
|
682
936
|
}
|
|
683
937
|
|
|
684
|
-
|
|
938
|
+
/**
|
|
939
|
+
* menciones (ids o nombres, «@» opcional) → ids de participantes que existen, sin repetidos y sin ti. Un nombre que
|
|
940
|
+
* llevan dos no se adivina (_resolverParticipante). «@todos» o una lista vacía = sin menciones (toda la sala).
|
|
941
|
+
*/
|
|
942
|
+
async _resolverMenciones(menciones) {
|
|
943
|
+
let refs;
|
|
944
|
+
try { refs = referenciasDeMenciones(menciones); } catch (e) { throw new ErrorLienzo(e.message); }
|
|
945
|
+
const ids = [];
|
|
946
|
+
for (const r of refs) {
|
|
947
|
+
const id = await this._resolverParticipante(r);
|
|
948
|
+
if (id && id !== this.participanteId && !ids.includes(id)) ids.push(id);
|
|
949
|
+
}
|
|
950
|
+
return ids;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Las tarjetas recientes para contar turnos (menciones.mjs): de la más nueva a la más vieja, de a PAGINA_TURNOS,
|
|
955
|
+
* hasta dar con una de una persona o PAGINAS_TURNOS páginas. El cursor es (creadaEn, id): los puntos de una lista
|
|
956
|
+
* nacen en el mismo commit y comparten la hora, así que un corte solo por la hora se saltaría tarjetas.
|
|
957
|
+
*/
|
|
958
|
+
async _rachaReciente() {
|
|
959
|
+
const recientes = [];
|
|
960
|
+
let agotada = false;
|
|
961
|
+
let cursor = null;
|
|
962
|
+
for (let i = 0; i < PAGINAS_TURNOS; i++) {
|
|
963
|
+
const consulta = {
|
|
964
|
+
from: [{ collectionId: 'tarjetas' }],
|
|
965
|
+
orderBy: [
|
|
966
|
+
{ field: { fieldPath: 'creadaEn' }, direction: 'DESCENDING' },
|
|
967
|
+
{ field: { fieldPath: '__name__' }, direction: 'DESCENDING' },
|
|
968
|
+
],
|
|
969
|
+
limit: PAGINA_TURNOS,
|
|
970
|
+
};
|
|
971
|
+
if (cursor) consulta.startAt = { values: cursor, before: false };
|
|
972
|
+
const pagina = await this.fs.consultar(`salas/${this.salaId}`, consulta)
|
|
973
|
+
.catch((e) => { throw this._traducir(e, 'leer las últimas tarjetas de la sala'); });
|
|
974
|
+
recientes.push(...pagina);
|
|
975
|
+
const ultima = pagina.at(-1);
|
|
976
|
+
if (pagina.length < PAGINA_TURNOS || !ultima?.creadaEn) { agotada = true; break; }
|
|
977
|
+
if (pagina.some((t) => esPersona(t.autorId))) break; // lo anterior a una persona no cuenta
|
|
978
|
+
cursor = [
|
|
979
|
+
{ timestampValue: ultima.creadaEn },
|
|
980
|
+
{ referenceValue: this.fs.nombre(`salas/${this.salaId}/tarjetas/${ultima.id}`) },
|
|
981
|
+
];
|
|
982
|
+
}
|
|
983
|
+
return { racha: rachaDeRecientes(recientes, { agotada }), recientes };
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Tope de turnos entre agentes (menciones.mjs): antes de crear una tarjeta se cuenta la racha y, si ya llegó al tope,
|
|
988
|
+
* no se publica nada dirigido a otro agente (paraId, menciones o respondeA a una tarjeta de otro agente) y lo demás
|
|
989
|
+
* pasa una vez por agente. El autor de respondeA se lee solo en el tope (de lo ya leído o con una lectura).
|
|
990
|
+
*/
|
|
991
|
+
async _revisarTurnos({ destinos = [], respondeA = null } = {}) {
|
|
992
|
+
const { racha, recientes } = await this._rachaReciente();
|
|
993
|
+
if (racha.turnos < TOPE_TURNOS) return racha;
|
|
994
|
+
const lista = destinos.filter(Boolean);
|
|
995
|
+
if (respondeA) {
|
|
996
|
+
let autor = recientes.find((t) => t.id === respondeA)?.autorId ?? null;
|
|
997
|
+
if (!autor) {
|
|
998
|
+
autor = (await this.fs.leer(`salas/${this.salaId}/tarjetas/${respondeA}`).catch(() => null))?.autorId ?? null;
|
|
999
|
+
}
|
|
1000
|
+
if (autor) lista.push(autor);
|
|
1001
|
+
}
|
|
1002
|
+
const dirigido = vaAOtroAgente(this.participanteId, lista);
|
|
1003
|
+
if (pasaElTope(racha, this.participanteId, dirigido)) throw new ErrorLienzo(textoTope(racha, dirigido));
|
|
1004
|
+
return racha;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** Suma a la racha las tarjetas entregadas (en orden de creación), cada una una sola vez. */
|
|
1008
|
+
_sumarRacha(tarjetas) {
|
|
1009
|
+
let r = this.racha ?? { ...RACHA_VACIA, hasta: '' };
|
|
1010
|
+
const porId = new Map(tarjetas.map((t) => [t.id, t.autorId]));
|
|
1011
|
+
const autorDe = (id) => porId.get(id) ?? null;
|
|
1012
|
+
for (const t of tarjetas) {
|
|
1013
|
+
const k = claveTiempo(t.creadaEn);
|
|
1014
|
+
if (!k || k <= r.hasta) continue;
|
|
1015
|
+
r = { ...sumarTarjeta(r, t, autorDe), hasta: k };
|
|
1016
|
+
}
|
|
1017
|
+
this.racha = r;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
async publicar({ texto, paraId = null, respondeA = null, tipo = 'mensaje', menciones = null }) {
|
|
685
1021
|
await this._asegurarSala();
|
|
686
1022
|
if (!['mensaje', 'nota'].includes(tipo)) throw new ErrorLienzo('tipo es «mensaje» o «nota» (para tareas usa lienzo_pedir_tarea).');
|
|
687
1023
|
texto = this._validarTexto(texto);
|
|
688
1024
|
this._vigilarSecretos([['el texto', texto]]);
|
|
689
1025
|
const para = await this._resolverParticipante(paraId);
|
|
1026
|
+
const ids = await this._resolverMenciones(menciones);
|
|
690
1027
|
if (respondeA && !ID_TARJETA.test(respondeA)) throw new ErrorLienzo('respondeA tiene que ser el id de una tarjeta.');
|
|
691
|
-
|
|
1028
|
+
await this._revisarTurnos({ destinos: [para, ...ids], respondeA });
|
|
1029
|
+
const datos = { tipo, texto, paraId: para, respondeA: respondeA || null };
|
|
1030
|
+
let sinMenciones = false;
|
|
1031
|
+
let id;
|
|
1032
|
+
// el campo va solo si hay menciones: una tarjeta sin ellas es igual a la de siempre (reglas viejas incluidas)
|
|
1033
|
+
if (!ids.length) id = await this._crearTarjeta(datos);
|
|
1034
|
+
else {
|
|
1035
|
+
try {
|
|
1036
|
+
id = await this._crearTarjeta({ ...datos, menciones: ids });
|
|
1037
|
+
} catch (e) {
|
|
1038
|
+
// Reglas anteriores a F2 (despliegue a medias): el campo se rechaza. Se publica igual, sin él, y se dice.
|
|
1039
|
+
// Si te sacaron de la sala, el reintento también se niega y el error es el de siempre.
|
|
1040
|
+
if (!negadoDe(e)) throw e;
|
|
1041
|
+
id = await this._crearTarjeta(datos);
|
|
1042
|
+
sinMenciones = true;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
692
1045
|
await this.latido();
|
|
693
|
-
return { id, paraId: para, link: this.linkTarjeta(id) };
|
|
1046
|
+
return { id, paraId: para, menciones: sinMenciones ? [] : ids, sinMenciones, link: this.linkTarjeta(id) };
|
|
694
1047
|
}
|
|
695
1048
|
|
|
696
1049
|
/**
|
|
@@ -740,6 +1093,8 @@ export class Lienzo {
|
|
|
740
1093
|
try { revisarZip(buf); } catch (e) { throw this._traducir(e, 'compartir el archivo'); }
|
|
741
1094
|
}
|
|
742
1095
|
const para = await this._resolverParticipante(paraId);
|
|
1096
|
+
// el tope de turnos se mira antes de subir nada a Storage (si no, quedaría un objeto sin tarjeta)
|
|
1097
|
+
await this._revisarTurnos({ destinos: [para] });
|
|
743
1098
|
|
|
744
1099
|
// texto chico (también un SVG, como en 0.3: la tarjeta lo muestra como código, nunca lo dibuja): en la tarjeta
|
|
745
1100
|
if (enTarjeta) {
|
|
@@ -953,6 +1308,7 @@ export class Lienzo {
|
|
|
953
1308
|
texto = this._validarTexto(texto);
|
|
954
1309
|
this._vigilarSecretos([['el texto', texto]]);
|
|
955
1310
|
const para = await this._resolverParticipante(paraId);
|
|
1311
|
+
await this._revisarTurnos({ destinos: [para] });
|
|
956
1312
|
const id = await this._crearTarjeta({ tipo: 'tarea', texto, paraId: para, estado: 'abierta' });
|
|
957
1313
|
this.tareasPedidas.set(id, { estado: 'abierta', respuesta: null, tomadaPor: null });
|
|
958
1314
|
await this.latido();
|
|
@@ -983,6 +1339,7 @@ export class Lienzo {
|
|
|
983
1339
|
if (!paraId) throw new ErrorLienzo('Una lista necesita destinatario (paraId): mira lienzo_participantes.');
|
|
984
1340
|
this._vigilarSecretos([['el título', titulo], ['el contexto', contexto], ...puntos.map((p, i) => [`el punto ${i + 1}`, p])]);
|
|
985
1341
|
const para = await this._resolverParticipante(paraId);
|
|
1342
|
+
await this._revisarTurnos({ destinos: [para] });
|
|
986
1343
|
|
|
987
1344
|
const id = nuevoId();
|
|
988
1345
|
const ids = puntos.map((_, i) => idPunto(id, i + 1));
|
|
@@ -1229,9 +1586,12 @@ export class Lienzo {
|
|
|
1229
1586
|
marcaDesde = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?Z$/.test(marcaDesde) ? marcaDesde : d.toISOString();
|
|
1230
1587
|
}
|
|
1231
1588
|
const tarjetas = await this._tarjetasDesde(marcaDesde);
|
|
1589
|
+
// las últimas de la sala: la racha de turnos se cuenta de nuevo sobre ellas (con un hueco no se arrastra la vieja)
|
|
1590
|
+
if (!marcaDesde) this.racha = { ...RACHA_VACIA, hasta: '' };
|
|
1232
1591
|
const editadas = await this._editadasDesde(marcaDesde);
|
|
1233
1592
|
const cambios = await this._cambiosEnMisTareas();
|
|
1234
|
-
|
|
1593
|
+
// sin desde son las últimas de la sala: la línea del tope de turnos vale (con desde pueden ser viejas)
|
|
1594
|
+
return { tarjetas, editadas, cambios, desde: marcaDesde, lleno: Boolean(tarjetas.lleno), conTope: !marcaDesde };
|
|
1235
1595
|
}
|
|
1236
1596
|
|
|
1237
1597
|
/** Para lienzo_esperar: tarjetas y ediciones ajenas y cambios en tus tareas; null si no hay nada ajeno. */
|
|
@@ -1277,6 +1637,10 @@ export class Lienzo {
|
|
|
1277
1637
|
const id = limpiarLinea(t.id, 64);
|
|
1278
1638
|
const para = t.paraId ? await this._describir(t.paraId) : null;
|
|
1279
1639
|
const tomador = t.tomadaPor ? await this._describir(t.tomadaPor) : null;
|
|
1640
|
+
// menciones: quién (nombres elegidos por otros → siempre dentro del sobre) y si te nombran a ti
|
|
1641
|
+
const mencionados = mencionesDe(t).sort((a, b) => Number(b === this.participanteId) - Number(a === this.participanteId));
|
|
1642
|
+
const menciona_ = mencionados.length ? `menciona a: ${(await Promise.all(mencionados.map((m) => this._describir(m)))).join(', ')}` : null;
|
|
1643
|
+
const teMencionan = t.autorId !== this.participanteId && mencionados.includes(this.participanteId);
|
|
1280
1644
|
const fechas = [t.creadaEn ? `creada: ${limpiarLinea(t.creadaEn, 40)}` : null, t.editadaEn ? `editada: ${limpiarLinea(t.editadaEn, 40)}` : null];
|
|
1281
1645
|
const estado = t.tipo === 'tarea' ? `estado: ${limpiarLinea(t.estado, 20)}` : null;
|
|
1282
1646
|
|
|
@@ -1309,7 +1673,7 @@ export class Lienzo {
|
|
|
1309
1673
|
// Lo tuyo va sin sobre, pero sin nombres ajenos: esos van dentro del sobre de abajo.
|
|
1310
1674
|
salida.push([`(tuya) tipo: ${limpiarLinea(t.tipo, 20)}`, `id: ${id}`, ...fechas, estado, datoArchivo].filter(Boolean).join(' · '));
|
|
1311
1675
|
salida.push(neutralizar(cuerpo));
|
|
1312
|
-
const otros = [para && para !== 'ti' ? `para: ${para}` : null, tomador && tomador !== 'ti' ? `tomada por: ${tomador}` : null].filter(Boolean);
|
|
1676
|
+
const otros = [para && para !== 'ti' ? `para: ${para}` : null, tomador && tomador !== 'ti' ? `tomada por: ${tomador}` : null, menciona_].filter(Boolean);
|
|
1313
1677
|
if (t.respuesta && t.tipo === 'tarea') {
|
|
1314
1678
|
const quien = await this._autor(t.tomadaPor || t.paraId);
|
|
1315
1679
|
salida.push(envolver({
|
|
@@ -1324,7 +1688,7 @@ export class Lienzo {
|
|
|
1324
1688
|
}
|
|
1325
1689
|
} else {
|
|
1326
1690
|
const autor = await this._autor(t.autorId);
|
|
1327
|
-
const extra = [...fechas, para ? `para: ${para}` : null, t.respondeA ? `responde a: ${limpiarLinea(t.respondeA, 64)}` : null,
|
|
1691
|
+
const extra = [teMencionan ? 'te mencionan' : null, ...fechas, para ? `para: ${para}` : null, menciona_, t.respondeA ? `responde a: ${limpiarLinea(t.respondeA, 64)}` : null,
|
|
1328
1692
|
estado ? `${estado}${tomador ? ` (tomada por ${tomador})` : ''}` : null, datoArchivo].filter(Boolean);
|
|
1329
1693
|
salida.push(envolver({
|
|
1330
1694
|
de: autor.nombre, tipoAutor: autor.tipo, 'dueño': autor['dueño'], autorId: t.autorId, homonimos: autor.homonimos,
|
|
@@ -1500,7 +1864,8 @@ export class Lienzo {
|
|
|
1500
1864
|
const visibles = piezas.filter((p) => !(soloAjenas && p.t.autorId === this.participanteId));
|
|
1501
1865
|
return {
|
|
1502
1866
|
seccion: 'tarjetas', piezas: visibles.length, silencioso: !visibles.length,
|
|
1503
|
-
|
|
1867
|
+
mias: visibles.filter((p) => this._meMencionan(p.t)).map((p) => p.t.id),
|
|
1868
|
+
entregar: () => { for (const t of grupo) this._avanzarMarca(t.creadaEn); this._sumarRacha(grupo); },
|
|
1504
1869
|
ultimaCreada: grupo[grupo.length - 1].creadaEn,
|
|
1505
1870
|
texto: async (codigo, max) => (await Promise.all(visibles.map((p) => (p.puntos
|
|
1506
1871
|
? this._formatearLista(p.t, [...p.puntos].sort((a, b) => (a.id < b.id ? -1 : 1)), { codigo, max })
|
|
@@ -1534,6 +1899,7 @@ export class Lienzo {
|
|
|
1534
1899
|
...editadas.map((t) => ({
|
|
1535
1900
|
seccion: 'editadas', piezas: 1, entregar: () => this._avanzarEdicion(t.editadaEn),
|
|
1536
1901
|
texto: (codigo) => this.formatearTarjeta(t, { codigo, editada: true }),
|
|
1902
|
+
mias: this._meMencionan(t) ? [t.id] : [],
|
|
1537
1903
|
// la edición de una tarjeta que llega nueva en esta misma respuesta ya viene con el texto al día
|
|
1538
1904
|
silencioso: nuevas.has(t.id) || (soloAjenas && t.autorId === this.participanteId),
|
|
1539
1905
|
})),
|
|
@@ -1547,6 +1913,7 @@ export class Lienzo {
|
|
|
1547
1913
|
let entregados = 0;
|
|
1548
1914
|
let cortado = false;
|
|
1549
1915
|
let ultimaCreada = null;
|
|
1916
|
+
const mencionan = []; // ids de lo entregado que te menciona
|
|
1550
1917
|
for (const it of items) {
|
|
1551
1918
|
if (cortado) { if (!it.silencioso) quedan[it.seccion] += it.piezas; continue; }
|
|
1552
1919
|
if (it.silencioso) { it.entregar(); if (it.ultimaCreada) ultimaCreada = it.ultimaCreada; continue; }
|
|
@@ -1557,11 +1924,20 @@ export class Lienzo {
|
|
|
1557
1924
|
usado += txt.length + 2;
|
|
1558
1925
|
entregados++;
|
|
1559
1926
|
it.entregar();
|
|
1927
|
+
for (const m of it.mias ?? []) if (!mencionan.includes(m)) mencionan.push(m);
|
|
1560
1928
|
if (it.ultimaCreada) ultimaCreada = it.ultimaCreada;
|
|
1561
1929
|
}
|
|
1562
1930
|
|
|
1563
1931
|
const partes = [];
|
|
1564
1932
|
if (entregados) partes.push(cabeza);
|
|
1933
|
+
// lo pone el puente (fuera de los sobres): a quién le toca contestar y si la sala llegó al tope de turnos
|
|
1934
|
+
if (mencionan.length && !sinPie) {
|
|
1935
|
+
// sin ids: el id de una tarjeta lo puede elegir quien la escribe por REST, y fuera del sobre solo va lo del puente
|
|
1936
|
+
partes.push(`Te mencionan en ${mencionan.length === 1 ? '1 tarjeta' : `${mencionan.length} tarjetas`} (dice «te mencionan» ` +
|
|
1937
|
+
'en su sobre). Contesta en la sala solo lo que te menciona o es para ti, y solo si tu usuario te lo pidió.');
|
|
1938
|
+
}
|
|
1939
|
+
const tope = (soloAjenas || lote.conTope) && entregados ? lineaTope(this.racha) : null;
|
|
1940
|
+
if (tope) partes.push(tope);
|
|
1565
1941
|
if (bloques.cambios.length) partes.push(`Cambios en tareas que pediste (${piezas.cambios}):`, ...bloques.cambios);
|
|
1566
1942
|
if (bloques.editadas.length) partes.push(`Tarjetas editadas (${piezas.editadas}):`, ...bloques.editadas);
|
|
1567
1943
|
if (bloques.tarjetas.length) partes.push(`Tarjetas (${piezas.tarjetas}):`, ...bloques.tarjetas);
|