Changelog¶
All notable changes to this project are documented here. The format follows Keep a Changelog and the project uses Semantic Versioning.
Unreleased¶
Fixed¶
- The Nightly smoke test of production through the public URL accepts
Cloudflare's block of its invalid requests too (
--edge-blocks-invalid), like the deployment's since 2.2.1 (#231).
Security¶
- RUSTSEC-2026-0391, -0398 and -0403 (
zstd0.13,zstd-safe7) are listed as not applicable indeny.toml. Onlywreq-util0.2 pulls those versions, for TLS certificate decompression in its Firefox profiles, and the client accepts Chromium profiles only. Responses are decoded withzstd0.14.3, which is not affected. The ignores go oncewreq-utilmoves tozstd>= 0.14.3. - The same crates carry ten more advisories marked unsound
(RUSTSEC-2026-0395 to -0406), which OpenSSF Scorecard reports through OSV.
osv-scanner.tomlnext to eachCargo.locklists all thirteen with that reason andignoreUntil2027-01-26, and a test keeps it in step withdeny.toml.
2.2.1 - 2026-10-26¶
Fixes for running behind Cloudflare: a health check that is never cached,
a deploy smoke test that understands Cloudflare schema validation, and a rate
limit that leaves the server's own requests alone, so its burst can be small.
GraphQL errors now come in a stable order. The /v1 and GraphQL contracts are
unchanged.
Changed¶
- The rate limit no longer counts requests from the server itself: over
loopback and not forwarded by a proxy, such as health checks and the deploy's
smoke test. A smaller
RATE_LIMIT_BURSTno longer fails the smoke test of a deploy. A forwardedX-Forwarded-For: 127.0.0.1still counts.
Fixed¶
/healthzanswers withCache-Control: no-store. Behind Cloudflare it got the default edgeCache-ControlofCACHE_MODE, so with a cache rule a health check could read a cached "ok".- The deploy's smoke test through the public address accepts Cloudflare's
firewall block (
403, error 1020) for its invalid requests as well as the API's422(smoke_matrix.py --edge-blocks-invalid). Cloudflare schema validation rejected them at the edge, and that rolled back a healthy release. Any other403still fails the test. - GraphQL errors are listed in the order their fields appear in the query. Before, they came in the order the resolvers finished, so one query could return them in a different order each time.
2.2.0 - 2026-10-25¶
Every instance now has a landing page at /, the API reference at /api, and
its own copy of the documentation at /docs in three languages, with
robots.txt and a sitemap. Requests to t.me present a current Chrome 156 and
keep t.me's cookie like a browser. The /v1 and GraphQL contracts are
unchanged.
Added¶
- A public face for the instance.
/is a landing page now: what the service is, a Try it box forGET /v1/body/{channel}with the response, the instance's health, version and features, and links to the docs, the API reference, GraphQL and GitHub. Static HTML, CSS and a little JS from the package, light and dark, without a framework or anything loaded from elsewhere (CSP'self').- The documentation is served at
/docs/(/docs/es/,/docs/ru/). The image ships it, built for that prefix byscripts/build_docs.py(DOCS_SITE_DIR=/app/docs, about 8 MB more image), so it needs no proxy and always matches the running version. Without a build/docs/…redirects to GitHub Pages. /robots.txtand/sitemap.xml: every docs page in all three languages withhreflangalternates, generated from the zensical navs at build time, on the newPUBLIC_URLsetting (the deploy sets it fromPUBLIC_URLorPUBLIC_HOST).- With
ENABLE_POWthe browser solves the proof of work, for the landing page and for Swagger's Try it out, on Web Workers with a synchronous SHA-256 (difficulty 16 in about half a second).
Changed¶
- Swagger UI moved from
/to/api;/openapi.jsonstays. Old deep links (/#/Channel/...) are forwarded to/apiby the landing page.DISABLE_DOCShides the new pages as well, and/robots.txtthen disallows everything. The smoke matrix checks the new pages. - Responses that set their own
Cache-Control(docs, static files, rejections) keep it behind Cloudflare; theCACHE_MODEedge default only fills in the others.
Fixed¶
- The landing page's proof-of-work worker checks its task: a difficulty above 32 used to search forever, and malformed input now fails at once. It also ignores messages from another origin (CodeQL: missing origin verification).
- The docs' own sitemaps (
/docs/sitemap.xml,/docs/es/…,/docs/ru/…) carried root-relative locations, which Google Search Console rejects (33 errors). The instance now serves them with absolute URLs for its public address; the docs theme navigates with them as before.
2.1.0 - 2026-10-25¶
Requests to t.me now go out with one browser's TLS, HTTP/2 and headers. Nodes
on a private network can share their work without Redis (optional). Redis
connections hold up at a distance. Every release is gated by smoke tests, a
canary and fleet rollback. The /v1 and GraphQL contracts are unchanged.
Added¶
- Browser-consistent HTTP client for t.me (
TELEGRAM_CLIENT=browser, the new default). It is native: wreq on BoringSSL, intelegram-me-native. One profile (TELEGRAM_BROWSER_PROFILE=chrome_156onTELEGRAM_BROWSER_PLATFORM=android) sets the TLS handshake, the HTTP/2 settings and the headers, so they all describe the same browser. - JA4 and the Akamai HTTP/2 fingerprint change from rustls/hyper
(
t13d1011h2_61a7ad8aa9b6_…,m,s,a,p) to Chrome's (t13d1516h2_8daaf6152771_…,52d84b11…); seedocs/internals/http-client.md. - The "load more" call goes out as the page's XHR (
sec-fetch-mode: cors,origin,referer, Chromium's header order), not as a navigation. TELEGRAM_CLIENT=reqwestkeeps pyreqwest; it is not used in production.scripts/fingerprint.pymeasures both clients against a TLS echo service.- Our own profile of the current stable Chrome,
chrome_156, is the default; the wreq-util profiles (newestchrome_149) stay selectable. It claims Chrome 156 in theUser-Agentandsec-ch-ua(whose GREASE brand is derived from the version as Chromium does), sendsimage/jxlinacceptas Chrome 155+ does, and the HTTP/2HEADERSpriority weight 256 that Chrome sends (wreq-util sends 220). The TLS handshake is unchanged: the BoringSSL that wreq builds can not offer the ML-DSA signature algorithms (it rejects them), a GREASE signature algorithm or thetrust_anchorsextension that Chrome 155 sends; seedocs/internals/http-client.md. - Cookies like a browser: the client keeps the cookie t.me sets
(
stel_ssid) and sends it back,cookieplaced where Chromium puts it. Each worker has one browser identity (jar, connections, TLS tickets), renewed afterTELEGRAM_SESSION_LIFETIME(new, default 4 hours ±25 %), when its cookies expire, or after a 4xx other than 404. Cookies stay in the native client: never in a response, a log or the cache. - Peer layer (
ENABLE_PEERS, off by default): nodes on a private network share their work directly, groupcache-style, with or without Redis. See Deployment → Peer layer. - Each channel's keys have one owner among the live nodes (rendezvous hashing). Only the owner fetches and renders them; on a miss the other nodes ask it and keep its answer for the remaining TTL. With peers up this replaces the Redis lease.
- The owner answers cache reads natively, without Python: 0.18 ms for a 56 KB page, against 95 ms through a Redis 90 ms away.
- Membership is SWIM gossip (
foca) over UDP; requests use one multiplexed TCP connection per peer. Nodes find each other throughPEERS(addresses or DNS names) and the Redis heartbeat markers, which now carry"peer". - Every frame is authenticated (HMAC-SHA256 with
PEER_SECRET, timestamp, nonce against replays) and optionally encrypted (PEER_ENCRYPTION, XChaCha20-Poly1305); peers outsidePEER_ALLOW_CIDRare refused. - A slow or unreachable peer never fails a request: after
PEER_TIMEOUT(50 ms) the node does the work itself. - Without Redis, nodes share rate-limit counts and feed popularity, and the live node with the lowest address runs the feed refresher.
- New settings:
ENABLE_PEERS,PEERS,PEER_PORT,PEER_ADVERTISE_ADDR,PEER_ALLOW_CIDR,PEER_SECRET,PEER_ENCRYPTION,PEER_TIMEOUT. deploy/bootstrap-server.shopens the peer ports to the private network (PEER_CIDR); the deploy workflow enables the layer with theENABLE_PEERSvariable and thePEER_SECRETsecret.- The standalone feed worker runs with
ENABLE_PEERSinstead ofREDIS_URLas well. - Stricter checks on every commit and pull request (
make lint,make audit,make image-scan, all inmake check): clippy restriction lints and no panicking slices in the parser; cargo-deny and cargo-machete; more mypy error codes and ruff rule families; import-linter layer contracts; vulture;uv audit; gitleaks; zizmor; hadolint; typos; trivy on the built image; dependency review on pull requests; OpenSSF Scorecard. REDIS_CONNECT_TIMEOUT(default 2 s): seconds to open a Redis connection, TLS handshake included.- Deployment gates. Every server runs the smoke test (status codes and
content) inside the new container after its health check and goes back to
the previous image when it fails. Server 1 is a canary: it serves real
traffic for
CANARY_SOAKseconds and is verified again before the other servers follow. The rollout ends with the smoke test through the public URL (PUBLIC_URLorhttps://PUBLIC_HOST, withCF_BYPASS_KEY), and a failure after the first server rolls back every server that took the release (deploy/rollout.sh,remote-deploy.sh --verifyand--rollback). Manual deploys can skip the smoke test while t.me is down. - CI runs the built image as two servers with two workers each and a real
Valkey (
tests/integration): shared cache, cross-process fetch lease, shared rate limit, proof of work, a rolling upgrade from the release in production and a Valkey outage.deploy/remote-deploy.shis tested with real Docker Compose (upgrade, unhealthy and broken releases, rollback, the Caddy proxy) and the fleet logic with stub servers (tests/deploy). - Guards workflow: breaking changes of the REST (OpenAPI) and GraphQL
contract against the base of a pull request fail it unless it is labelled
breaking-change(scripts/api_contract.py,make contract); coverage floors may only go up (scripts/coverage_floor.py). - Coverage floors in CI: Python 92.5 % (
fail_under), Rust 96 % lines (cargo llvm-cov). - Property-based tests (hypothesis): no 500 whatever t.me returns, the
protection middleware on any input, feed invariants. Hostile-HTML tests of
the Rust parsers (mutated pages, deep nesting, linear parse time) and a
cargo fuzztarget. - Nightly workflow: the production image against live t.me, production
through the public URL, 10 minutes of fuzzing and long property runs; a
failure opens an issue labelled
nightly. - The smoke matrix checks the content of the main responses, not only status codes, and uses recorded pages only, so it also runs against a fake t.me.
TELEGRAM_BASE_URL: where t.me pages are fetched from (a mirror, or a fake t.me in tests).- Redis heartbeat (
REDIS_HEARTBEAT_INTERVAL, default 5 s). Every worker writes its markertme:v2:node:<host>:<pid>with a TTL of three intervals. - It keeps a connection to Redis open.
- It notices outages within one interval.
- It shows which processes are running.
- It probes while the circuit breaker is open: the breaker closes as soon as Redis answers again, instead of after the full 30 s cooldown.
Changed¶
- Request headers to t.me come from the browser profile for both clients,
not from a hand-written Chrome 141 set: Chrome 149 values, Chrome's header
order and a quoted
sec-ch-ua-platform.cache-controlanddnt, which Chrome does not send on a first visit, are gone. - The Docker build stage needs cmake, clang and git for BoringSSL. A cold build takes about 75 s longer on native arm64, and about 11 min longer for arm64 under QEMU, as the release workflow builds it. The runtime image grows by 3–7 MB.
- The image runs as
USER 10001:10001(the sameappuser, by number). - The image's health check probes every 2 s during the first 30 s, so a new container is healthy (and a deployment done) within seconds instead of 30 s.
- The Redis circuit breaker opens after three failures in a row within
10 seconds instead of after the first one. Any command that reaches Redis
resets the count. A single slow TLS connect to a distant Redis, or one lost
packet, no longer takes the shared cache, the shared rate limit and the
cross-server lease offline for 30 seconds. Failures below the threshold are
logged at
INFO. - Release images build each platform on a runner of its own architecture
(
ubuntu-24.04-armforlinux/arm64) instead of arm64 under QEMU, and merge the per-platform digests into one manifest list with the same tags, provenance and SBOM. Each platform has its own build cache.
Fixed¶
- Redis no longer keeps dropping out when it is far away. Opening a
connection used the 0.5 s command timeout, and a TLS connection to a Redis
90 ms away takes 0.3–0.7 s. After any error, every reconnect timed out and
reopened the circuit breaker, which bypassed Redis for 30 s every 30–60 s in
production. Connections now get
REDIS_CONNECT_TIMEOUT(default 2 s), and each worker opens its first connection at startup. - Deeply nested markup (tens of thousands of levels) no longer overflows the stack while a post is serialized, which aborted the worker process.
- The nightly fuzzing job builds for the host target. The prebuilt cargo-fuzz defaulted to musl, which the nightly toolchain lacks (issue #217).
Security¶
- ctutils 0.4.5 (RUSTSEC-2026-0385, non-constant-time comparisons under the proof-of-work HMAC). The crate declared Rust 1.85, which kept the resolver on the vulnerable release; it now declares 1.99, what it is built with.
- The image no longer contains pip, whose vendored urllib3, msgpack and setuptools had fixable high-severity advisories; it was never used at run time.
- The deploy workflow passes only the secrets it needs to the reusable deployment, reads repository variables from the environment instead of expanding them into the script, and keeps no git credentials after checkout.
- The Dockerfile's base images (uv, Rust, Python) are pinned by digest as well
as by tag; Dependabot updates both. OpenSSF Scorecard flagged the tag-only
FROMlines (Pinned-Dependencies).
2.0.0 - 2026-10-24¶
A rewrite of the internals with the same /v1 API. Existing clients keep
working; see the upgrade guide for the few behaviour changes.
Changed¶
POST /v1/feedis disabled by default. Enable it withENABLE_FEED=true.POST /v1/previewsreturns the channels it could resolve and omits unknown ones, instead of failing the whole request with404.- Pages Telegram serves in an unexpected format answer
502instead of400. - A single malformed post is skipped (and logged) instead of failing the page.
- The server is granian (Rust) started by
telegram-me serve; the image no longer usesstart.sh. TheServerheader is nowgranian. REDIS_URLhas no default; without it an in-process cache is used.- Cache keys moved to the
tme:v2:prefix. - The license is now Apache-2.0 (was GPL-3.0).
NOTICEstates that the project is not affiliated with Telegram and that returned content belongs to its authors. - The feed is ranked natively in Rust (
telegram-me-native, PyO3): engagement relative to each channel, freshness half-life, duplicate stories collapsed across channels, topics inferred from the texts and diversity re-ranking._scorevalues and the feed order differ from 1.x. - The rate limit is native (Rust) and shared. It uses GCRA (a burst of
RATE_LIMIT_BURST, thenRATE_LIMIT_RPMper minute) instead of a Python sliding window. With Redis, a client is counted across all workers and servers without requests ever waiting for Redis. Retry-Afteris now the actual wait instead of a fixed 60 s.- The
429body is JSON ({"detail": "Rate limit exceeded"}). - CORS preflights are no longer counted, and
429responses carry CORS headers.
Added¶
- GraphQL API at
/graphqlnext to REST/v1, so clients can select only the fields they need: the same data and services as REST, per-request page memoization, batching, GraphiQL, stableextensions.codeerrors and configurable depth, cost, alias, token and batch limits. Enabled by default (ENABLE_GRAPHQL); see the GraphQL API docs. /v1/feeditems may carry_duplicates(collapsed reposts) and_topic(shared subject with keywords).forwarded.author: the signature Telegram shows next to a forward's source.- Stale-while-revalidate page cache for the feed, parallel fetching of missing
channels, popularity ranking with decay and a background refresher (embedded
or
telegram-me worker) with Redis leader election. - Cache of rendered responses for
/v1/bodyand/v1/more; coalescing of identical concurrent requests to t.me; a global cap on concurrent upstream requests (TELEGRAM_MAX_CONCURRENCY). - Circuit breaker and short timeouts for Redis (
REDIS_TIMEOUT); compressed in-memory cache bounded by real memory (MEMORY_CACHE_MAX_MB); 60 s cache of unknown channels in previews. - New settings:
HOST,WEB_CONCURRENCY,FORWARDED_ALLOW_IPS,ACCESS_LOG,GRACEFUL_TIMEOUT,WORKERS_MAX_RSS_MB,METRICS_PORT(Prometheus),CORS_ALLOW_ORIGINS,TELEGRAM_TIMEOUT,PREVIEW_CACHE_TTL, theFEED_*options,MEMORY_TRIM_INTERVAL,LOG_LEVEL,LOG_FORMAT(JSON logs). compose.yamlwith a bundled Valkey cache, an optional worker andstandard/full/minimalpresets.- Servers can sit straight behind Cloudflare: with the
PUBLIC_HOSTvariable every server also runs Caddy on port 443 (Cloudflare Origin CA certificate from theTLS_CERT/TLS_KEYsecrets, or a self-signed one) and the API only listens on localhost.deploy/bootstrap-server.shprepares a fresh Ubuntu server (Docker, swap, Oracle Cloud firewall). Deployment logs mask the server addresses. - Rolling multi-server deployments with automatic rollback, migration from the 1.x container, manual rollback by tag; multi-arch images with SBOM and provenance on GHCR and Docker Hub.
- Documentation site on GitHub Pages.
- Offline test suite with recorded t.me pages and contract snapshots of every endpoint, strict mypy, ruff, pre-commit.
- Optional proof of work (
ENABLE_POW). Each request carries anX-PoW: <challenge>:<nonce>header, and its SHA-256 must start withPOW_DIFFICULTYzero bits (18 by default). - Challenges come from
GET /v1/pow/challengeor any rejection. They are stateless and HMAC-signed (POW_SECRET), bound to the client IP (POW_BIND_IP), and reusable until they expire (POW_CHALLENGE_TTL). - Each solution is tied to its method and target, and accepted once.
- Rejections answer
428,400or403with a fresh challenge. - Verification is native (Rust) and takes under a microsecond.
RATE_LIMIT_BURSTandRATE_LIMIT_SHARED.scripts/audit.py: wall time, CPU time and Python allocations per endpoint, an event-loop stall test, the cost of each stage of the cold page pipeline and the memory of a feed loop. The findings and the plan for the rest of the hot path are indocs/internals/rust-roadmap.md.- The contract snapshots also pin every REST response byte for byte
(
tests/snapshots/raw_digests.json), and a synthetic feed covers duplicates and malformed pages. UPSTREAM_QUEUE_LIMIT(default 16 ×TELEGRAM_MAX_CONCURRENCY) andMAX_WAITING_REQUESTS(default 4096): per-worker bounds beyond which requests are shed with503.SHARED_COALESCING(defaulttrue): the cross-process fetch lease.EARLY_REFRESH_WINDOW(default0.1): how close to expiry popular entries are renewed in the background;0disables it.scripts/coalescing_load.py: duplicate-heavy load over several worker processes sharing one Redis.MEMORY_CACHE_MAX_TTL(default 10 s): with Redis, the longest a worker keeps its own copy of an entry, which bounds how late it sees an entry another server or the feed worker wrote.
Fixed¶
- Post IDs above 999 999 were rejected with
422. - Inline keyboards lost every row after the first.
forwarded.name.htmlcontained a broken<span>fragment.- With Redis unreachable every preview request took 10 seconds.
- A few concurrent feed or previews requests could exhaust the Redis
connection pool, which opened the circuit breaker and sent every request to
t.me for 30 seconds. Bursts now wait briefly for a pooled connection
(
2 × TELEGRAM_MAX_CONCURRENCYper worker), and an exhausted pool no longer counts as a Redis outage. - The comment count used in feed ranking was never found, while costing one extra request to t.me per post (up to ~2 000 per feed request); the dead lookup was removed.
- Containers ignored
SIGTERM(the server was not PID 1):docker stopwaited 10 seconds and killed in-flight requests. It now takes well under a second. - Deployments started containers without a restart policy, so servers did not recover after a reboot.
- The rate limiter kept every client IP forever.
- Invalid input to a disabled feature now still answers
422. - A feed refresher tick over ~200 channels stalled the event loop for about a second (pages were parsed and validated on it), long enough for a Redis command to time out and open the circuit breaker for 30 s. Pages are now parsed, validated and rendered on two dedicated threads, and the refresher refreshes at most 16 channels at a time: worst event-loop lag under load ~1 s → about 30 ms (rarely ~120 ms), no breaker trips.
Performance¶
- Outgoing requests use one pooled HTTP/2 client in Rust (pyreqwest) instead of a new httpx client per request: about 9× less CPU per request.
- HTML is parsed once per page instead of twice; responses are validated once and serialized by pydantic-core.
- t.me pages are parsed in Rust (
telegram-me-native: html5ever, selectors, regex) with the GIL released, instead of Python over selectolax. The output is byte-for-byte identical to the Python parser; it is 1.3–2× faster per page and runs in parallel under load. - Memory: cache values are compressed UTF-8 bytes counted at their real size
(a 64 MB cache used to grow to ~260 MiB), workers return freed heap memory
to the OS (
malloc_trim), and the server process no longer imports the application. One worker: ~110 MiB idle instead of ~150; under heavy traffic ~230 instead of ~460 MiB with the memory cache and ~155 instead of ~300 MiB with Redis. - Cached
/v1/bodyresponses take about 1 ms; a cached 6-channel feed about 6 ms (82 ms for 5 channels in 1.x). - The feed is built natively.
POST /v1/feedhands the cached pages to the Rust extension and gets the response back in one call with the GIL released (telegram_me_native.build_feed): reading the pages, the ranking signals, ranking and rendering. A 100-channel feed takes about half the time (84 → 43 ms in process), allocates half as much Python memory, and no longer stalls the worker's other requests: a cached/v1/bodynext to a feed loop went from 62 to 10 ms at p99. Responses are byte-for-byte the same. Resident memory is unchanged within noise. - Identical concurrent requests do the work once, across all workers and servers. With Redis, the process that fetches a page from t.me takes a short lease (extended while it runs); every other worker and server waits for its result in the cache instead of fetching the same page. Under a duplicate-heavy load on 6 processes, upstream requests dropped from 15–16 to 2.8 per distinct page over 25 s (one per TTL period, 75 instead of ~420), also with Redis 90 ms away, and CPU per request from 1.4 ms to 0.27–0.29 ms. When Redis is slow or down, each worker still coalesces on its own.
- Rendering is shared too: concurrent misses of
/v1/bodyrender the page once (it was rendered once per request),/v1/postresponses are cached rendered (a hit takes 0.07 ms instead of 0.24 ms), and identical/v1/feedrequests (REST or GraphQL, channel names in any case) are built once, and decoded once for GraphQL. - Popular pages are renewed in the background shortly before they expire, so they no longer expire under load and nobody waits for t.me at expiry.
- The per-worker cap on requests to t.me (
TELEGRAM_MAX_CONCURRENCY) is shared fairly: round-robin over channels and feed requests instead of first come, first served, and background refreshes only use spare capacity. The feed refresher no longer fetches pages that any process fetched moments ago. - Requests beyond the new queue bounds are answered
503withRetry-Afterinstead of queueing without limit. - Repeated requests no longer wait for Redis. Each worker keeps an
in-process cache in front of Redis: a repeated
/v1/body,/v1/post,/v1/moreor/v1/previewtakes 0.1–0.25 ms instead of one Redis round trip (~95 ms with Upstash). Misses read Redis once (GETandPTTLin one pipeline) and keep a copy for the entry's remaining TTL, at mostMEMORY_CACHE_MAX_TTLseconds; writes go to both levels. While Redis is down the in-process cache works alone. - The in-process cache is native (Rust,
moka, zstd) instead of a PythonOrderedDict: LRU bounded byMEMORY_CACHE_MAX_MBof real memory as before, per-entry expiry, and a hit is up to a third faster without Redis. - With Redis, each worker now also uses up to
MEMORY_CACHE_MAX_MB(64 MiB by default) for the in-process cache; in practice only what was read in the lastMEMORY_CACHE_MAX_TTLseconds.
Removed¶
- Fly.io (
fly.toml), Google App Engine (app.yaml), Heroku (Procfile,runtime.txt) configuration,start.sh,docker-compose.yml,docker-compose.ghcr.yml,requirements.txtand the pylint configuration. - The
VERSIONsetting.
1.7 - 2025-10-31¶
Last release of the 1.x line.