Saltearse al contenido

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:

monta

HTTP

¿quién es?

tu sitio

el anfitrión

@reply2social/svelte

las 8 pantallas

@reply2social/core

cliente + reglas

el servicio

Rust + Postgres

Instagram · Mastodon

Telegram · RSS

PiezaQué es¿Obligatoria?
El servicioUn binario Rust + Postgres. Trae material, lo archiva, lo publica.
El panelDos 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:

Ventana de terminal
git clone https://gitlab.com/pineiden/reply2fb.git reply2social

El 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
Ventana de terminal
podman --version || docker --version
psql --version
node --version
# Y que el clon esté completo:
ls reply2social/backend/Cargo.toml reply2social/ui/core/package.json

Los 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:

  1. Compará el X-API-Key en tiempo constante. Una comparación normal filtra el secreto carácter por carácter con suficientes intentos.
  2. 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.
  3. 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 / Node
app.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]}
// Laravel
Route::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 token
GET {api_url}/api/admin/usuarios → 200 [{id, username, estado, rol}]
POST {api_url}/api/posts
X-API-Key: <IDENTIDAD_SECRETO>
{ "cuerpo": "", "fuente_url": "https://…", "fuente_nombre": "Instagram" }
2xx { "id": "" }
POST {api_url}/api/posts/{id}/media
X-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:

Ventana de terminal
# Con el secreto correcto y un token válido → 200 con id y roles
curl -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 → 401
curl -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.

VariableObligatoriaQué es
DATABASE_URLpostgres://usuario:clave@host:5432/reply2fb
REPLY2FB_CIFRADO_KEY32 bytes en base64. Cifra los tokens de las redes
IDENTIDAD_URL_VALIDARLa URL del paso 1.1, como la ve el servicio
IDENTIDAD_SECRETOEl secreto compartido con tu sitio
IDENTIDAD_MAPA_ROLESNoadmin=administrar,editor=operar,lector=ver
REPLY2FB_EMISION_ACTIVANo1 enciende la publicación. Sin esto sólo archiva
REPLY2FB_UID_SERVICIOSólo webA nombre de qué usuario publica en tu sitio
PORTNoPor defecto 8080

Generar el secreto y la clave:

Ventana de terminal
# 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 48

REPLY2FB_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
Ventana de terminal
# La clave tiene que decodificar a 32 bytes exactos
echo -n "$REPLY2FB_CIFRADO_KEY" | base64 -d | wc -c # → 32

Cualquier otro número y el servicio va a fallar al arrancar diciéndolo.


Paso 3 — Levantar el servicio

compose.yml
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:
Ventana de terminal
podman-compose up -d --build

Las 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
Ventana de terminal
# 1. El contenedor está arriba y NO reiniciándose
podman ps --format '{{.Names}} {{.Status}}' | grep reply2fb
# 2. Responde, y rechaza a quien no se identifica
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/api/emisiones

El segundo tiene que dar 401.

  • Si da 503: el servicio no puede hablar con tu sitio. Revisá IDENTIDAD_URL_VALIDAR — desde dentro del contenedor, localhost es 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
Ventana de terminal
curl -s -o /dev/null -w '%{http_code}\n' https://TU-SITIO/reply2fb/api/emisiones
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'Authorization: Bearer token-falso' https://TU-SITIO/reply2fb/api/emisiones

Los 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.

Ventana de terminal
# Ver qué plataformas hay y cuáles tienen cliente
podman exec reply2fb reply2fb cuentas list
# Dar de alta una fuente RSS: es la más fácil de probar, no lleva token
podman exec reply2fb reply2fb cuentas add \
--plataforma rss --rol lectura \
--handle prensa --id-remoto https://ejemplo.org/feed.xml

El 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
Ventana de terminal
podman exec reply2fb reply2fb poller --una-vez
podman exec reply2fb reply2fb cuentas list

Si 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:

Ventana de terminal
# Dentro del clon
node ui/empaquetar.mjs --destino /ruta/a/tu-sitio/vendor

Eso 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:

ComponenteSu vista
PorAprobarcrearVistaPorAprobar(cliente, confirmar)
HistorialcrearVistaHistorial(cliente, confirmar)
ArchivocrearVistaArchivo(cliente, confirmar)
FuentescrearVistaFuentes(cliente, confirmar)
AltacrearVistaAlta(cliente, confirmar)
CuentascrearVistaCuentas(cliente, confirmar)
ProgramacioncrearVistaProgramacion(cliente, confirmar)
RescatecrearVistaRescate(cliente)
AvisoscrearVistaAvisos(cliente)
Verificar el paso 6
Ventana de terminal
npm run build
grep -rl "sin permiso registrado" dist/ | head -1

Si 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:

  1. --r2s-duda no 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.
  2. --r2s-atribucion no 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:

  1. 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.
  2. Probá el flujo sin publicar: Flujos → «Probar», después «Ensayo en seco».
  3. Publicá UN ítem a mano y miralo en la red de destino: «Publicar uno».
  4. 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.

Ventana de terminal
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 vesLo que suele ser
«Cargando…» para siempreAl 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 coloresNo importaste estilos.css ni definiste los --r2s-*. Los colores caen a reservas pensadas para otro esquema
Todo responde 503El servicio no llega a tu endpoint de validación. Desde dentro del contenedor, localhost es el contenedor
Todo responde 401 con un token buenoTu endpoint devuelve 401 cuando falla la consulta a la base. Tiene que devolver 5xx
El deploy dice OK y ves el código viejoLa 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 apruebeEse 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.