Integrando el blog con el fediverso y Bluesky

Bueno, después de darme cuenta en plena mañana de que mi última automatización estaba spameando en mi cuenta de Mastodon... Ahora creo que ya está solucionado.

La historia es siempre la misma: despliegas un cambio con prisas, te vas a dormir tranquilo, y a la mañana siguiente te despiertas con 47 notificaciones en Mastodon de gente preguntándote si te han hackeado la cuenta o si te has vuelto loco. No, no me han hackeado. Solo soy un ingeniero que no puso un if de más.

¿Automatizando en el fediverso?

No es que me esté convirtiendo en un bot en internet (pero ganas no me faltan...), pero noto que es imposible mantener una presencia online cuando el trabajo y la vida personal me absorben. Si a eso le sumamos que estoy cerrando todos los side projects que tengo abiertos desde 2017, pues llego a un punto que mi tiempo libre no da para hacer tantos cambios.

Es por eso que, tras hacer las mejoras que necesitaba este blog, he decidido que parte de las interacciones se van a ir también a Bluesky y Mastodon.

El problema del spam (y cómo lo (mal)solucioné)

Todo empezó cuando, en un arrebato de productividad un sábado por la tarde, decidí que el scheduler que había montado para cross-postear mis artículos no solo publicase posts nuevos, sino que también republicase los antiguos "para darles visibilidad". Sonaba bien sobre el papel.

El scheduler, obedientísimo él, empezó a publicar un post cada 5 minutos. No se detuvo. No había check de "este post ya fue publicado". A las 3 de la mañana llevaba 20 posts publicados en Mastodon y seguía. Por suerte los rate limits me salvaron de llegar a 50.

La solución fue meter un archivo de estado (JSON) que trackea qué posts se han publicado ya en cada red:

{
  "mastodon": {
    "relanzando-blog": "1234567890",
    "appwrite-guia-supervivencia": "1234567891",
    ...
  },
  "bluesky": {
    "relanzando-blog": "at://did:plc:xxxxx/app.bsky.feed.post/yyyyy",
    ...
  }
}

Así, antes de publicar, el scheduler verifica si el slug ya existe en el archivo de estado. Si existe, lo salta. Si no, publica y guarda el ID del post en la red correspondiente.

La arquitectura: cómo funciona todo

El sistema se divide en tres partes que se comunican entre sí:

1. CMS API (Nuxt 4)

Cuando publico un post desde el panel de administración, la API:

  1. Crea/actualiza el archivo Markdown en web/content/blog/
  2. Marca el post como published en la metadata
  3. Dispara un build de Zola (genera el HTML estático)

2. Scheduler (Node.js)

El scheduler es un servicio que corre en segundo plano y hace dos cosas:

a) Monitoriza nuevos posts Cada hora, escanea el directorio de contenido, compara con su archivo de estado, y publica los posts nuevos en Mastodon y Bluesky.

// scheduler/index.js (simplificado)
const fs = require('fs');
const path = require('path');
const { publishToMastodon } = require('./mastodon');
const { publishToBluesky } = require('./bluesky');

const STATE_FILE = './state.json';
const CONTENT_DIR = '/site/web/content/blog';

async function checkNewPosts() {
  const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
  const files = fs.readdirSync(CONTENT_DIR).filter(f => f.endsWith('.md'));

  for (const file of files) {
    const slug = file.replace('.md', '');
    if (state.mastodon[slug]) continue; // Ya publicado

    const content = parseMarkdownFile(path.join(CONTENT_DIR, file));
    if (content.draft) continue; // No publicar borradores

    // Publicar en Mastodon
    const mastodonId = await publishToMastodon({
      title: content.title,
      description: content.description,
      url: `https://midominio.com/blog/${slug}/`
    });

    // Publicar en Bluesky
    const blueskyId = await publishToBluesky({
      title: content.title,
      description: content.description,
      url: `https://midominio.com/blog/${slug}/`
    });

    // Guardar estado
    state.mastodon[slug] = mastodonId;
    state.bluesky[slug] = blueskyId;
    fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));

    console.log(`[${new Date().toISOString()}] Published: ${slug}`);
  }
}

// Ejecutar cada hora
setInterval(checkNewPosts, 60 * 60 * 1000);

b) Sincroniza comentarios Cada 30 minutos, checkea los replies de los posts publicados y los guarda como datos estructurados que el blog puede consumir.

async function syncComments() {
  for (const [slug, mastodonId] of Object.entries(state.mastodon)) {
    const replies = await fetchMastodonReplies(mastodonId);
    const blueskyReplies = await fetchBlueskyReplies(state.bluesky[slug]);

    const allComments = [...replies, ...blueskyReplies]
      .sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));

    // Guardar comentarios como JSON para que Zola los consuma
    const commentsDir = `/site/web/comments/${slug}/`;
    fs.mkdirSync(commentsDir, { recursive: true });
    fs.writeFileSync(
      path.join(commentsDir, 'comments.json'),
      JSON.stringify(allComments, null, 2)
    );
  }
}

3. Integración con Mastodon

La API de Mastodon es REST y está bastante bien documentada. Usar la API de Mastodon para publicar es trivial:

const MASTODON_URL = process.env.MASTODON_URL;
const MASTODON_TOKEN = process.env.MASTODON_TOKEN;

async function publishToMastodon({ title, description, url }) {
  const status = `📝 ${title}\n\n${description}\n\n🔗 ${url}`;

  const response = await fetch(`${MASTODON_URL}/api/v1/statuses`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${MASTODON_TOKEN}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      status,
      visibility: 'public',
    })
  });

  const data = await response.json();
  return data.id;
}

4. Integración con Bluesky (AT Protocol)

Bluesky es más coñazo. No es REST como Mastodon, usan AT Protocol que requiere PAC (Proactive Assertion of Credentials) y firmas criptográficas en cada request. Tuve que usar la librería @atproto/api:

const { BskyAgent } = require('@atproto/api');

const agent = new BskyAgent({
  service: 'https://bsky.social',
});

async function publishToBluesky({ title, description, url }) {
  await agent.login({
    identifier: process.env.BSKY_IDENTIFIER,
    password: process.env.BSKY_PASSWORD,
  });

  const post = await agent.post({
    text: `${title}\n\n${description}`,
    embed: {
      $type: 'app.bsky.embed.external',
      external: {
        uri: url,
        title: title,
        description: description,
      }
    }
  });

  return post.uri;
}

La parte de los embeds (previews de enlaces) me costó encontrarla. Bluesky no permite enlaces en el texto plano como Mastodon — tienes que usar el app.bsky.embed.external explicitamente o no se genera la preview. Y la preview necesita título y descripción separados, no vale con mandar solo la URL.

Los problemas que encontré (y cómo los solucioné)

1. Resolución de enlaces en Mastodon

Mastodon resuelve las URLs de los enlaces para generar previews. Pero si tu servidor es lento o tiene rate limiting, Mastodon se rinde y no muestra preview. Solución: añadir Open Graph tags al HTML del blog y asegurarse de que el servidor responde en menos de 2 segundos:

<meta property="og:title" content="Título del post">
<meta property="og:description" content="Descripción del post">
<meta property="og:image" content="https://midominio.com/images/og-default.png">
<meta property="og:url" content="https://midominio.com/blog/slug/">

En Zola, esto se configura en el template base:

{% if page %}
<meta property="og:title" content="{{ page.title }}" />
<meta property="og:description" content="{{ page.description }}" />
<meta property="og:url" content="{{ current_url }}" />
{% endif %}
<meta property="og:image" content="https://midominio.com/images/og-default.png" />

2. La maldición de los caracteres especiales

Mi primer post automatizado tenía acentos y eñes que se rompieron al llegar a Mastodon. El problema era que no estaba codificando UTF-8 correctamente en el fetch. Solución: asegurarse de que el body del request usa UTF-8 explícitamente:

body: JSON.stringify({ status, visibility: 'public' }),
headers: {
  'Content-Type': 'application/json; charset=utf-8',
  ...
}

Y en Bluesky, los emojis y caracteres que ocupan más de 2 bytes en UTF-8 cuentan como 2 caracteres para el límite de 300. Cosas que aprendes cuando tu post de "🔧 Actualizando el servidor" se corta a mitad.

3. Webhooks vs polling

Inicialmente intenté usar webhooks para detectar nuevos posts al instante. Setup: cada vez que el CMS publica un post, hace un POST al scheduler. En teoría perfecto. En práctica, el scheduler estaba en un contenedor Docker que a veces se reiniciaba y perdía la notificación.

Me rendí y pasé a polling cada hora. Es menos elegante pero no falla. Y para un blog personal que publica un par de posts al mes, la latencia de una hora es perfectamente aceptable.

4. El rate limiting de Bluesky

Bluesky tiene rate limits más restrictivos que Mastodon. Si publicas seguido, te bloquean. La solución fue meter un semáforo:

let lastBlueskyPost = 0;
const BLUESKY_COOLDOWN = 5 * 60 * 1000; // 5 minutos

async function publishToBlueskySafe(post) {
  const now = Date.now();
  const wait = BLUESKY_COOLDOWN - (now - lastBlueskyPost);
  if (wait > 0) {
    console.log(`Esperando ${wait}ms antes de publicar en Bluesky`);
    await new Promise(r => setTimeout(r, wait));
  }
  lastBlueskyPost = Date.now();
  return publishToBluesky(post);
}

5. La autenticación en AT Protocol se refresca cada 2 horas

Algo que no esperaba: los tokens de Bluesky expiran a las 2 horas. Si el scheduler lleva más tiempo sin publicar, el token ha caducado y la publicación falla. La solución fue meter un refresh automático antes de cada operación:

async function ensureAuthenticated() {
  try {
    // Intentar una operación simple para verificar sesión
    await agent.getTimeline({ limit: 1 });
  } catch {
    // Token caducado, reloguear
    console.log('Token expirado, relogueando en Bluesky...');
    await agent.login({
      identifier: process.env.BSKY_IDENTIFIER,
      password: process.env.BSKY_PASSWORD,
    });
  }
}

6. El problema de los acentos y la longitud de posts

En Mastodon, los acentos cuentan como un carácter cada uno para el límite de 500 caracteres. En Bluesky, los caracteres multibyte (emojis, acentos en UTF-8 extendido) cuentan de forma diferente. Tuve que implementar un truncador inteligente:

function truncateText(text, maxGraphemes = 280) {
  // Usar el segmentador de Unicode para contar grafemas correctamente
  const segmenter = new Intl.Segmenter('es', { granularity: 'grapheme' });
  const segments = [...segmenter.segment(text)];

  if (segments.length <= maxGraphemes) return text;

  return segments.slice(0, maxGraphemes - 3).map(s => s.segment).join('') + '...';
}

Esto lo aprendes cuando tu post "🔧 He actualizado el servidor con las nuevas configuraciones de seguridad y rendimiento para la migración a Kubernetes" se corta a mitad y parece un mensaje cifrado.

7. Sincronización de comentarios: el talón de Aquiles

La parte más complicada no fue publicar, sino traer los comentarios de vuelta al blog. La idea era que los replies en Mastodon y Bluesky aparecieran como comentarios en el post original, creando un hilo conversacional unificado.

Para Mastodon, es relativamente fácil: la API devuelve el contexto de un post (thread), así que puedes obtener todos los replies:

async function fetchMastodonReplies(postId) {
  const response = await fetch(
    `${MASTODON_URL}/api/v1/statuses/${postId}/context`,
    {
      headers: {
        'Authorization': `Bearer ${MASTODON_TOKEN}`,
      }
    }
  );

  const context = await response.json();
  // Filtrar solo replies directos (no boosts, no el post original)
  return context.descendants
    .filter(s => s.in_reply_to_id === postId)
    .map(s => ({
      author: s.account.display_name || s.account.username,
      avatar: s.account.avatar,
      content: stripHtml(s.content),
      createdAt: s.created_at,
      url: s.url,
      platform: 'mastodon'
    }));
}

Para Bluesky es más enrevesado. No hay un endpoint directo de "dame los replies de este post". Tienes que usar el firehose (Jetstream) o hacer polling de la threadgate:

async function fetchBlueskyReplies(postUri) {
  const thread = await agent.getPostThread({
    uri: postUri,
    depth: 1,
  });

  if (!thread.data.thread.replies) return [];

  return thread.data.thread.replies
    .filter(r => r.post.record.reply?.parent?.uri === postUri)
    .map(r => ({
      author: r.post.author.displayName || r.post.author.handle,
      avatar: r.post.author.avatar,
      content: r.post.record.text,
      createdAt: r.post.record.createdAt,
      url: `https://bsky.app/profile/${r.post.author.handle}/post/${r.post.uri.split('/').pop()}`,
      platform: 'bluesky'
    }));
}

La arquitectura en detalle: el scheduler como sistema

El scheduler no es un script suelto, es un servicio completo con su propia arquitectura:

scheduler/
├── index.js          # Punto de entrada, polling loop
├── mastodon.js       # API client para Mastodon
├── bluesky.js        # API client para Bluesky (AT Protocol)
├── statemanager.js   # Gestión del archivo de estado
├── comment-sync.js   # Sincronización de comentarios
├── logger.js         # Logging estructurado
├── state.json        # Archivo de estado persistente
└── Dockerfile        # Para correr como contenedor

El logger fue una adición después del incidente del spam. Ahora todo lo que hace el scheduler queda registrado con timestamp y nivel:

// logger.js
const levels = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
const currentLevel = process.env.LOG_LEVEL || 'INFO';

function log(level, message, meta = {}) {
  if (levels[level] < levels[currentLevel]) return;

  console.log(JSON.stringify({
    timestamp: new Date().toISOString(),
    level,
    message,
    ...meta
  }));
}

Los logs van a Docker y los monitorizo con docker logs -f scheduler o, cuando me siento fancy, con Dozzle (un visor de logs web que tengo en otro contenedor).

¿Funciona? Pues sí, pero...

El sistema lleva funcionando desde enero de 2025. En este tiempo:

  • Posts publicados: ~3 (sí, escribo poco, el trabajo me absorbe)
  • Comentarios sincronizados: ~2 en Mastodon, 0 en Bluesky
  • Fallsos: 2 (uno por rate limiting, otro porque se me olvidó renovar el token de Mastodon)
  • Spam accidental: 1 (el del principio, que ya he contado)

Para ser un side project que programé en dos tardes entre semana, no está mal. Para el día a día de un blog personal, sobra. ¿Mejoraría algo? Muchas cosas. Pero mientras funcione, no lo toco.

El MVP está servido, ¿y ahora qué?

Con esta integración, mi portal ha alcanzado el MVP que quería desde que empecé a rediseñarlo en 2022:

  • Blog estático con Zola (rápido, seguro, sin JavaScript)
  • Publicación automática en Mastodon y Bluesky
  • Sincronización de comentarios desde las redes al blog
  • Despliegue automatizado via GitLab CI + Dokploy

El código del scheduler lo tengo en mi GitLab privado. Cuando lo limpie y documente (y cuando tenga tiempo) lo publicaré. De momento, si alguien quiere ver cómo funciona, que me pregunte en Mastodon.

Este post lo escribí mientras comía, y ahora lo dejo en manos del destino... y del despliegue semi-automatizado. Que los rate limits me acompañen.