Skip to content

Instantly share code, notes, and snippets.

@daisyUniverse
Last active July 22, 2026 10:05
Show Gist options
  • Select an option

  • Save daisyUniverse/2d44c42a69a83924fb6823ba5c79ebf6 to your computer and use it in GitHub Desktop.

Select an option

Save daisyUniverse/2d44c42a69a83924fb6823ba5c79ebf6 to your computer and use it in GitHub Desktop.
Experiments with oEmbed, OpenGraph, and Twitter Summary Cards in Discord, Fluxer, and Telegram [WIP]

Over the years I've had such a pain in the ass finding out exactly how discord embeds work. I love doing web development in my off time, and one of the most fascinating parts to me is creating apps that you can just post a link to in your friend group and have them all instantly be able to see whatever weird thing you're working on. A while back that led me to creating fxtwitter, which required some pretty jank setup to make work

It's strange. Over the years it feels like nobody has actually gone through and documented all possible interactions between embedded metadata and what appears on screen when you use it.. people vaguely are aware that OG tags work, and sometimes creative folks experiment with oembed endpoints, but I feel like no one has ever gone out of their way to actually go through and list what does work, what works weird, and what doesn't work at all

So I'm gonna try!

This is where I will document any limitations and abilities I find for embeds in a given chat protocol

DISCORD

These are each found through slow and tedious experimentation and research, along with personal experience from my work on fxtwitter


OpenGraph


What works

  • site_name
  • site_url
  • theme-color
  • Descripton ( up to 300 characters, rest is truncated )
  • One image (can be enlarged by using <meta name="twitter:card" content="summary_large_image" />)

What doesn't

  • image arrays
  • videos ( they just show up as a single static image )
  • metadata tags for media ( ie music, movies, books, etc., )

Twitter Summary Cards


What works

What doesn't


oEmbed


What works

  • Videos

What doesn't

  • Arbitrary iframes via the html tag. This follows a strict whitelist of sites that are able to use this.

FLUXER

As of writing these are only verified by skimming the source code. Probably will act very different after refactor

Extras

  • If OpenGraph, Twitter Card, or oEmbed don't provide the Title, it can be extracted from the <title> tag
  • Audio resolver supports mp3, .wav, .ogg, .flac, and .aac
  • Video resolver supports mp4, webm, .ogv, .mov, and .avi
  • All media appear to support metadata somehow
  • Can also resolve media embeds with url parameters

Dedicated Resolvers

  • Bluesky
    • API Provided ( packages/api/src/unfurler/resolvers/BlueskyResolver.tsx )
    • Theme Color
    • Author Icon, Name, and URL
    • Description
    • Image array with alt text
    • Videos
    • Footer ( Icon, Text )
    • Subfooter ( Text ( X reposts, X likes ) )
  • HackerNews
    • API provided ( packages/api/src/unfurler/resolvers/HackerNewsResolver.tsx )
    • Theme Color
    • Author name
    • Title name & link
    • Description ( markdown supported )
    • Footer ( Icon & Text )
  • Tenor
    • Scraped ( packages/api/src/unfurler/resolvers/TenorResolver.tsx )
    • Simple GIFV direct media link
    • it is very funny that they dont use the api for this
  • Klipy
    • Scraped ( packages/api/src/unfurler/resolvers/KlipyResolver.tsx )
    • Doesn't appear to be working at the moment
    • Probably works identically to Tenor
  • Wikipedia
    • API Provided ( packages/api/src/unfurler/resolvers/WikipediaResolver.tsx )
    • Theme Color
    • Title name & URL
    • Description
    • Thumbnail
  • Xkcd
    • Scraped ( packages/api/src/unfurler/resolvers/WikipediaResolver.tsx )
    • Title name & url
    • Image thumbnail with alt text
    • Footer ( Alt text )
  • Youtube
    • API Provided ( packages/api/src/unfurler/resolvers/WikipediaResolver.tsx )
    • Theme Color
    • Provider name & url
    • Author name & url
    • Title name & url
    • Embeded youtube iframe
  • AcitivityPub
    • API Provided ( packages/api/src/unfurler/resolvers/subresolvers/ActivityPubFetcher.tsx )
    • Theme Color
    • Provider Name and URL
    • Author Name and URL
    • Site Name and URL?
    • Descripton
    • Image array with alt text
    • Videos

OpenGraph


Genuinely, stellar support all around for opengraph. Many social and chat apps seem to have shoddy support for the 3 main embedding standards, leading to the kind of frustrated confusing I've had to work around with Discord specifically - Fluxer actually did the smart thing here and took the time to actually properly implement all of the relevant features of opengraph, which means you can make just about any embed possible simply by having your opengraph tags set up correctly. We love to see it. There is only one minor downside ( and honestly it's so minor that I might expect it to be a bug rather than an intentional design decision ) which is that the "Author Name" and "Author URL" parameters are only ever picked up from an oembed endpoint. So basically you need to spin up an entire oembed system just to fill out that one line ( I believe it's usually that medium white text underneath the provider name and above the Title )

What ( Probably ) Works

  • og:title
  • og:site_name ( Seemingly this can be used in place of a provider_name value from an oembed endpoint )
  • og:description
  • og:theme-color
  • og:image ( Properly implements image arrays up to 10 by default! )
  • og:image:alt
  • og:image:description (fallback if no alt text in :alt)
  • og:image:secure_url
  • og:video
  • og:video:url
  • og:video:secure_url
  • og:audio
  • og:audio:url
  • og:audio:secure_url

What Doesn't

  • og:determiner
  • og:url
  • any extended namespaces ( ie movie and music metadata )

Twitter Summary Cards


Twitter elements in Fluxer are seemingly not as useful as they are with Discord - The main use of twitter elements there is to get around the piss-poor opengraph support that has a ton of holes, and for manipulating the image size, because for some reason that's the only way to do that. Fluxer does have good support for twitter card tags, but most of them are largely reduntant in the context of building an embed for fluxer intentionally. Basically all of the functionality is covered by opengraph, with the interesting exception of twitter:player. I genuinely don't know how or why this is implemented, but I'm excited to test it, because this is one of the two ways that someone would potentially embed arbitrary HTML into an embed

What Works

  • twitter:title ( fallback if no opengraph title is found )
  • twitter:description ( ditto )
  • twitter:site:name ( Seemingly this can be used in place of a provider_name value from an oembed endpoint )
  • twitter:image ( og fallback )
  • twitter:image:src ( og fallback )
  • twitter:image:alt ( og fallback )
  • twitter:video ( og fallback )
  • twitter:video:url ( og fallback )
  • twitter:player ( genuinely no fucking clue how this would work )
  • twitter:player:stream ( ditto )
  • twitter:audio ( og fallback )
  • twitter:audio:url ( og fallback )

What Doesn't

  • twitter:card ( All element sizing is determined by the client )
  • twitter:creator ( basically the @username field, seemingly just ommitted? ) ( can be provided via author_name in oembed )

oEmbed


From what I can tell, Fluxer in it's current state does not really read into oEmbed data on urls without a dedicated unfurler They seem to have instead opted to implement opengraph tags properly, supporting things like image arrays and video embeds Unfortunately, it also appears that there is a single field that can't be filled with opengraph tags that you need an oembed endpoint to fill out, being the auther_name and author_url. This is minorly frustrating because you have to spin up a whole endpoint to support one line of text, but the embed implementation is still a vast improvement over Discord's

It is a bit of a bummer to see that you can't just cram an iframe into an oembed response to make basically whatever you want, but I will wait for the refactor to see if this behavior changes. if not I may attempt to make a PR to implement it as an optional feature you can enable for self hosted instances full of people you trust. I do understand blocking it from a security standpoint but the potential for cool gizmos and gadgets is so enticing that I would actually suggest that the rendering of rich embeds be something the client can opt-in to play with

What Works

  • author_url
  • author_name
  • provider_url
  • provider_name

What Doesn't

  • basically everything else

CONCLUSION


Basically it's the stochastic ideal in comparison to discord, allowing you to do basically everything that you can possible do via simple meta tags in the header of your site - But there are still possible improvements - The absolute perfect implementation would be websites that can basically completely take advantage of the full embed object the same as a webhook or a bot could, but there are a few properties you can't set

  • You can't provide a Author icon
  • You can't provide Footers or Subfooters ( or an icon that would normally accompany that )
  • Secondary images aren't possible as far as I can tell ( ie a site badge to display in the top right corner alongside a gallery of images )

The only other issue is that you are only able to provide an author_name and author_url via a dedicated oembed endpoint ( it would be way better if it was possible to use twitter:author ) but honestly this is such a minor gripe that I would probably assume this is a bug rather than a deliberate design decision

Fluxer's web embed implementation gets 4 big booms

TELEGRAM

These are found through light experimentation and user reports


OpenGraph



Twitter Summary Cards


What Works

What Doesn't

  • twitter:player (breaks the whole embed)
  • twitter:description (breaks the whole embed)

I actually recently figured out why twitter:player breaks the entire telegram embed. It's because twitter:player's purpose is for embedding a livestream, and in order to do that, it needs to actually embed the player itself into the embed, which means it wants to embed HTML, which is obviously a big XSS vector, so is agressively disabled. ( speculation guy )


oEmbed


# oEmbed is quite a lot more powerful, especially in the case of Discord embeds
# Though it is a bit more complex to implement.
# With OG tags, you are able to just place those right in the HTML of the page being served, not so with oEmbed
# oEmbed basically tells the client (in our case, the discord embed scraper bot) to make a request to a seperate endpoint specified in the file
# The endpoint can either provide a .json response, or .XML, though I've only experimented with .json personally, they should be basically interchangable
# A typical setup is to have your oEmbed endpoint take several URL paramters that are generated with the content of the page being previewed
# Technically there's nothing stopping you from having a single oembed endpoint that generates the content the same way your main page does, but
# it does seem to be fairly atypical to do so
# A typical oembed header line
url = "https://fluxerselfhosting.universe.dog"
parameters = [ f"{url}/somepage", "json" ]
oembed_line = f'<link rel=alternate type="application/json+oembed" href="{url}/oembed.json?url={parameters[0]}&format={parameters[1]}"'
# oEmbed support in discord is slightly hit or miss. While it does support most of the features (title, description, and a few more fields)
# discord explicitly blocks arbitrary iframes from being embedded via the html oEmbed attribute for all but a specific whitelist of websites
# Hopefully with Fluxer these limitations will be able to be worked around on self hosted instances though!
oembed = {
_type = "" # photo, video, link, rich
version = 1.0 # The oEmbed version number. This must be 1.0.
title = "" # A text title, describing the resource.
author_name = "" # The name of the author/owner of the resource.
author_url = "" # A URL for the author/owner of the resource.
provider_name = "" # The name of the resource provider.
provider_url = "" # The url of the resource provider.
thumbnail_url = "" # A URL to a thumbnail image representing the resource.
thumbnail_width = "" # The width of the optional thumbnail
thumbnail_height = "" # The height of the optional thumbnail
cache_age = 86400 # The suggested cache lifetime for this resource, in seconds.
width = 600 # The width of the embed. Sometimes respected
height = "null" # This is usually not respected
html = "" # The HTML required to display a rich resource (ie iframe)
}
oembed_photo = {
_type = "photo" # When using a photo type, these are the minimum required fields
url = "" # the URL of the photo
width = "" # the Width of the photo
height = "" # the Height of the photo
}
oembed_video = {
_type = "video" # When using a video type, these are the minimum required fields
html = "" # Typically this would be an embedded video player, but this isn't respected in most apps
width = "" # the Width of the video
height = "" # the Height of the video
url = "" # Technically optional, but required to embed videos in discord
}
# This is the basic structure of an oembed
# Embeds the simple way: OpenGraph tags
# Simply embedding og:whatever tags in the header of the site itself is usually sufficient for discord embeds
# this allows you to embed an image, a description, and a title. For a basic pretty embed this is usually fine
# Most of this data is pulled from https://ogp.me/
# While most of the experimentation here will be in regards to these systems interaction with discord and the like, I will be trying to document as
# much of these standards as I can. who knows, maybe I stumble upon some weird undiscovered tricks along the way?
# og:
# Base OpenGraph tags
OpenGraph = {
title = "" # The title of your object as it should appear within the graph, e.g., "The Rock".
_type = "" # The type of your object, e.g., "video.movie". Depending on the type you specify, other properties may also be required.
image = "" # An image URL which should represent your object within the graph
audio = "" # A URL to an audio file to accompany this object.
description = "" # A one to two sentence description of your object.
determinier = "" # The word that appears before this object's title in a sentence. An enum of (a, an, the, "", auto). If auto is chosen, the consumer of your data should choose between "a" or "an". Default is "" (blank).
locale = "" # The locale these tags are marked up in. Of the format language_TERRITORY. Default is en_US
locale_alternative = "" # An array of other locales this page is available in
site_name = "" # If your object is part of a larger web site, the name which should be displayed for the overall site. e.g., "IMDb"
video = "" # A URL to a video file that complements this object
url = "" # The canonical URL of your object that will be used as its permanent ID in the graph, e.g., "https://www.imdb.com/title/tt0117500/".
}
# og:image:
# The standard supports using multiple of these elements to make an array, but as far as I know Discord does not recognize more than one.
OpenGraph_Image = {
image = "" # The URL of the image
width = "" # The Dimensions of the image
height = "" # The Dimensions of the image
secure_url = "" # An HTTPS url for the image
_type = "" # The MIME Type of the image (for example 'image/jpeg')
alt = "" # The images alt text
}
# og:video:
# Has the same properties of the image type, but this method does not work in discord
OpenGraph_Video = {
video = "" # The URL of the video
width = "" # The Dimensions of the video
height = "" # The Dimensions of the video
secure_url = "" # An HTTPS url for the video
_type = "" # The MIME Type of the video (for example 'application/x-shockwave-flash')
}
# og:audio:
# This would be used to embed audio, but I don't know of any chat service that supports this.
OpenGraph_Audio = {
audio = "" # The URL of the audio file
secure_url = "" # An HTTPS url for the audio
_type = "" # The MIME Type of the audio (for example 'audio/mpeg')
}
# There are also some types dedicated to media metadata, as seen in IMBD headers
# None of these are used for discord embeds but I want to have them all documented anyways
### MUSIC ###
# These operate under a namespace system following a URI found here: https://ogp.me/ns/music#
OpenGraph_Music_Song = {
duration = 1 # TYPE: Integer # Length of the song in seconds
album = "" # TYPE: Music.album Array # The album the song is from
album_disc = 1 # TYPE: Integer # Which disc of the album this song is on
album_track = 1 # TYPE: Integer # Which track this song is
musician = "" # TYPE: Profile Array # The musician that made this song. Takes a profile object
}
OpenGraph_Music_Album = {
song = "" # TYPE: Music.song # The song on this album
disc = 1 # TYPE: Integer # The disc this song is on
track = "" # TYPE: Integer # The track this song is on
musician = "" # TYPE: Profile # The musician that made this song
release_date = "" # TYPE: Datetime # The date this album was released
}
OpenGraph_Music_Playlist = {
song = "" # TYPE: Music.song # The song on this album.
disc = "" # TYPE: Music.song:disc # The disc this song is on
track = "" # TYPE: Music.song:track # The track
creator = "" # TYPE: Profile # The creator of this playlist
}
OpenGraph_Music_Radio_Station = {
creator = "" # TYPE: Profile # The creator of this station
}
### VIDEO ###
# These operate under a namespace system following a URI found here: https://ogp.me/ns/video#
OpenGraph_Video_Movie = {
actor = "" # TYPE: Profile Array # Takes an array of profile objects
actor_role = "" # TYPE: String # The role that they played
director = "" # TYPE: Profile Array # Takes an array of profile objects
writer = "" # TYPE: Profile Array # Takes an array of profile objects
duration = 1 # TYPE: Datetime # Takes an array of profile objects
release_date = "" # TYPE: Datetime # Takes a datetime object
tag = "" # TYPE: String Array # Takes a string array of words associated with this movie (ie genres)
}
# Identical to video.movie
OpenGraph_Video_TV_Show = OpenGraph_Video_Movie
# Identical to video.movie, but with one more tag to indicate TV show
OpenGraph_Video_Episode = {
series = "" # TYPE: Video.tv_show # The series this episode belongs to
}
# Identical to video.movie
OpenGraph_Video_Other = OpenGraph_Video_Movie
### NON-VERTICAL ###
# These are globally defined objects that just don't fit into a vertical but yet are broadly used and agreed upon.
# None have uses in discord as far as I know
OpenGraph_Article = { # URI : https://ogp.me/ns/article#
published_time = "" # TYPE: Datetime # When the article was first published.
modified_time = "" # TYPE: Datetime # When the article was last changed.
expiration_time = "" # TYPE: Datetime # When the article is out of date after.
author = "" # TYPE: Profile Array # Writers of the article.
section = "" # TYPE: String # A high-level section name. E.g. Technology
tag = "" # TYPE: String Array # Tag words associated with this article.
}
OpenGraph_Book = { # URI : https://ogp.me/ns/book#
author = "" # TYPE: Profile Array # Who wrote this book.
isbn = "" # TYPE: String # The ISBN
release_date = "" # TYPE: Datetime # The date the book was released.
tag = "" # TYPE: String Array # Tag words associated with this book.
}
# Note: This is still in beta according to OGP
OpenGraph_Payment_Link = { # URI : https://ogp.me/ns/payment#
description = "" # TYPE: String # Description about the payment link.
currency = "" # TYPE: String # The currency code ISO 4217 of the payment.
amount = 1.0 # TYPE: Float # An amount requested on the payment link in decimal format.
expires_at = "" # TYPE: Datetime # The date and time including minutes and seconds on which the payment link expires.
status = "" # TYPE: Enum # Status of the payment. (Pending, Paid, Failed, Expired)
id = "" # TYPE: String # The unique identifier associated with the payment link for a given payment gateway or service provider
success_url = "" # TYPE: URL # A valid URL that gets redirected when payment is success.
}
OpenGraph_Profile = { # URI : https://ogp.me/ns/profile#
first_name = "" # TYPE: String # A name normally given to an individual by a parent or self-chosen.
last_name = "" # TYPE: String # A name inherited from a family or marriage and by which the individual is commonly known.
username = "" # TYPE: String # A short unique string to identify them.
gender = "" # TYPE: Enum # Their gender. (Male, Female) (bruh)
}

Support Matrix

This should help to show at a glance which components of which standards are supported by which sites, what they do, and any implementation notes Please note that this is a work in progress document, and for now I'm just filling it out based on shakey experience. Once this document is complete I will be making an actual repo out of it, but until then take these findings with a grain of salt.

I will mark each entry that I have verified through testing with a V tag

Emoji Meaning
I genuinely have no clue
I'm pretty sure it works based on what I know
☑️ Redundant, used as a fallback value in case a higher priority tag is unset
🚫 As far as I know, it's not supported

in the future I will make a matrix for each app with a diagram showing which value each tag fills, it's priority, and where it ends up in the final embed.


OpenGraph


Tag Discord Fluxer Telegram Purpose
og:title V V The title of your object as it should appear within the graph, e.g., "The Rock".
og:site_name If your object is part of a larger web site, the name which should be displayed for the overall site. e.g., "IMDb"
og:description V V A one to two sentence description of your object.
og:image V V An image URL which should represent your object within the graph
og:image:alt V The images alt text
og:image:description ☑️V Another way to enter alt text
og:image:secure_url ☑️V An HTTPS url for the image
og:video 🚫1 A URL to a video file that complements this object
og:video:url 🚫 ☑️ The URL of the video
og:video:secure_url 🚫 ☑️ An HTTPS url for the video
og:audio 🚫 ☑️ A URL to an audio file to accompany this object.
og:audio:url 🚫 ☑️ The URL of the audio
og:audio:secure_url 🚫 An HTTPS url for the audio
og:determiner 🚫 🚫 The word that appears before this object's title in a sentence. An enum of (a, an, the, "", auto). If auto is chosen, the consumer of your data should choose between "a" or "an". Default is "" (blank).
og:url 🚫 🚫 The canonical URL of your object that will be used as its permanent ID in the graph, e.g., "https://www.imdb.com/title/tt0117500/".

[1] Will only show a static image thumbnail


Twitter Summary Cards


Tag Discord Fluxer Telegram Purpose
twitter:card 🚫 summary, summary_large_image, app, player
twitter:creator 🚫 The creator of the content (eg @daisy.universe.dog)
twitter:title ☑️ The title of the content
twitter:description ☑️ ☑️ A description that concisely summarizes the content.
twitter:site:name Same as Provider name, eg 'Bluesky'
twitter:image ☑️ An image URL
twitter:image:src ☑️ An image URL
twitter:image:alt ☑️ The alt text of the image
twitter:video ☑️ A Video URL
twitter:video:url ☑️ A Video URL
twitter:player 🚫1 A Video URL (i think)
twitter:player:stream 🚫 A Video URL (i think)
twitter:player:height 🚫 The intended height of the player
twitter:player:width 🚫 The indented width of the player
twitter:player:stream:content_type 🚫 The MIME type of the meda, ie 'video/mp4'

[1] Including any tags with this breaks the entire embed


oEmbed


Tag Discord Fluxer Telegram Purpose
type 🚫 photo, video, link, rich
version The oEmbed version number. This must be 1.0.
title ☑️ A text title, describing the resource.
author_name V V The name of the author/owner of the resource.
author_url V V A URL for the author/owner of the resource.
provider_name V ☑️ The name of the resource provider.
provider_url V ☑️ The url of the resource provider.
thumbnail_url V 🚫 A URL to a thumbnail image representing the resource.
thumbnail_height 🚫 🚫 The height of the optional thumbnail
thumbnail_width 🚫 🚫 The width of the optional thumbnail
cache_age 🚫 🚫 🚫 The suggested cache lifetime for this resource, in seconds.
width 🚫 The width of the embed. Sometimes respected
height 🚫 The width of the embed. almost never respected
url V 🚫 the URL of the resource (when using photo or video type)
html 🚫 🚫 🚫 The HTML required to display a rich resource (ie iframe)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment