Integrar en tu sitio
Este manual está escrito para que otra persona —o un asistente de IA— pueda integrar reply2social en un sitio cualquiera sin conocer este código.
Cada paso trae cómo verificarlo. Eso no es celo: en el despliegue real de
este mismo sistema, el proceso terminó «✓ OK» tres veces seguidas con algo
roto — una imagen que no se construyó, un npm ci que fallaba, una pantalla
que nunca pedía sus datos. El sitio respondía 200 en los tres casos.
Lo que vas a integrar
Son dos piezas separables y conviene tenerlo claro desde el principio:
| Pieza | Qué es | ¿Obligatoria? |
|---|---|---|
| El servicio | Un binario Rust + Postgres. Trae material, lo archiva, lo publica. | Sí |
| El panel | Dos paquetes npm con las ocho pantallas. | No: podés hablarle al servicio por HTTP desde tu propio front |
Este manual cubre las dos. Si sólo querés el servicio, terminá en el paso 5.
Paso 0 — Requisitos y el código
Lo primero es tener el código. El repo es público:
git clone https://gitlab.com/pineiden/reply2fb.git reply2socialEl nombre del destino importa: el compose.yml del paso 3 apunta a
./reply2social/backend. Los detalles —bajarlo sin git, fijar una versión, qué
hay en cada directorio y qué te obliga la licencia AGPL— están en
Obtener el código.
En la máquina donde va a correr el servicio:
- Postgres 14+ (se probó en 16).
- Podman o Docker, con compose.
- Node 22+ sólo si vas a construir el panel.
- Salida a internet hacia las redes que vayas a usar.
Verificar
podman --version || docker --versionpsql --versionnode --version
# Y que el clon esté completo:ls reply2social/backend/Cargo.toml reply2social/ui/core/package.jsonLos tres comandos de versión tienen que responder, y los dos archivos existir.
Si psql no está, podés usar el Postgres del compose y saltear la instalación
local.
Paso 1 — El contrato: qué tiene que ofrecer tu sitio
Este es el paso que decide si la integración es posible. El servicio no tiene usuarios propios: le pregunta a tu sitio quién es cada persona.
1.1 Obligatorio siempre: validar identidad
Tu sitio expone un endpoint que el servicio llama en cada petición. La
ruta la elegís vos: el servicio llama a lo que digas en
IDENTIDAD_URL_VALIDAR. Puede ser /api/reply2fb/validar, /auth/verify o
/rpc/quien-es; acá se usa la primera como ejemplo.
Pedido que le llega a tu sitio:
POST <la ruta que vos elijas>X-API-Key: <el secreto compartido>Content-Type: application/json
{ "token": "<lo que el navegador mandó en Authorization: Bearer …>" }Respuesta esperada, 200:
{ "id": "uuid-o-lo-que-uses", "roles": ["admin"] }Respuesta cuando el token no vale, 401: cualquier cuerpo.
Tres reglas que tu implementación tiene que cumplir, y las tres vienen de un incidente real:
- Compará el
X-API-Keyen tiempo constante. Una comparación normal filtra el secreto carácter por carácter con suficientes intentos. - Releé el usuario de TU base en cada llamada. No confíes en lo que dice el token. Es lo que hace que suspender a alguien tenga efecto inmediato en vez de esperar a que expire.
- Si no podés decidir, devolvé 5xx, no 401. Un 401 afirma «no sos nadie»; si tu base está caída lo cierto es «no pude preguntar». El servicio traduce eso a un 503 y el panel dice «no se pudo consultar» en vez de mandar a entrar de nuevo, que no arreglaría nada.
Ejemplo mínimo, en tres lenguajes:
// Express / Nodeapp.post('/api/reply2fb/validar', async (req, res) => { const enviada = Buffer.from(req.get('X-API-Key') ?? ''); const nuestra = Buffer.from(process.env.REPLY2FB_IDENTIDAD_SECRETO); if (enviada.length !== nuestra.length || !crypto.timingSafeEqual(enviada, nuestra)) { return res.sendStatus(401); } try { // Se relee de la base: el token dice quién FUE, la base dice quién ES. const u = await usuarioDesdeToken(req.body.token); if (!u || u.estado !== 'activo') return res.sendStatus(401); res.json({ id: u.id, roles: [u.rol] }); } catch (e) { // No pude preguntar ≠ no sos nadie. res.sendStatus(503); }});# FastAPI@app.post("/api/reply2fb/validar")async def validar(cuerpo: dict, x_api_key: str = Header(None)): if not hmac.compare_digest(x_api_key or "", SECRETO): raise HTTPException(401) try: u = await usuario_desde_token(cuerpo["token"]) except Exception: raise HTTPException(503) # no pude preguntar if not u or u.estado != "activo": raise HTTPException(401) # no sos nadie return {"id": str(u.id), "roles": [u.rol]}// LaravelRoute::post('/api/reply2fb/validar', function (Request $r) { if (!hash_equals(config('r2f.secreto'), $r->header('X-API-Key', ''))) { abort(401); } try { $u = usuarioDesdeToken($r->input('token')); } catch (\Throwable $e) { abort(503); } if (!$u || $u->estado !== 'activo') abort(401); return ['id' => (string) $u->id, 'roles' => [$u->rol]];});1.2 Sólo si vas a publicar EN tu propio sitio
La plataforma web —«el sitio propio» como origen y destino— es la única parte
de reply2social que espera rutas con forma fija. Todo lo demás (Instagram,
Mastodon, Telegram, RSS) habla con APIs ajenas y no te pide nada.
Camino A — implementar las rutas. Cinco endpoints con estas formas exactas:
GET {api_url}/api/perfil/me → 200 con el usuario del tokenGET {api_url}/api/admin/usuarios → 200 [{id, username, estado, rol}]
POST {api_url}/api/postsX-API-Key: <IDENTIDAD_SECRETO>{ "cuerpo": "…", "fuente_url": "https://…", "fuente_nombre": "Instagram" } → 2xx { "id": "…" }
POST {api_url}/api/posts/{id}/mediaX-API-Key: <IDENTIDAD_SECRETO>multipart/form-data, campo «file» → 2xx
GET {api_url}/api/posts → 200 [ … ] (para leer DE tu sitio)Son cinco rutas finas; en la mayoría de los marcos es un controlador que
traduce a tu modelo. fuente_url y fuente_nombre son la atribución: si
tu sitio los muestra en un campo propio, no los pegues además al cuerpo.
Camino B — un adaptador delante. Un proxy de veinte líneas que reciba esas rutas y llame a las tuyas. Sirve cuando no querés tocar tu API (WordPress, Ghost, un CMS que no controlás).
Camino C — no usar web. Es lo más común: si sólo querés
Instagram → Mastodon, o RSS → Telegram, saltá esta sección entera. No
necesitás implementar nada de esto y todo el resto del manual aplica igual.
Verificar el paso 1
Todavía no hay servicio, así que se prueba a mano contra tu sitio:
# Con el secreto correcto y un token válido → 200 con id y rolescurl -s -X POST https://TU-SITIO/api/reply2fb/validar \ -H "X-API-Key: $SECRETO" -H 'Content-Type: application/json' \ -d '{"token":"UN_TOKEN_VALIDO"}' -w '\n%{http_code}\n'
# Con el secreto MAL → 401curl -s -o /dev/null -X POST https://TU-SITIO/api/reply2fb/validar \ -H "X-API-Key: mal" -H 'Content-Type: application/json' \ -d '{"token":"x"}' -w '%{http_code}\n'La primera tiene que devolver 200 y un JSON con id y roles. La segunda,
401. Si la segunda devuelve 200, pará acá: cualquiera podría hacerse
pasar por administrador.
Paso 2 — Las variables de entorno
Todas van en un .env junto al compose. Las marcadas obligatorias hacen
que el servicio no arranque si faltan, a propósito: un servicio a medio
configurar que arranca es peor que uno que no.
| Variable | Obligatoria | Qué es |
|---|---|---|
DATABASE_URL | Sí | postgres://usuario:clave@host:5432/reply2fb |
REPLY2FB_CIFRADO_KEY | Sí | 32 bytes en base64. Cifra los tokens de las redes |
IDENTIDAD_URL_VALIDAR | Sí | La URL del paso 1.1, como la ve el servicio |
IDENTIDAD_SECRETO | Sí | El secreto compartido con tu sitio |
IDENTIDAD_MAPA_ROLES | No | admin=administrar,editor=operar,lector=ver |
REPLY2FB_EMISION_ACTIVA | No | 1 enciende la publicación. Sin esto sólo archiva |
REPLY2FB_UID_SERVICIO | Sólo web | A nombre de qué usuario publica en tu sitio |
PORT | No | Por defecto 8080 |
Generar el secreto y la clave:
# La clave de cifrado: EXACTAMENTE 32 bytes.openssl rand -base64 32
# El secreto compartido con tu sitio.openssl rand -base64 36 | tr -d '/+=' | head -c 48REPLY2FB_CIFRADO_KEY no se rota a la ligera. Con ella se cifraron los
tokens de todas las redes: si la cambiás, ninguno se puede abrir y hay que
volver a cargarlos uno por uno. Guardala donde guardes las claves de
producción, y hacé un respaldo (cauce exportar --respaldo) antes de tocarla.
Verificar el paso 2
# La clave tiene que decodificar a 32 bytes exactosecho -n "$REPLY2FB_CIFRADO_KEY" | base64 -d | wc -c # → 32Cualquier otro número y el servicio va a fallar al arrancar diciéndolo.
Paso 3 — Levantar el servicio
services: reply2fb-db: image: docker.io/postgres:16-alpine environment: POSTGRES_DB: reply2fb POSTGRES_USER: reply2fb POSTGRES_PASSWORD: ${DB_PASSWORD:?falta DB_PASSWORD} volumes: ['reply2fb-datos:/var/lib/postgresql/data'] healthcheck: test: ['CMD-SHELL', 'pg_isready -U reply2fb'] interval: 10s
reply2fb: build: # El directorio del clon del paso 0. Si clonaste sin nombrar el destino, # acá va `./reply2fb/backend`. context: ./reply2social/backend environment: DATABASE_URL: postgres://reply2fb:${DB_PASSWORD}@reply2fb-db:5432/reply2fb REPLY2FB_CIFRADO_KEY: ${REPLY2FB_CIFRADO_KEY:?falta} IDENTIDAD_URL_VALIDAR: ${IDENTIDAD_URL_VALIDAR:?falta} IDENTIDAD_SECRETO: ${IDENTIDAD_SECRETO:?falta} IDENTIDAD_MAPA_ROLES: admin=administrar,editor=operar,lector=ver REPLY2FB_EMISION_ACTIVA: '0' depends_on: reply2fb-db: { condition: service_healthy } ports: ['127.0.0.1:8080:8080']
volumes: reply2fb-datos:podman-compose up -d --buildLas migraciones corren solas al arrancar.
Empezá con REPLY2FB_EMISION_ACTIVA=0. Con eso el sistema archiva y no
publica. Vas a poder configurar todo, mirar qué entra, y recién cuando estés
seguro encender la publicación. Al revés —encender primero y configurar
después— el primer error de configuración sale publicado en una cuenta ajena.
Verificar el paso 3
# 1. El contenedor está arriba y NO reiniciándosepodman ps --format '{{.Names}} {{.Status}}' | grep reply2fb
# 2. Responde, y rechaza a quien no se identificacurl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/api/emisionesEl segundo tiene que dar 401.
- Si da 503: el servicio no puede hablar con tu sitio. Revisá
IDENTIDAD_URL_VALIDAR— desde dentro del contenedor,localhostes el contenedor, no tu máquina. - Si da 000 o no conecta: el servicio no levantó.
podman logs reply2fb. - Si da 200: algo está muy mal — no debería dejar entrar sin token.
Paso 4 — El proxy
El navegador tiene que poder llegar al servicio con la sesión de tu sitio.
El prefijo lo elegís vos —acá se usa /reply2fb/— y lo único que importa
es que el transporte del panel use el mismo. El servicio no sabe ni le importa
bajo qué ruta lo publicaste.
location /reply2fb/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header Authorization $http_authorization;
# Los videos tardan: sin esto un backfill grande se corta a los 60 s. proxy_read_timeout 300s; client_max_body_size 512M;}El token de la cuenta de un tercero nunca pasa por tu backend. El panel
pega directo a /reply2fb/* y nginx lo reenvía al servicio: no pasa por tu
aplicación, ni por tu base, ni por tus logs. Si lo hacés pasar por tu backend
«para simplificar», te llevás la responsabilidad de custodiar credenciales
ajenas.
Verificar el paso 4
curl -s -o /dev/null -w '%{http_code}\n' https://TU-SITIO/reply2fb/api/emisionescurl -s -o /dev/null -w '%{http_code}\n' \ -H 'Authorization: Bearer token-falso' https://TU-SITIO/reply2fb/api/emisionesLos dos tienen que dar 401. Un 502 significa que nginx no llega al servicio; un 404, que el prefijo no coincide.
Paso 5 — La primera cuenta, sin panel todavía
Antes de montar la interfaz, comprobá que el servicio hace su trabajo. Todo esto se puede por CLI:
Reemplazá reply2fb por el nombre que le hayas puesto al contenedor.
# Ver qué plataformas hay y cuáles tienen clientepodman exec reply2fb reply2fb cuentas list
# Dar de alta una fuente RSS: es la más fácil de probar, no lleva tokenpodman exec reply2fb reply2fb cuentas add \ --plataforma rss --rol lectura \ --handle prensa --id-remoto https://ejemplo.org/feed.xmlEl alta verifica antes de escribir: si el feed no responde o no es un feed, no crea la cuenta y dice por qué. Eso es a propósito — una fuente que no se puede leer sondearía cada seis horas sin traer nada.
Verificar el paso 5
podman exec reply2fb reply2fb poller --una-vezpodman exec reply2fb reply2fb cuentas listSi entraron ítems, el servicio funciona. Lo que falta es la interfaz.
Paso 6 — Montar el panel
6.1 Traer los paquetes
Viven en el mismo repo que clonaste en el paso 0
—cómo obtenerlo—, en ui/. Se empaquetan con:
# Dentro del clonnode ui/empaquetar.mjs --destino /ruta/a/tu-sitio/vendorEso corre los tests de los dos paquetes, los compila y deja dos .tgz con el
commit en el nombre, más un PROCEDENCIA.json que dice de qué versión salieron.
En tu sitio:
{ "dependencies": { "@reply2social/core": "file:./vendor/reply2social-core-0.1.0-abc12345.tgz", "@reply2social/svelte": "file:./vendor/reply2social-svelte-0.1.0-abc12345.tgz" }}El nombre lleva el sha a propósito. Con un nombre fijo, npm sirve el
tarball que tiene en caché aunque el contenido haya cambiado: tu sitio compila
en verde con el código anterior y nadie se entera. Si actualizás los paquetes,
el nombre cambia y el package.json también.
6.2 El transporte
El núcleo no sabe cómo se autentica tu sitio: se lo pasás vos. Un Transporte
es cualquier función con la firma de fetch:
import { crearCliente } from '@reply2social/core';
// La ruta va SIN el prefijo: el transporte lo agrega.const transporte = (ruta: string, opts: RequestInit = {}) => fetch(`/reply2fb${ruta}`, { ...opts, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tuToken}`, ...(opts.headers ?? {}), }, });
const cliente = crearCliente(transporte);Con cookies de sesión en vez de token: credentials: 'include' y sin
Authorization.
6.3 Montar las pantallas
<script lang="ts"> import { crearCliente, crearVistaPorAprobar } from '@reply2social/core'; import { PorAprobar, Confirmar, crearConfirmador } from '@reply2social/svelte'; import '@reply2social/svelte/estilos.css';
const confirmador = crearConfirmador(); const cliente = crearCliente(transporte);
// Una sola vez: recrearla en cada render pierde la selección a mitad de un lote. const vista = crearVistaPorAprobar(cliente, confirmador.confirmar);</script>
<!-- El diálogo se monta UNA vez, arriba de todo --><Confirmar {confirmador} /><PorAprobar {vista} />Las ocho pantallas siguen el mismo patrón:
| Componente | Su vista |
|---|---|
PorAprobar | crearVistaPorAprobar(cliente, confirmar) |
Historial | crearVistaHistorial(cliente, confirmar) |
Archivo | crearVistaArchivo(cliente, confirmar) |
Fuentes | crearVistaFuentes(cliente, confirmar) |
Alta | crearVistaAlta(cliente, confirmar) |
Cuentas | crearVistaCuentas(cliente, confirmar) |
Programacion | crearVistaProgramacion(cliente, confirmar) |
Rescate | crearVistaRescate(cliente) |
Avisos | crearVistaAvisos(cliente) |
Verificar el paso 6
npm run buildgrep -rl "sin permiso registrado" dist/ | head -1Si el segundo no encuentra nada, tu bundle NO tiene el panel aunque el build
haya dicho «Complete»: revisá que el package.json apunte al tarball correcto
y que npm install lo haya instalado.
Y abrí la pantalla. Tiene que mostrar datos o un error explicado. Si dice «Cargando…» para siempre, el componente no está pidiendo nada: revisá que estés pasando la vista, no el cliente.
Paso 7 — Los colores
El paquete trae su propia hoja (@reply2social/svelte/estilos.css) con un
esquema oscuro y una variante clara automática. Si tu sitio ya tiene una
paleta, no importes la hoja: definí los mismos nombres desde la tuya.
/* En el contenedor donde montás el panel */.mi-panel { --r2s-fondo: var(--mi-superficie); --r2s-tinta: var(--mi-texto); --r2s-borde: var(--mi-borde-de-control); --r2s-ok: var(--mi-verde); --r2s-fallo-texto: var(--mi-rojo-legible); --r2s-fallo-borde: var(--mi-rojo); --r2s-fallo-fondo: var(--mi-rojo-fondo); --r2s-atribucion: var(--mi-ambar); --r2s-duda: var(--mi-gris-con-matiz); --r2s-peligro: var(--mi-rojo);}Dos reglas al re-tematizar, y no son de estilo:
--r2s-dudano puede ser el gris de «deshabilitado». Marca lo que el panel NO conoce —un régimen o un estado nuevo del servicio— y en gris apagado se lee como «ignorable», que es lo contrario. Dale un matiz propio.--r2s-atribucionno puede ser ni rojo ni gris. Marca lo que se publicó con atribución y sin consentimiento registrado: no es un error —pintarlo de error enseña a ignorar los errores— pero tampoco es neutro, porque quien aprueba tiene que verlo. Ámbar.
Lo que no podés romper aunque quieras: los glifos y el borde punteado de «desconocido» viajan en el marcado del paquete. Esa es la mitad del significado que sobrevive a cualquier tema, y la razón de que re-tematizar sea seguro.
Verificar el paso 7
Abrí la pantalla de Flujos con un destino sin permiso y comprobá cuatro
estados a la vez: uno en verde (probado), uno en rojo (sin permiso), uno en
ámbar (pausa) y uno en gris con ? (sin probar). Si dos se ven iguales, el
tema fundió dos familias y hay que separarlas.
Medí el contraste del texto sobre su fondo: 4.5:1 mínimo. En el despliegue real, el verde por defecto sobre un panel oscuro daba 2:1 —ilegible— y nadie lo vio hasta que estuvo publicado.
Paso 8 — Encender la publicación
Recién ahora, y en este orden:
- Registrá el permiso de la fuente hacia cada destino, en el panel, con el nombre de quien lo afirma. No hay atajo por CLI, y es a propósito.
- Probá el flujo sin publicar: Flujos → «Probar», después «Ensayo en seco».
- Publicá UN ítem a mano y miralo en la red de destino: «Publicar uno».
- Recién ahí,
REPLY2FB_EMISION_ACTIVA=1.
Un flujo sin consentimiento registrado no emite, y el panel lo dibuja roto. Eso no es una traba burocrática: este sistema republica el trabajo de otras personas, y el permiso es una afirmación personal y fechada de quien lo otorgó. No hay forma de otorgarlo en lote, ni siquiera importando un archivo.
Verificación final
Corré esto entero. Los seis tienen que dar lo esperado; si uno falla, la integración no está completa aunque todo lo demás ande.
SITIO=https://tu-sitio
echo "1. sin token → $(curl -so /dev/null -w %{http_code} $SITIO/reply2fb/api/emisiones) esperado 401"echo "2. token falso → $(curl -so /dev/null -w %{http_code} -H 'Authorization: Bearer x' $SITIO/reply2fb/api/emisiones) esperado 401"echo "3. secreto mal → $(curl -so /dev/null -w %{http_code} -X POST $SITIO/api/reply2fb/validar -H 'X-API-Key: mal' -H 'Content-Type: application/json' -d '{}') esperado 401"echo "4. panel en bundle → $(grep -rl 'sin permiso registrado' dist/ | wc -l) esperado 1 o más"echo "5. hay cuentas → $(podman exec reply2fb reply2fb cuentas list | grep -c .) esperado 1 o más"echo "6. sin credenciales en la respuesta:"curl -s -H "Authorization: Bearer $TOKEN_ADMIN" $SITIO/reply2fb/api/cuentas \ | grep -c 'credenciales_cifradas'El sexto tiene que dar 0: ningún endpoint devuelve la columna cifrada.
Errores frecuentes, y qué significan de verdad
Todos estos pasaron en el despliegue real de este sistema.
| Lo que ves | Lo que suele ser |
|---|---|
| «Cargando…» para siempre | Al componente le pasaste el cliente en vez de la vista, o la vista nunca se creó. La pantalla se monta y no pide nada |
| El panel se ve «pelado», sin colores | No importaste estilos.css ni definiste los --r2s-*. Los colores caen a reservas pensadas para otro esquema |
| Todo responde 503 | El servicio no llega a tu endpoint de validación. Desde dentro del contenedor, localhost es el contenedor |
| Todo responde 401 con un token bueno | Tu endpoint devuelve 401 cuando falla la consulta a la base. Tiene que devolver 5xx |
| El deploy dice OK y ves el código viejo | La imagen no se reconstruyó, o npm sirvió un tarball cacheado. Comparás la fecha de la imagen, no el health check |
| Un flujo no emite y no dice por qué | Falta el consentimiento. El panel lo dibuja roto; en la CLI, rutas list lo muestra |
| Sale publicado sin que nadie apruebe | Ese flujo tiene auto_publica. Se prende a mano y se confirma |
Por qué este manual insiste tanto con verificar.
Integrar esto son ocho pasos y cada uno tiene una forma de fallar en silencio: un build que no corre, un tarball viejo, una pantalla que no pide datos, un color de 2:1. Ninguna de esas se ve en un health check, y todas se ven en treinta segundos si sabés qué mirar.
El sitio respondiendo 200 no prueba nada sobre reply2social.