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 } } } }"}'
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:
previewypreviewsdevuelven objetosPreviewcon un campo adicionalusername;previewses 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: unPostmáschannel,score,duplicatesytopic(en REST:_score,_duplicates,_topic).duplicateses una lista vacía cuando no se agrupó nada en la publicación. Los campos ausentes de los datos sin procesar del feed valennull. - Los conjuntos fijos de cadenas son enums cuyos valores son las cadenas de
REST:
MediaType(image,video, …),EntityType,ReactionTypeyDirection(after,before). Date.unixes un escalarLong: 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 } }
}
}
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)ypost(id: 531)) comparten una solicitud al origen. - Los campos raíz se resuelven de forma concurrente;
previewsyfeeddescargan sus canales en paralelo (siempre dentro del límite deTELEGRAM_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
GETque llegan desde Cloudflare reciben el mismoCache-Controlque 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.