Saltar al contenido
Blog

Enlaces de Discord caducan: mueve las imágenes de FiveM a un CDN

Mugshots del MDT y pruebas guardados como enlaces de Discord dicen “This content is no longer available.” Por qué caducan, cómo encontrarlos y cómo arreglarlo.

Por Termi

7 min de lectura
  • Discord
  • Uploads
  • MySQL
  • MDT
En esta página

Muchos servidores de FiveM mandan capturas, mugshots del MDT y fotos de pruebas a un webhook de Discord y guardan en la base de datos el enlace del adjunto que Discord devuelve. Funciona durante un día. Luego el MDT muestra imágenes rotas, y al abrir uno de los enlaces solo sale una línea: This content is no longer available. Los archivos suelen seguir en el canal. Lo que ha caducado son los enlaces de tu base de datos. Esta guía explica por qué, encuentra los enlaces muertos y pasa las subidas a un CDN cuyos enlaces no cambian.

Por qué dejan de funcionar los enlaces de Discord

La referencia de la API de Discord dice: “attachments uploaded to Discord’s CDN (like user and bot-uploaded images) have signed URLs with a preset expiry time.” Discord anunció el cambio a los desarrolladores a finales de 2023 y lo documentó en febrero de 2024. Desde entonces, cada enlace de adjunto lleva tres parámetros en la URL:

a signed link, split for reading
https://cdn.discordapp.com/attachments/<channel id>/<attachment id>/screenshot.png
  ?ex=66424c96
  &is=6640fb16
  &hm=0b3d3210b4ea…287dace4&
  • ex es, en palabras de Discord, un “hex timestamp indicating when an attachment CDN URL will expire”.
  • is es el timestamp en hexadecimal del momento en que se emitió el enlace.
  • hm es una firma que “remains valid until the URL’s expiration”.

Dentro de Discord no se nota nada raro, porque “Discord automatically refreshes attachment CDN URLs that appear within the client.” La imagen del canal sigue cargando. El enlace que tu script guardó en MySQL es una copia y nadie la renueva. Cuando pasa su hora ex, el CDN lo rechaza.

Cuánto dura un enlace

Discord no documenta una duración fija, así que léela del propio enlace. Los dos valores son timestamps Unix en hexadecimal: 6640fb16 es el 12 de mayo de 2024 a las 17:23:34 UTC y 66424c96 queda exactamente 24 horas después. Es el enlace real que probamos más abajo, pero Discord puede emitir enlaces con otras duraciones, así que revisa ex en tus propias filas en lugar de dar por hecho un día.

Qué significa “This content is no longer available.”

Abre un enlace caducado fuera de Discord y el CDN responde con un 404 en texto plano:

bash
$ curl -i "https://cdn.discordapp.com/attachments/…/screenshot.png?ex=66424c96&is=6640fb16&hm=…"
HTTP/2 404
content-type: text/plain;charset=UTF-8

This content is no longer available.

Los enlaces guardados antes de que Discord empezara a firmarlos, los que no llevan ningún ex, is ni hm, reciben la misma respuesta, tanto en cdn.discordapp.com como en media.discordapp.net. En un MDT o en cualquier otra página NUI se ve como una imagen rota, sin nada en la consola del servidor que lo explique.

Encuentra los enlaces muertos en tu base de datos

Todo enlace de adjunto de Discord contiene discordapp. y /attachments/. Esta consulta escribe una consulta de recuento para cada columna de texto de la base que usa tu servidor (la de mysql_connection_string):

SQL
SET SESSION group_concat_max_len = 1000000;

SELECT GROUP_CONCAT(
  CONCAT(
    'SELECT ''', table_name, '.', column_name, ''' AS place, COUNT(*) AS links FROM `',
    table_name, '` WHERE `', column_name, '` LIKE ''%discordapp.%/attachments/%'''
  )
  SEPARATOR ' UNION ALL '
) AS query
FROM information_schema.columns
WHERE table_schema = DATABASE()
  AND data_type IN ('char', 'varchar', 'tinytext', 'text', 'mediumtext', 'longtext', 'json');

Devuelve un único valor: otra consulta. Cópiala y ejecútala. Cada fila del resultado nombra una table.column y cuántos enlaces de Discord contiene. Todo lo que pase de cero es una columna que tus scripts rellenaron a través de un webhook.

Para ver qué filas de una columna ya han caducado, decodifica ex. Aquí con la tabla mdt_mugshots de la guía de mugshots:

SQL
SELECT identifier, image,
  IF(image LIKE '%?ex=%',
     FROM_UNIXTIME(CONV(SUBSTRING_INDEX(SUBSTRING_INDEX(image, '?ex=', -1), '&', 1), 16, 10)),
     NULL) AS expires
FROM mdt_mugshots
WHERE image LIKE '%discordapp.%/attachments/%';

Una fila cuyo expires está en el pasado ya no carga fuera de Discord, y tampoco una fila con NULL, que significa que el enlace nunca se firmó.

Sube a un CDN en su lugar

La solución es subir el archivo a un almacenamiento pensado para servirlo, guardar ese enlace en la base de datos y seguir publicando en Discord si el staff quiere ver la imagen en un canal, con el enlace del CDN en el embed. Aquí tienes una captura de prueba hecha con screenshot-basic, subida al endpoint base64 de FiveDock, guardada y publicada después en un webhook:

server/evidence.lua
local UPLOAD_URL = 'https://api.fivedock.dev/api/v3/file/base64'
local API_TOKEN = GetConvar('fivedock_api_token', '')
local WEBHOOK = GetConvar('evidence_webhook', '')

local function postEvidence(playerId, caseId)
    exports['screenshot-basic']:requestClientScreenshot(playerId, { encoding = 'webp', quality = 0.85 }, function(err, data)
        if err then
            return print(('Screenshot failed: %s'):format(err))
        end

        local key = ('evidence/%s/%d.webp'):format((tostring(caseId):gsub('[^%w_%-]', '_')), os.time())

        PerformHttpRequest(UPLOAD_URL, function(status, body)
            if status ~= 200 and status ~= 201 then
                return print(('Evidence upload failed (%s): %s'):format(status, tostring(body)))
            end

            local response = json.decode(body)
            local url = response and response.data and response.data.url
            if not url then return end

            MySQL.insert('INSERT INTO `mdt_evidence` (`case_id`, `image`) VALUES (?, ?)', { caseId, url })

            PerformHttpRequest(WEBHOOK, function() end, 'POST', json.encode({
                embeds = { { title = ('Case %s'):format(caseId), image = { url = url } } },
            }), { ['Content-Type'] = 'application/json' })
        end, 'POST', json.encode({ base64 = data, filename = 'evidence.webp', key = key }), {
            ['Content-Type'] = 'application/json',
            ['Authorization'] = API_TOKEN,
        })
    end)
end
  • El token de API (fd_live_…, con el scope files:write) va en server.cfg con set, nunca con setr. La guía de mugshots explica por qué.
  • El key le da a cada imagen un enlace que nunca cambia: https://cdn.fivedock.dev/o/<your-workspace>/evidence/<case>/<time>.webp.
  • Discord muestra la imagen del embed directamente desde ese enlace. La fila de mdt_evidence guarda el mismo enlace, que sigue funcionando hasta que borres el archivo o lo quite una regla de retención que hayas configurado.

La petición tiene la misma forma que la subida base64 de Fivemanage, así que un script hecho para Fivemanage solo necesita otra URL y otro token. Sube archivos tú mismo cubre el panel, los enlaces con clave y el endpoint S3.

Si usas el agente de FiveDock, exports.fivedock:screenshot hace la captura y la sube en una sola llamada, sin token en tu script. Mira la documentación del agente.

Recuperar las imágenes antiguas

Los enlaces de tu base de datos están muertos, pero los archivos normalmente siguen en el canal de Discord, y Discord entrega un enlace nuevo cada vez que una aplicación vuelve a pedir el mensaje. Un bot de Discord que pueda leer el canal puede recorrer el historial de 100 en 100 mensajes y emparejar cada adjunto con tus filas: en /attachments/<channel id>/<attachment id>/, el segundo número es el ID del adjunto. Descarga cada archivo con su enlace nuevo, súbelo a FiveDock con la clave que ahora usa tu script y actualiza la fila.

Un webhook no puede listar un canal, así que hace falta un bot. Los archivos cuyo mensaje se borró, o cuyo canal ya no existe, no se pueden recuperar.

Preguntas frecuentes

¿Por qué dejan de funcionar los enlaces de imágenes de Discord?

Discord firma cada enlace de adjunto con una hora de caducidad y lo documentó en su referencia de la API en febrero de 2024. La aplicación de Discord renueva los enlaces que muestra, por eso la imagen sigue cargando en el canal, pero un enlace guardado en una base de datos nunca se renueva. Al caducar, el CDN responde 404 con el texto This content is no longer available.

¿Qué significan ex, is y hm en un enlace de Discord?

ex es la hora a la que caduca el enlace e is la hora a la que se emitió, ambas como timestamps Unix escritos en hexadecimal. hm es una firma que sigue siendo válida hasta ex. En MySQL, FROM_UNIXTIME(CONV('66424c96', 16, 10)) convierte un valor ex en una fecha.

¿Cuánto duran los enlaces de adjuntos de Discord?

Discord no documenta una duración fija; cada enlace lleva su propia caducidad en el parámetro ex. El enlace real que probamos para esta guía se emitió para exactamente 24 horas. Trata cualquier enlace de adjunto de Discord guardado en una base de datos como temporal.

¿Puedo hacer permanente un enlace de adjunto de Discord?

No. La única forma de volver a tener un enlace que funcione es pedir el mensaje a la API de Discord, y para eso hace falta un bot que pueda leer el canal. Para todo lo que necesites cargar más adelante, sube el archivo a un CDN, guarda ese enlace y publica el enlace del CDN en Discord si aun así lo quieres en un canal.

¿Es un webhook de Discord un buen sitio para guardar capturas de FiveM?

Es un buen sitio para mostrarlas y uno malo para conservarlas. El enlace que devuelve caduca, y el límite de subida por defecto de Discord, 20 MiB por archivo desde septiembre de 2026, también se aplica a los webhooks. Guarda el archivo en un CDN y envía el enlace del CDN en el embed del webhook.

Todos los artículos