Publicas en Bluesky a través de la API, el post aparece, y el enlace que lleva dentro está muerto. No roto: simplemente texto plano, ahí, sin poder hacerle clic. Nada dio error. La API devolvió éxito.
Esto importa más de lo que parece, porque si estás publicando un enlace rastreado el sentido entero del post es que alguien pueda hacerle clic. Nosotros publicamos posts reales en Bluesky, así que esto es lo que está pasando, la parte que la gente hace mal cuando lo corrige, y cómo comprobar que lo arreglaste bien.
Bluesky no interpreta tu texto
La mayoría de las plataformas escanean el post buscando cualquier cosa que parezca una URL y la convierten en enlace por ti. Bluesky no. El AT Protocol guarda el texto exactamente como lo enviaste y, por separado, guarda anotaciones que describen qué rangos de ese texto significan algo. Esas anotaciones se llaman facets.
Sin facets, no hay enlace. El cliente no tiene nada que le diga que esos caracteres en particular son una URL, así que los muestra como caracteres. Es una decisión de diseño deliberada, no un descuido: el protocolo mantiene el texto canónico y empuja toda la interpretación a metadatos estructurados, que es la razón por la que el mismo registro se ve igual en todos los clientes que lo leen.
Un facet es un rango más una o más features. El rango es un objeto con byteStart y byteEnd. La feature lleva un discriminador $type y la carga que ese tipo necesite:
- app.bsky.richtext.facet#link — lleva un campo uri con el destino real. Este es el que necesitas para enlaces clicables.
- app.bsky.richtext.facet#mention — lleva un did, porque las menciones se resuelven a un identificador descentralizado y no a un handle. Los handles pueden cambiar; los DID no.
- app.bsky.richtext.facet#tag — lleva una cadena tag para los hashtags.
Como la uri va separada del texto, el texto mostrado y el destino no tienen por qué coincidir. Así es como los clientes muestran un enlace corto sobre una URL larga. Y también, vale la pena decirlo, así es como un registro malicioso podría mostrar un dominio y enlazar a otro — así que si alguna vez renderizas registros del AT Protocol por tu cuenta, no confíes en el texto.
El registro que realmente envías por POST a com.atproto.repo.createRecord es un cuerpo con tres campos: repo con el DID del autor, collection con app.bsky.feed.post, y record — el post en sí. El record lleva un $type de app.bsky.feed.post, el text, un createdAt en ISO, un array facets opcional y un embed opcional. Fíjate en el encuadre: estás escribiendo un registro en el repositorio del usuario, no llamando a un endpoint de crear post. Esa distinción explica casi toda la forma de la API.
La parte que todo el mundo hace mal: son offsets en BYTES
byteStart y byteEnd son offsets en bytes UTF-8. No son índices de string, y en JavaScript esas dos cosas no son el mismo número.
Los strings de JavaScript son UTF-16. Un carácter ASCII simple ocupa un byte en UTF-8 y una unidad en UTF-16, así que en un post todo-ASCII los dos coinciden exactamente y tu código parece correcto. En cuanto aparece algo multibyte antes de la URL — un emoji, una letra acentuada, un apóstrofo curvo, un alfabeto no latino — se separan, y el facet apunta a un tramo equivocado.
Vale la pena interiorizar la magnitud de la desviación. Un carácter latino acentuado son dos bytes UTF-8 y una unidad UTF-16, así que te desplaza uno. Un carácter CJK son tres bytes y una unidad: un desplazamiento de dos. Un emoji típico son cuatro bytes y dos unidades UTF-16: un desplazamiento de dos. Un emoji compuesto con tono de piel o secuencias ZWJ puede ser mucho más. Así que un post que empieza con un solo emoji de cohete desplaza todos los offsets en bytes posteriores respecto al índice del string, y el enlace o pierde sus primeros caracteres o se extiende más allá de su final. Bluesky renderiza fielmente lo que cubra el rango, que suele ser un fragmento de URL envuelto en un fragmento de tu frase.
Por eso el bug es tan escurridizo. Funciona perfecto en cada test que escribes con fixtures en ASCII, y luego se rompe con el primer usuario que empieza un post con un emoji — que, en redes sociales, es más o menos el primer usuario.
Por qué el arreglo obvio sigue estando mal
El arreglo obvio es echar mano de una longitud en bytes en algún lado. Va en la dirección correcta y aun así suele estar mal de dos maneras.
El primer error es medir el string completo en vez del prefijo. Lo que quieres para byteStart es la longitud en bytes de todo lo que va antes de la coincidencia — la longitud UTF-8 del trozo que va desde el inicio del texto hasta el índice de la coincidencia. En Node, eso es la longitud en bytes de text.slice(0, matchIndex). Ni la longitud del texto, ni el índice de la coincidencia.
El segundo error es más sutil y sobrevive a un code review. Una vez calculado bien byteStart, es tentador escribir byteEnd como byteStart más la longitud en caracteres de la URL. Eso funciona con cualquier URL ASCII, que son casi todas, así que se despacha. Se rompe con dominios internacionalizados y con cualquier URL que lleve caracteres no ASCII en la ruta o en la query, algo que pasa más de lo que crees en cuanto la gente pega enlaces desde resultados de búsqueda. byteEnd tiene que ser byteStart más la longitud en bytes de la propia URL.
Los dos errores tienen la misma forma: convertir parcialmente a bytes y dejar un término en espacio de caracteres. Si tu función que construye facets tiene algún .length pelado sobre un string, míralo otra vez.
Un segundo detalle: la puntuación final
Una URL al final de una frase suele llevar un punto pegado. Una regex ingenua de URL — del tipo “https más lo que sea que no sea un espacio en blanco” — es codiciosa hasta el espacio y se lo traga. Ahora tu facet afirma que el enlace incluye el punto, y la uri que adjuntas también lo incluye, lo que produce un enlace que da 404.
Recorta la puntuación final de la frase de la URL detectada antes de calcular el rango: puntos, comas, punto y coma, dos puntos, signos de exclamación e interrogación, corchetes y paréntesis de cierre. Recórtalos como grupo, no de uno en uno, porque una URL que cierra un paréntesis puede arrastrar dos a la vez. Ojo: ese recorte tiene que ocurrir antes de calcular byteEnd, porque el rango y la uri deben describir la misma cadena — un facet cuyo rango cubre un tramo mientras su uri contiene otro es un bug que solo se ve visualmente.
Los paréntesis de cierre son el único juicio discutible, porque algunas URLs reales terminan legítimamente en uno. Recortarlos es el default correcto: un enlace roto dentro de un paréntesis es peor resultado que un enlace ligeramente corto en una URL rara.
Cómo detectar esto en tu propio código
El test que atrapa todo esto es un ida y vuelta. No verifiques los números que calculaste: verifica que cortar el texto por el rango que produjiste devuelve exactamente la URL que declaraste.
- Codifica el texto del post a un buffer UTF-8, córtalo de byteStart a byteEnd, decodifícalo de vuelta a string y comprueba que es igual a la uri del facet. Esa única aserción atrapa a la vez errores de prefijo, de tramo y de puntuación.
- Corre esa aserción sobre un conjunto de fixtures deliberadamente hostil: un emoji al inicio, una palabra acentuada antes del enlace, una frase CJK, un apóstrofo curvo, dos enlaces en un post, un enlace en la posición cero, y un enlace que cierra la frase con un punto.
- Agrega la misma comprobación de ida y vuelta como aserción barata en tiempo de ejecución antes de publicar, y loguea fuerte si falla. Cuesta microsegundos y convierte un bug visual silencioso en una línea de log.
- Si ya tienes posts publicados, puedes auditarlos: recupera los registros, vuelve a correr el ida y vuelta contra el texto y los facets guardados, y cuenta los desajustes. Todo lo que falle se publicó roto.
Lo que hace que valga el esfuerzo es el costo de dejarlo pasar. La API devuelve éxito. Tu monitoreo está en verde. El post se ve bien en tu propio dashboard, porque tu dashboard renderiza tu texto, no los facets de Bluesky. El único lugar donde el fallo es visible es en Bluesky mismo, para tus lectores, en los posts que más importan: los que llevan un enlace. Si estás midiendo clics, la señal es un canal que reporta impresiones y cero clics para siempre, lo que parece un problema de distribución en vez de un bug.
Otras cosas que conviene saber antes de construir esto
- No hay flujo de redirección OAuth que implementar para el camino de app password. Te autenticas con un handle y una app password contra com.atproto.server.createSession, y refrescas con com.atproto.server.refreshSession.
- Las app passwords las crea el usuario en su configuración de Bluesky y se pueden revocar de forma independiente de su contraseña real. Es un modelo genuinamente más agradable que el de la mayoría de las integraciones OAuth.
- Los tokens de sesión duran poco — del orden de un par de horas — así que refresca con antelación en vez de esperar a un 401 en mitad de la publicación.
- Distingue 4xx de 5xx al refrescar. Un 4xx significa que el permiso ya no existe y el usuario debe reconectar; un 5xx significa que el servidor tuvo un tropiezo y deberías reintentar. Meter ambos en una sola clase de error hace que una app password revocada se reintente para siempre mientras que un hipo rutinario del servidor desconecte permanentemente una cuenta que funcionaba. Nosotros enviamos esa confusión una vez y produjo los dos modos de fallo.
- Guarda el DID, no el handle, como identificador de la cuenta. Los handles son mutables; los DID son la identidad estable, y cada facet de mención se indexa por DID exactamente por eso.
- El límite de 300 caracteres se cuenta en grafemas, no en bytes ni en unidades UTF-16. Un emoji de familia es un grafema y muchísimos bytes. Hay tres definiciones distintas de longitud en juego en la misma API: bytes para los facets, grafemas para el límite, UTF-16 para el tipo string de tu lenguaje.
- Las imágenes se suben primero como blobs vía com.atproto.repo.uploadBlob con el Content-Type real del archivo, y luego se referencian desde el embed del record.
- Un embed es de tipo images, video o external — nunca una mezcla, y el video usa su propio $type con un solo blob en lugar de un array. Construir un array de images sin importar el tipo de medio hace que un blob de video sea rechazado con un error de tipo que nombra el desajuste, lo cual al menos es un fallo honesto.
- La respuesta devuelve una URI at://. La URL pública web se construye a partir del DID y del último segmento de la ruta, la rkey.
- Los perfiles vuelven con displayName como cadena vacía, no null, para las cuentas que nunca lo configuraron — así que un fallback con nullish coalescing no hace nada en silencio y terminas mostrando un nombre en blanco.
Por qué nos importaba tanto dejar esto exacto
seenpaid atribuye ingresos a posts individuales, y lo hace rastreando el enlace que va dentro de cada post. En Bluesky, un enlace sin facet es una cadena sin clic — lo que significa cero clics, cero atribución, y una función que no hace nada en silencio mientras reporta éxito en cada capa.
Así que no era un bug cosmético. Era la diferencia entre que Bluesky fuera un canal soportado y que fuera un canal donde el producto central fallaba en silencio. Esa es a grandes rasgos la historia de toda integración con plataformas: LinkedIn retira versiones de su API en una fecha sin avisar, la propia guía de configuración de Meta nombra un permiso que no existe, y Bluesky aceptará encantado un post cuyo enlace no hace absolutamente nada.