# Risa → Authy · Telegram shared circuit — design + implementation plan

**Fecha:** 2026-08-11
**Apps:** risa (`apps/liberada/risa/`) → lovy (P3) → future community apps
**Estado:** diseño aprobado por Marc; sin implementar aún (plan de referencia)
**Specs que evoluciona:** `specs/2026-07-19-risa-banco-telegram-moderation-design.md`
**Plans que enlaza:** `plans/2026-07-19-risa-banco-web-phase0.md`, `plans/2026-07-19-risa-banco-bot-phase1.md`, `plans/2026-07-25-browsy-keys-trust-performance.md`, `plans/central-backend.md` (P01), `proposals.md` P13 (claim via authy)

---

## 1. Goal

Convertir el circuito del **Banco de la risa** (Telegram subida → moderación → `banco.json` → web) en un **patrón compartido y reutilizable** para cualquier app de comunidad (risa ahora, lovy después), añadiendo:

- **Authy** como capa de identidad canal-agnóstica en `central/shared` (Telegram es un *driver* más; mañana email/phone).
- Un **filespace `central/users/<key>/`** persistente por autor (navegación estable, agregación cross-app).
- **Claim flow** (L2 → L3): vincular un clip a un perfil sin exponer nunca el nickname real de Telegram en la web.
- Feed de autor + enlaces en el perfil de **appy** (contribuciones).
- Fases v1 (serverless, bot repo + filespace) y v2 (Railway FastAPI + libSQL).

## 2. Arquitectura

```
  Autor ──(nota de voz / acción + palabra)──▶ 🤖 bot <app> (Telegram)
         │  (moderador ve el @username real; el público NUNCA)
         │  GitHub Actions cron
         ▼
  ✅ aprobado ─▶ ffmpeg/audio a Cloudflare R2  (+/anonimo/ si anónima)
         │
         ├──▶ prepend `banco.json` (app)   { id, name: PALABRA, src, when, tags?, authy?, author?, key? }
         ├──▶ registra/actualiza `central/users/<key>/`  (clips.json + index.html + authy.json)
         └──▶ post al canal público de Telegram

  Web: fetch banco.json → 6ª playlist / feed
       nombre = PALABRA (nunca @username)
       no reclamada → rojo + modal «Claim it»
       reclamada   → enlace al perfil
```

**Regla de privacidad (firme):** en la web solo se muestra la **palabra** (`name`). El `@username` real vive solo en: (a) el DM del propio autor, (b) la superficie privada de moderación, (c) la cabecera del bot. Nunca se renderiza en la web, ni en clips, ni en el modal, ni tras el claim.

## 3. Decisiones (asentadas por Marc)

| # | Decisión |
|---|---|
| D1 | **Authy abstraído y canal-agnóstico.** `authy.js` = núcleo (niveles, identidad, claim/verify); `authy-telegram.js` = driver; email/phone = stubs futuros. El clip referencia `authy` + `author`, nunca el canal. |
| D2 | **Niveles:** L1 self/authored (anónima o con palabra) · L2 telegram-presencia no reclamada · L3 telegram verificado (claimed) · L4 +phone (futuro) · L5 +biometría (futuro). |
| D3 | **`key` inmutable = URL estable** (`users/sara-2/`); **`name` = palabra, por defecto el key, editable** por el autor (DM `/name` en v1; API en v2). Los moderadores NO renombran. |
| D4 | **Colisión de keys:** slug (`a–z0–9-`, minúsculas), *first-wins*, sufijo `-2`, `-3`… Sólo se sufija el key, nunca la palabra mostrada. El telegram id (claim) es el tie-breaker autoritativo. |
| D5 | **La palabra es el display.** En la subida (input de autor en web; bot DM para notas de voz) la palabra sobrescribe la muestra del id; el id de Telegram se guarda como **link-info oculto** para el claim. |
| D6 | **Revelar Telegram = opt-in.** Por defecto la palabra es display-only. Solo si el autor marca «muestra también mi Telegram» tras grabar, la palabra enlaza a `t.me/…` (o muestra el handle en hover). |
| D7 | **Claim = gate por id.** Permitido ⟺ el id que confirma en el DM == id que subió el clip. Nonce (aleatorio, one-shot, TTL) identifica el intento y evita replay; el id autoriza. |
| D8 | **El nonce (v1)** vive en `state/pending-claims.json` del repo del bot (one-shot, TTL ~10–15 min). **v2:** FastAPI lo mintea server-side en libSQL (`claims`), rate-limited. |
| D9 | **`central/users/` es el store canónico de identidad** (v1 filespace; v2 lee lo mismo desde libSQL). El repo `banco-risa` deja de ser dueño de identidad → bot puro. |
| D10 | **El bot escribe claims en `central/users/`** vía PAT dedicado (v1) o `POST /api/authy/claim` (v2). La web NUNCA escribe identidad (sin token en cliente). |
| D11 | **Los clips se actualizan al renombrar:** el bot hace backfill de `name` en `banco.json` para todos los clips con ese `key` (opción 2a). |
| D12 | **Anónima:** sin carpeta de autor; el audio va a prefijo `anonimo/` del bucket R2. Sin claim posible. |
| D13 | **Legacy:** los clips ya publicados sin key se dejan como están y se documentan. |
| D14 | **`banco.json` v2:** `name` = palabra; `key?`, `authy?`, `author?` (handle tras claim). `tg_ref` (hash salado) NUNCA en `banco.json`. |
| D15 | **Namespace:** `users/` es top-level nuevo → alta en el filtro de publish-web y ruta network-first del SW. Palabras reservadas (`index.html`, nombres de app), tope por cuenta anti-squatting. |
| D16 | **Primera-gana id→perfil** (un telegram id → un perfil; un perfil puede tener muchos ids y muchos avatares — Q022 «Telegram allows many»). |
| D17 | **Sin re-derivación de claves:** vincular authy a browsy NO re-deriva la clave Ed25519 (Q93a keys deterministas); los claims añaden facetas, nunca re-key. |
| D18 | **Backfill `name` = opción (a)** (bot backfill), no preferencia de renderer. |
| D19 | **i18n + a11y** del modal «Claim it» y de la página de autor (en/es; el «rojo» nunca es color-only: icono + aria). |
| D20 | **Versionado paralelo (v1 ∥ v2):** risa v1 queda **estable** (base de recuperación; polish posterior a v2). **flove v2** integra **lovy completo + risa parcial** (authy, banco.js generalizado, renderer de autor, claims). **risa NO pasa a v2** hasta que lovy pruebe estabilidad. Detalle en §11. |

## 4. Modelo de datos

### Clip (`banco.json` v2, denormalizado para la web)
```json
{ "id": "q_…", "name": "sara-2", "key": "sara-2", "src": "https://…r2.dev/<app>/q_….mp3",
  "when": "2026-08-11", "tags": "…", "authy": 2, "author": null, "tg_public": false }
```
- `name` = palabra visible. `key` = clave de carpeta (inmutable). `authy` = 1|2|3. `author` = handle tras claim. `tg_public` = opt-in de mostrar Telegram.

### Filespace por autor (`central/users/<key>/`)
```
central/users/
  sara-2/
    clips.json      → [{app, id, src, when, tags, name}]  (app: risa | lovy | …)
    index.html      → página de autor generada (navegación persistente)
    authy.json      → aparece al reclamar: { handle, claimed_at, tg_public, avatars? }
    profile.json    → (v2) avatares/subperfiles, handles hermanos
```
Servido en `flove.org/users/<key>/` (mismo origen, SW network-first).

### LibSQL (v2, mapping 1:1 desde el filespace)
`profiles` · `avatars` · `authy_refs` · `clips` · `claims` — el backend importa el filespace al arrancar y sirve `/api/authy/…`, `/api/users/<handle>/…`.

## 5. Authy API (contrato del driver)

```js
authy.levels            // { anon:1, presence:2, verified:3, phone:4, bio:5 }
authy.verify(channel, proof) → facet      // prueba de posesión del canal
authy.claim(channel, key, handle, proof)  // liga un authy-ref a un perfil
authy.levelOf(identity) → 1..5
authy.resolveKey(name) → key              // slug, first-wins, sufijos
// driver telegram:
authyTelegram.dmLink(bot, payload)        // t.me/<bot>?start=…
authyTelegram.claimModal(clip, opts)      // modal «Claim it» (i18n, a11y)
```

## 6. Claim flow

**v1 (serverless):**
1. Web: clip no reclamada (L2) muestra la palabra en rojo + affordance.
2. Modal «Claim it»: *«Este clip está atribuido a “sara-2”. Si es tuyo, vincula tu Telegram para adjuntar este y futuros clips a tu perfil. Tu usuario de Telegram no se mostrará.»*
3. Deep link `t.me/<bot>?start=claim_<nonce>`; el bot busca en `state/pending-claims.json`, valida TTL, y confirma en DM: *«¿Reclamas el clip “sara-2”? ✅/✗»* verificando **id entrante == id que subió**.
4. ✅ → bot escribe `central/users/<key>/authy.json` (+ `claimed_by`), y el siguiente fetch de la web re-renderiza (claimed → enlace al perfil). Futuros clips del mismo id auto-registran al mismo key.

**v2 (Railway):** web `POST /api/authy/claim {clipId}` → FastAPI mintea el nonce en libSQL → deep link → bot confirma → FastAPI persiste `authy_refs`+`claims`, browsy firma el claim cuando el bridge está presente. Filespace = read model / fallback estático.

## 7. Fases

### P1 — Núcleo compartido + risa
- [ ] `central/shared/code/js/authy/authy.js` (núcleo + `resolveKey`)
- [ ] `central/shared/code/js/authy/authy-telegram.js` (deep-link, modal, driver)
- [ ] `central/shared/code/js/banco.js` generalizado (contrato feed/playlist, estados de autor: anónima / roja / claimed)
- [ ] Filtro publish-web + SW: `users/` (allowlist + network-first)
- [ ] Bot: captura `tg_username` + `name` (palabra) en subida; backfill de `name`; registro de `central/users/<key>/`; opt-in `tg_public`; anónimas → `anonimo/` R2
- [ ] `banco.json` v2 + fixtures de test (key-assignment, claim, estados)
- [ ] `state/pending-claims.json` + `/name` + confirm DM
- [ ] `risa/index.html`: palabra en vez de nombre; rojo+modal «Claim it»; fetch de `users/<key>/` para re-resolver

### P2 — Perfil de appy + páginas de autor
- [ ] `central/users/<key>/index.html` generado (página de autor, navegación cross-app)
- [ ] Feed de contribuciones en appy (lee su `clips.json` / API)
- [ ] Enlaces autor: clip → `users/<key>/` → perfil appy; perfil → clips

### P3 — lovy (ver sección 9)
- [ ] Bot **LovyBot** sobre el mismo esqueleto (ingesta: audio **y vídeo ≤ 1 min**)
- [ ] Bucket R2 **nuevo** (`lovy`) + `anonimo/` por app
- [ ] **Crear lovy en `central/apps/lovy/`** (`index.html` canónico): mantener diseño y funcionalidades actuales, sustituyendo todo lo posible por las abstracciones de `central/shared` (banco.js, authy-telegram.js, renderer de autor)
- [ ] Sección «feed de la comunidad» en lovy + integración de autor
- [ ] `banco.json` de lovy + `central/users/` compartido (la página de autor agrega risa + lovy)
- [ ] Grupo de moderación de lovy: lo crea Marc (misma mecánica ✅/🗑)

### P4 — Railway + libSQL + browsy
- [ ] FastAPI: `/api/authy/claim`, `/api/users/<handle>/…`; import del filespace
- [ ] Tablas libSQL (mapping §4); filespace como fallback
- [ ] Browsy firma claims; sin re-derivación de claves (D17)

## 8. Trabajo pendiente / aplazado
- **`flovebot` paraguas (L2)**: ofrece interactuar con **lovy y/o risa** desde un mismo bot (misma mecánica, más avanzado) — plan futuro, **spec en §10**.
- Feed cross-app vía endpoint central (v2) en vez de fetch por app.
- Email/phone drivers de authy.
- Avatares/subperfiles en la página de autor (v2).
- Editar la palabra por API (v2); v1 solo `/name` del bot.

## 9. Decisiones de lovy (respuestas de Marc a los issues)

| # | Decisión |
|---|---|
| L1 | **Contenido:** lovy = **audio y vídeo ≤ 1 min** + acciones (relaja la regla «solo voz/audio» del spec risa §6). |
| L2 | **Bot LovyBot** dedicado, sobre el mismo esqueleto. **flovebot** = paraguas futuro que ofrece interactuar con **lovy y/o risa** desde un mismo bot (misma mecánica, pero más avanzado). |
| L3 | **Bucket R2 nuevo** (`lovy`); `anonimo/` por app. |
| L4 | `central/users/<key>/` **compartido**: `banco.json` per-app, usuarios no (la página de autor agrega risa + lovy). |
| L5 | Integración web propia del layout de lovy; **compartido** = renderer de autor, modal «Claim it», fetch (banco.js / authy-telegram.js). |
| L6 | Strings parametrizables (títulos, tags, empty states, licencia) vía banco.js generalizado (D19). |
| L7 | **lovy se crea en `central/apps/lovy/`** manteniendo diseño y funcionalidades actuales, sustituyendo lo posible por las abstracciones de `central/shared`. |
| L8 | Grupo de moderación de lovy: lo crea **Marc**, misma mecánica ✅/🗑. |

## 10. Spec de flovebot (paraguas lovy + risa)

**Idea (L2):** un solo bot (`flovebot`) que ofrece interactuar con **lovy y/o risa** desde el mismo chat — misma mecánica que los bots dedicados (subida → moderación → `banco.json`), pero más avanzado: routing por app, búsqueda cross-app, perfil e historial del autor, y reproducción en-chat.

**Identidad compartida:** flovebot usa el **mismo** registro authy / `central/users` — claim una vez en risa y flovebot/LovyBot ya te ven claimed. Eso es lo que hace el paraguas «más avanzado» sin duplicar identidad.

### Mapa de comandos (v1 = serverless en cron, v2 = Railway)

| Comando | Qué hace | Fase | Enriquecería a los bots dedicados |
|---|---|---|---|
| `/start` | Menú: *«Elige app — 😹 risa · 💗 lovy»* (inline). Elección recordada por usuario; `/app` para cambiar | v1 | sí (LovyBot / RisaLiberada) |
| `/app <risa\|lovy>` | Cambio rápido de app activa | v1 | sí |
| `/me` | Tu identidad authy: key, palabra, nivel L1–L3, estado del claim, enlace a tu página de autor | v1 | sí |
| `/name <word>` | Set/editar palabra de display | v1 (D3/D11) | sí |
| `/claim` | Flow de claim desde el bot (nonce + id-confirm; sin web) | v1 | sí |
| `/link <handle>` | Vincular telegram a un perfil appy vía API | v2 (D9) | sí |
| `/history` | Tus subidas + estado: pending / approved / denied (lee `state/queue.json` + `banco.json` por tg id) | v1 | sí |
| `/stats` | Tus números: clips publicados | v1 conteos (las reales, en la web) | sí |
| `/profile` | Tus enlaces: `flove.org/users/<key>/` + perfil appy | v1 | sí |
| `/search <q>` | **Búsqueda unificada** (uno para encontrarlo todo): palabra/tag/nombre o **similitud a un clip** (`/search <id>`); reemplaza `/browse` y `/more` (fetch de ambos `banco.json`, filtro local — mismo fetch que ya hace la web) | v1 texto/tag; v2 similitud | sí |
| `/latest` | Los N más nuevos de ambas apps | v1 | sí |
| `/random` | Clip sorpresa | v1 | sí |
| `/trending` | Top tags/chips por app | v1 (conteos de tags de banco.json) | sí |
| `/play <id\|word>` | Reproduce el clip en-chat (bot manda la URL R2 pública como `sendAudio`/`sendVideo`; sin almacenar `file_id`) | v1 | sí |
| `/today` | «risa del día» / «acción del día» rotatoria | v1 | sí |
| `/status` | Salud: offset, pendientes, último run | v1 | sí |
| `/queue [app]` | Cola de pendientes (admin); en flovebot, `[app]` elige risa\|lovy (merge tabla base + avanzada) | v1 | sí |

### Capa v2 «avanzada»

**2 · Identidad & perfil (authy)**
- **`/fave <id>`**: favoritos personales → «mis favoritos» en `users/<key>/` (v1).
- **`/alias <palabra>`**: palabra adicional = **subperfil del perfil principal, misma key** (muchos handles → un perfil, Q022/D16) (v2).
- **`/swap <id> <avatar>`**: mover un clip entre tus avatares/subperfiles (v2).
- **`/echo <id>`**: tarjeta bonita de metadatos del clip (palabra, tags, app, fecha, nivel) — base compartida de `/fave`, `/play`, `/report` (v1).
- **`/totem`**: marca animal/emoji determinista derivada de tu `key`, mostrada en tu página (v1).

**4 · Privacidad, recuperación & portabilidad (authy)**
- **`/vault`**: tu «recovery sheet» — frase/palabra + QR para restaurar tu identidad authy entre canales (v2).
- **`/passport`**: resumen portátil de authy (key, nivel, enlaces públicos) para ser reconocido fuera (v2).

**5 · Confianza & herencia**
- **`/trust <word>`**: nota de confianza en el perfil de otro autor; aparece en su página (v1).
- **`/heritage`** *(absorbe `/invite`)*: para **transferir/recuperar la cuenta**. El bot formatea la **cadena completa** y dibuja el **grafo de herencia fuera de Telegram** (web: página de la cadena / perfiles); la red de avales sirve de prueba para **transferir/recuperar** la cuenta authy (v2).

**7 · Lovy — Cadenas (solo lovy)**
- **`/chain`**: cadenas colaborativas — un prompt de palabra, cada contribución continúa la anterior (v1).
- **`/seeds`**: lista de cadenas/prompts abiertos a los que puedes unirte (v1).
- **`/cameo <palabra>`**: invitar a un autor concreto a hacer un dúo/continuar tu clip o cadena (v2).
- **`/lore <cadena>`**: storyline de la cadena para la web (narrativa + grafo de herencia juntos) (v2).

**8 · Búsqueda & exploración** (uno para encontrarlo todo)
- **`/search` unificado**: palabra, tag, nombre o **similitud a un clip** (`/search <q>`, `/search <id>`). Reemplaza `/browse` y `/more` (v1 texto/tag; v2 similitud).
- **`/latest`** · **`/random`** · **`/trending`** · **`/now`** · **`/since <fecha>`** ("qué hay de nuevo desde…", por app) (v1).

**9 · Reproducción & momentos**
- **`/play <id|word>`** · **`/radio`** (sesión seguida: `/next`, `/stop`; shuffle o por tag) · **`/today`** (v1).
- **`/reprise <id>`** (v2, creación): secuela/remix — clip nuevo que referencia a uno viejo; en la web se renderizan como hilo.
- **Reacciones en el canal**: siempre activas (Marc las habilita; no es opt-in); con webhook (v2) el bot las lee como señal para la «selección» HTML.

**10 · Moderación & administración**
- **`/status`** · **`/queue [app]`** · **`/mute` + strikes** (v1) · **`/pin <id>`** · **`/mirror`** (v1, admin).
- **`/report`** · **`/rules`** (v1).
- **Control del autor sobre su cola**: **`/draft`** (borrador antes de entrar a la cola) · **`/retake`** (retirar/re-grabar mientras está pendiente) (v1).
- **Rate-limit + anti-spam server-side** (refuerza D8, v2).

**11 · Sistema (v2)**
- **Webhooks** (respuesta instantánea, no poll de 10 min).
- **Notificaciones al autor**: DM al aprobar/denegar.
- **Telegram Mini App**: la app flove.org embebida en Telegram (subir/reproducir sin salir del chat).
- **Inline mode** `@flovebot <palabra>`: buscar y compartir clips desde cualquier chat.
- **`/edit <id>` y `/del <id>` del autor**: cambiar tags/palabra/anónima o retirar el propio clip (confirmación en DM).
- **Programación de publicación** (colas en libSQL).

**Descartado** (mantenido como referencia, comentado):
<!--
- ~~Relay entre canales~~ — Marc: no.
- ~~/dispute~~ (conflictos de palabra) — Marc: no.
- ~~/sessions~~ (canales vinculados) — Marc: no.
- ~~/digest~~ (lote curado periódico) — Marc: no.
- ~~Auto-deny presets~~ (botones con razón predefinida) — Marc: no.
- ~~/word~~ (mini-perfil público de otro autor) — Marc: no.
- ~~/muse~~ (prompt aleatorio «inspírame») — Marc: no.
- ~~/antenna~~ (señal «estoy hoy aquí») — Marc: no.
- ~~/gate~~ (opt-out de aparecer en /now · /latest · /random) — Marc: no.
- ~~/incognito~~ (anónima por defecto) — Marc: no.
- ~~/north~~ (prompt-tema del periodo) — Marc: no.
- ~~/beacon~~ (vía de recuperación secundaria) — Marc: no.
- ~~/witness~~ (testigos para recuperación social) — Marc: no.
- ~~/seal~~ (firma personal en clips) — Marc: no.
-->
En HTML, no en el bot: transcripción/flagging IA para moderadores, dailies + rachas, i18n del copy, y top/trending con stats reales de Central.

### Notas de diseño

- **Privacidad firme (§2):** `/search` y `/me` renderizan solo palabra/`key`, nunca el @username — misma regla que la web.
- **Playback barato:** las URLs de R2 son públicas → `sendAudio(url)` funciona en v1 serverless, sin bookkeeping de `file_id`.
- **Bots dedicados reutilizan todo:** LovyBot y RisaLiberada ganan `/me`, `/name`, `/claim`, `/history`, `/search`, `/latest`, `/random`, `/play`, `/profile`, `/status` sin coste extra (misma esqueleto).

## 11. Versionado y escalabilidad (D20)

**Estrategia (Marc, 2026-08-11):**

| Pieza | Estado | Plan |
|---|---|---|
| risa v1 | **estable v1** (marcado) | base de recuperación; **polish posterior a v2** |
| lovy | → flove v2 | integración **completa** |
| risa v1 | → flove v2 **parcial** | risa **NO** pasa a v2 hasta que lovy pruebe estabilidad |
| LovyBot · flovebot | v2 directo | esqueleto compartido abstracto (`bot-core`) |

- **flove v2** integra **lovy completo + risa parcial** (authy, banco.js generalizado, renderer de autor, claims). risa conserva su v1 en paralelo, marcada **estable** y congelada salvo polish.
- **v2 abstrae aún más los códigos v1**: `bot-core` (ingesta, cola, moderación ✅/🗑, claims, `/name`) compartido por lovy y risa; lovy actúa como banco de pruebas antes de migrar risa.
- **Convivencia sin ramas**: `banco.json` y filespace por app (D15, L4) permiten v1 y v2 viviendo a la vez.
- **Escalabilidad**: risa v1 sigue en serverless (Actions cron); flove v2 (Railway + libSQL) sirve lovy y flovebot; risa migra al v2 cuando lovy sea estable.

## 12. Self-review

- Cobertura spec: el circuito original (subir→moderar→publicar) se mantiene intacto; el plan añade identidad (authy) + persistencia por autor (users/) + privacidad (palabra, nunca nickname).
- Alineación con planes existentes: browsy (keys/trust, D17), central-backend (P01, D9/D10, §4), proposals P13 (claim via authy), Q022 (muchos ids → un perfil, D16).
- No rompe el pipeline `updaty-web` si se hace D15 (allowlist + SW).
- Pruebas: fixtures compartidos (key-assignment, claim, estados de autor) con `node --test`.
