Saltar a contenido

GraphQL

POST /graphql

Una API GraphQL junto a la API REST /v1, para clientes que quieren elegir los campos que reciben o combinar varias solicitudes en un solo viaje de ida y vuelta. Se apoya en los mismos servicios que REST: la misma caché, la misma agrupación de solicitudes, validación y feature flags, y los mismos datos.

Está activada por defecto; desactívala con ENABLE_GRAPHQL=false, tras lo cual /graphql responde 404.

Endpoint

Método Qué
POST /graphql {"query": …, "variables": {…}, "operationName": …} como JSON, o un array JSON de tales objetos (un lote, consulta Límites)
GET /graphql En un navegador: el IDE GraphiQL. Con ?query=…&variables=…: ejecuta una consulta

GraphiQL, las consultas GET y la introspección del esquema están disponibles mientras la documentación esté activada; DISABLE_DOCS=true desactiva las tres (POST sigue funcionando).

curl http://localhost:8000/graphql \
  -H 'content-type: application/json' \
  -d '{"query": "{ body(channel: \"durov\") { channel { title { string } } } }"}'
{"data": {"body": {"channel": {"title": {"string": "Pavel Durov"}}}}}

Esquema

Cada campo raíz refleja un endpoint REST y devuelve los mismos datos:

Campo Devuelve Equivalente REST
body(channel, position) ChannelBody GET /v1/body/{channel}
more(channel, direction, position) More GET /v1/more/{channel}/{direction}/{position}
post(channel, id) Post GET /v1/post/{channel}/{identifier}
preview(channel) Preview GET /v1/preview/{channel}
previews(channels) [Preview!] POST /v1/previews
feed(channels) [FeedPost!] POST /v1/feed

Los nombres de los campos son las claves JSON de REST (preview_link, to_message, is_verified, …), así que un resultado de GraphQL es exactamente la respuesta REST sin los campos que no pediste. Por eso los modelos de datos describen ambas API. Las diferencias:

  • preview y previews devuelven objetos Preview con un campo adicional username; previews es una lista (en el orden de la solicitud, sin los canales desconocidos) en lugar de un mapa indexado por nombre de usuario.
  • Los elementos del feed son FeedPost: un Post más channel, score, duplicates y topic (en REST: _score, _duplicates, _topic). duplicates es una lista vacía cuando no se agrupó nada en la publicación. Los campos ausentes de los datos sin procesar del feed valen null.
  • Los conjuntos fijos de cadenas son enums cuyos valores son las cadenas de REST: MediaType (image, video, …), EntityType, ReactionType y Direction (after, before).
  • Date.unix es un escalar Long: un entero JSON simple que puede superar los 32 bits.

Los campos raíz admiten nulos: cuando uno falla vale null con una entrada en errors, y los demás campos de la operación siguen devolviendo datos.

Esquema completo (SDL)
"""Channel profile shown above the posts."""
type Channel {
  username: String!
  title: ParsedAndRaw!
  description: ParsedAndRaw
  avatar: String
  counters: Counters!

  """Header labels, e.g. `verified`."""
  labels: [String!]
}

"""Channel page: profile, posts and pagination."""
type ChannelBody {
  channel: Channel!
  content: ChannelContent!
  meta: Meta!
}

"""Posts of a channel page."""
type ChannelContent {
  posts: [Post!]!
}

"""Channel statistics as formatted by Telegram (e.g. `1.2M`)."""
type Counters {
  subscribers: String!
  photos: String
  videos: String
  files: String
  links: String
}

"""Publication time."""
type Date {
  """ISO-8601 timestamp with offset."""
  string: String!
  unix: Long!
}

"""Load posts newer (`after`) or older (`before`) than a position."""
enum Direction {
  after
  before
}

"""Duration of time-based media."""
type Duration {
  """Human-readable value as shown by Telegram, e.g. `2:30`."""
  formatted: String!

  """Duration in seconds."""
  raw: Int!
}

"""Kind of a formatting entity."""
enum EntityType {
  italic
  bold
  code
  spoiler
  strikethrough
  underline
  text_link
  url
  pre
  emoji
  animoji
  hashtag
}

"""A repost of the same story collapsed into a feed post."""
type FeedDuplicate {
  channel: String
  id: Int!
}

"""A feed post with its channel and ranking score."""
type FeedPost {
  id: Int!
  content: PostContent!
  footer: Footer!
  forwarded: Forwarded
  view: String!
  channel: Channel!

  """
  Ranking score (`_score` in the REST feed); the feed is in ranking order.
  """
  score: Float!

  """Reposts of the same story collapsed into this post (`_duplicates`)."""
  duplicates: [FeedDuplicate!]!

  """Subject shared with other feed posts (`_topic`), if any."""
  topic: FeedTopic
}

"""Subject a feed post shares with other posts of the feed."""
type FeedTopic {
  id: Int!
  keywords: [String!]!
}

"""Post metadata shown under the message."""
type Footer {
  views: String
  edited: Boolean
  author: ParsedAndRaw
  date: Date!
}

"""Original source of a forwarded post."""
type Forwarded {
  """Source channel or user; empty when the sender hides their name."""
  name: ParsedAndRaw!
  url: String

  """Signature shown next to the source, e.g. the bot that posted it."""
  author: ParsedAndRaw
}

"""Inline keyboard button."""
type InlineButton {
  title: String!
  url: String!
}

"""Integer that may exceed 32 bits (Unix timestamps)."""
scalar Long

"""Media attachment of a post."""
type MediaItem {
  url: String
  thumb: String

  """Encoded waveform of a voice message."""
  waves: String
  duration: Duration
  type: MediaType!

  """False when Telegram does not expose the file (e.g. a too large video)."""
  available: Boolean
}

"""Kind of a media attachment."""
enum MediaType {
  image
  video
  voice
  roundvideo
  sticker
  gif
}

"""Pagination metadata."""
type Meta {
  offset: Offset!
}

"""A batch of posts loaded relative to a position."""
type More {
  posts: [Post!]!
  meta: Meta!
}

"""Pagination cursors: pass them to `more` to load adjacent posts."""
type Offset {
  before: Int
  after: Int
}

"""Text in two forms: plain `string` and the original `html`."""
type ParsedAndRaw {
  string: String!
  html: String!
}

"""Poll attached to a post."""
type Poll {
  question: String!
  type: String
  votes: String
  options: [PollOption!]!
}

"""One answer option of a poll."""
type PollOption {
  name: String!
  percent: Int!
}

"""A channel post."""
type Post {
  id: Int!
  content: PostContent!
  footer: Footer!
  forwarded: Forwarded
  view: String!
}

"""Everything a post can contain."""
type PostContent {
  text: Text
  media: [MediaItem!]
  poll: Poll
  inline: [InlineButton!]
  reply: Reply
  preview_link: PreviewLink
  reacts: [Reaction!]
}

"""Channel preview."""
type Preview {
  """The requested channel username."""
  username: String!
  channel: PreviewChannel
}

"""Channel summary from the `t.me/<channel>` landing page."""
type PreviewChannel {
  title: String!
  subscribers: String!
  description: String
  avatar: String
  is_verified: Boolean!
}

"""Link preview card generated by Telegram."""
type PreviewLink {
  title: String
  url: String!
  site_name: String
  description: ParsedAndRaw
  thumb: String
}

"""Read-only access to public Telegram channels."""
type Query {
  """
  Channel profile, its latest posts (or posts around `position`) and pagination. REST: `GET /v1/body/{channel}`.
  """
  body(
    """Channel username, e.g. `durov`."""
    channel: String!

    """Open the page around this post ID."""
    position: Int = null
  ): ChannelBody

  """
  Posts newer or older than `position` (anchor excluded); use `meta.offset` to keep paginating. REST: `GET /v1/more/{channel}/{direction}/{position}`.
  """
  more(
    """Channel username, e.g. `durov`."""
    channel: String!
    direction: Direction!

    """Anchor post ID."""
    position: Int!
  ): More

  """A single post. REST: `GET /v1/post/{channel}/{identifier}`."""
  post(
    """Channel username, e.g. `durov`."""
    channel: String!

    """Post ID."""
    id: Int!
  ): Post

  """
  Short channel summary: title, subscribers, description, avatar. REST: `GET /v1/preview/{channel}`. Requires `ENABLE_PREVIEWS`.
  """
  preview(
    """Channel username, e.g. `durov`."""
    channel: String!
  ): Preview

  """
  Previews of several channels, fetched in parallel; unknown channels are omitted. REST: `POST /v1/previews`. Requires `ENABLE_PREVIEWS`.
  """
  previews(
    """1 to 100 channel usernames."""
    channels: [String!]!
  ): [Preview!]

  """
  Posts of several channels ranked by score, best first. REST: `POST /v1/feed`. Requires `ENABLE_FEED`.
  """
  feed(
    """1 to 100 channel usernames."""
    channels: [String!]!
  ): [FeedPost!]
}

"""One reaction counter of a post."""
type Reaction {
  count: String!
  type: ReactionType!
  emoji: String
  emoji_id: String
  emoji_image: String
}

"""Kind of a reaction."""
enum ReactionType {
  telegram_stars
  emoji
  custom_emoji
}

"""The message this post replies to."""
type Reply {
  cover: String
  name: ParsedAndRaw!
  text: ParsedAndRaw!
  to_message: Int!
}

"""Post text with optional formatting entities."""
type Text {
  string: String!
  html: String!
  entities: [TextEntity!]
}

"""Formatting entity inside a post text; offsets refer to `string`."""
type TextEntity {
  offset: Int!
  length: Int!
  type: EntityType!
  language: String
  url: String
}

Ejemplos

Los ID de las últimas publicaciones, sus visualizaciones y el cursor de paginación de un canal:

{
  body(channel: "durov") {
    content { posts { id footer { views date { unix } } } }
    meta { offset { before } }
  }
}

La página siguiente, con variables:

query Older($channel: String!, $before: Int!) {
  more(channel: $channel, direction: before, position: $before) {
    posts { id content { text { string } } }
    meta { offset { before } }
  }
}
{"channel": "durov", "before": 531}

Varios canales en una sola solicitud, con alias y un fragmento:

{
  durov: body(channel: "durov") { ...Latest }
  telegram: body(channel: "telegram") { ...Latest }
  previews(channels: ["durov", "telegram"]) {
    username
    channel { title subscribers is_verified }
  }
}

fragment Latest on ChannelBody {
  channel { title { string } }
  content { posts { id content { media { type url thumb } } } }
}

Un feed clasificado (requiere ENABLE_FEED):

{
  feed(channels: ["durov", "telegram", "stranaua"]) {
    id
    score
    channel { username }
    content { text { string } }
  }
}

Un lote de dos operaciones en una sola solicitud HTTP:

[
  {"query": "{ post(channel: \"durov\", id: 531) { view } }"},
  {"query": "{ preview(channel: \"telegram\") { channel { title } } }"}
]

La respuesta es un array con un resultado por operación, en orden.

Eficiencia

  • Cada página se descarga como mucho una vez por solicitud HTTP: los campos raíz iguales (alias, fragmentos repetidos, las operaciones de un lote) comparten un resultado, y los campos que necesitan la misma página al mismo tiempo (body(position: 531) y post(id: 531)) comparten una solicitud al origen.
  • Los campos raíz se resuelven de forma concurrente; previews y feed descargan sus canales en paralelo (siempre dentro del límite de TELEGRAM_MAX_CONCURRENCY).
  • Las respuestas provienen de las mismas cachés que REST (consulta Actualidad de los datos), así que combinar ambas API no duplica la carga sobre t.me. Las consultas GET que llegan desde Cloudflare reciben el mismo Cache-Control que las respuestas REST.

Límites

Cada operación se comprueba antes de descargar nada. Todos los límites son configurables:

Ajuste Por defecto Rechaza
GRAPHQL_MAX_DEPTH 10 Selecciones anidadas a mayor profundidad que esta por debajo de un campo raíz (campo raíz = 0, salvo la introspección)
GRAPHQL_MAX_COMPLEXITY 1500 Operaciones cuyo coste supera este valor
GRAPHQL_MAX_ALIASES 15 Documentos con más alias
GRAPHQL_MAX_TOKENS 2000 Documentos con más tokens que este valor
GRAPHQL_MAX_BATCH_SIZE 10 Lotes con más operaciones (400 Too many operations)

El coste es 1 por cada campo seleccionado (con los fragmentos expandidos) más 10 por cada canal que un campo raíz puede descargar: 10 para body, more, post y preview, y 10 × el número de canales para previews y feed (la longitud real de la lista, también cuando se pasa como variable). Un feed sobre el máximo de 100 canales cuesta unos 1 000 + sus campos, así que cabe en el límite por defecto; dos de ellos no.

Los límites se aplican a cada operación de un lote. El límite de solicitudes (ENABLE_RATE_LIMIT) cuenta solicitudes HTTP, así que un lote cuenta una sola vez.

Errores

Los errores de GraphQL se devuelven con HTTP 200 en la lista errors, cada uno con un extensions.code estable:

{
  "data": {"body": null},
  "errors": [
    {
      "message": "Requested Telegram resource was not found",
      "locations": [{"line": 1, "column": 3}],
      "path": ["body"],
      "extensions": {"code": "NOT_FOUND"}
    }
  ]
}
extensions.code Estado REST Causa
BAD_INPUT 422 Nombre de usuario no válido, ID/posición de publicación fuera de 1..10000000, o no entre 1 y 100 canales
NOT_FOUND 404 El canal o la publicación no existe (o es privado)
BAD_GATEWAY 502 t.me respondió con una página que el parser no pudo interpretar
UNAVAILABLE 503 t.me no está accesible o respondió con un error
FEATURE_DISABLED 503 preview/previews con ENABLE_PREVIEWS=false, feed con ENABLE_FEED=false
QUERY_TOO_COMPLEX — La operación cuesta más que GRAPHQL_MAX_COMPLEXITY
GRAPHQL_PARSE_FAILED — Error de sintaxis, o más de GRAPHQL_MAX_TOKENS tokens
GRAPHQL_VALIDATION_FAILED — Campo o argumento desconocido, tipo incorrecto, límite de profundidad/alias, introspección mientras está desactivada
INTERNAL_SERVER_ERROR 500 Error inesperado del servidor; el mensaje se enmascara y los detalles se registran en el log

Igual que en REST, la entrada se valida antes de comprobar las feature flags, así que los argumentos no válidos dan BAD_INPUT incluso mientras una función está desactivada. Los errores de las filas BAD_INPUT…FEATURE_DISABLED son errores de ejecución: solo anulan su propio campo. Los demás se producen antes de la ejecución y devuelven "data": null.

Las solicitudes que no son GraphQL en absoluto se rechazan con un estado HTTP: 400 para un cuerpo que no es JSON, que no tiene query o que es un lote demasiado grande; 400 para las consultas GET mientras la documentación está desactivada; 429 cuando se aplica el límite de solicitudes.