Telegram gateway built on top of libtacky (in component mode)
  • Go 64.7%
  • Tcl 33.7%
  • Python 0.9%
  • Shell 0.3%
  • Makefile 0.2%
  • Other 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-28 00:25:03 +02:00
component An animated sticker's static preview has the type its bytes say, not image/webp by assumption: the engine reads the preview's first 4 KiB from Telegram once per file id (upload.getFile at offset 0, at most 5 s under the account's life, the message waiting for it as for its set's name; renewed references keep the type) and sniffs them without decoding (account.SniffImage: RIFF....WEBP image/webp, FF D8 FF image/jpeg, 89 PNG image/png); anything else, or a read Telegram refuses, is no file and the component sends the emoji alone. The component names a JPEG's slot and share sticker.jpg, imports JPEG previews into packs, and takes a preview's dimensions from the sticker when the engine's upload measures none (it measures WebP alone). The fake's video sticker preview is now a 256x256 JPEG (fake FileInfo sniffs as the account does). docs/frames.md and docs/DESIGN.md say so; make stats regenerated. Tests: TestSniffImage; account's animated stickers whose previews read as WebP, JPEG, a GIF and nothing (the last two no file), the type kept for the same sticker again without a read, the set's preview a PNG; the fake's JPEG preview (jpeg.DecodeConfig 256x256); e2e: the video sticker's preview a JPEG in its slot (image/jpeg, sticker.jpg, 256x256, size and hash), catpack's 🙀 a JPEG. make build test pass (Go 99.1 %); the e2e's stickers, packs, media and history parts in both modes 427 passed, 1 failed, the failure the archive caption-order check (priv1, media), whose shard then passed alone (129 of 129). 2026-10-27 17:54:22 +02:00
docs An animated sticker's static preview has the type its bytes say, not image/webp by assumption: the engine reads the preview's first 4 KiB from Telegram once per file id (upload.getFile at offset 0, at most 5 s under the account's life, the message waiting for it as for its set's name; renewed references keep the type) and sniffs them without decoding (account.SniffImage: RIFF....WEBP image/webp, FF D8 FF image/jpeg, 89 PNG image/png); anything else, or a read Telegram refuses, is no file and the component sends the emoji alone. The component names a JPEG's slot and share sticker.jpg, imports JPEG previews into packs, and takes a preview's dimensions from the sticker when the engine's upload measures none (it measures WebP alone). The fake's video sticker preview is now a 256x256 JPEG (fake FileInfo sniffs as the account does). docs/frames.md and docs/DESIGN.md say so; make stats regenerated. Tests: TestSniffImage; account's animated stickers whose previews read as WebP, JPEG, a GIF and nothing (the last two no file), the type kept for the same sticker again without a read, the set's preview a PNG; the fake's JPEG preview (jpeg.DecodeConfig 256x256); e2e: the video sticker's preview a JPEG in its slot (image/jpeg, sticker.jpg, 256x256, size and hash), catpack's 🙀 a JPEG. make build test pass (Go 99.1 %); the e2e's stickers, packs, media and history parts in both modes 427 passed, 1 failed, the failure the archive caption-order check (priv1, media), whose shard then passed alone (129 of 129). 2026-10-27 17:54:22 +02:00
engine 0.3.11: the engine says version 0.3.11, on libtacky v1.1.11 (47df7fd7), for the tree to be tagged v0.3.11 (the user, 2026-10-27: "tag 0.7.11 and 0.3.11 and libtacky 1.1.11"): a Telegram secret chat started from our side no longer ends at the peer's first message (libtacky b5113ca2: Telegram for iOS and macOS announce layer 46 first, which libtacky's engine took as a reason to abort; the gateway itself relayed correctly). Gates on this tree: make test (go vet, go test -race, coverage) green; make e2e with E2E_PORT_BASE 30000, both privilege modes over 12 rigs, 2,244 passed, 0 failed. 2026-10-28 00:25:03 +02:00
libtacky@47df7fd7c2 0.3.11: the engine says version 0.3.11, on libtacky v1.1.11 (47df7fd7), for the tree to be tagged v0.3.11 (the user, 2026-10-27: "tag 0.7.11 and 0.3.11 and libtacky 1.1.11"): a Telegram secret chat started from our side no longer ends at the peer's first message (libtacky b5113ca2: Telegram for iOS and macOS announce layer 46 first, which libtacky's engine took as a reason to abort; the gateway itself relayed correctly). Gates on this tree: make test (go vet, go test -race, coverage) green; make e2e with E2E_PORT_BASE 30000, both privilege modes over 12 rigs, 2,244 passed, 0 failed. 2026-10-28 00:25:03 +02:00
tests/e2e Revert "The media part's "the user's own captioned one, the caption ahead of the URL" failed now and then" (1d2c107): the 30 s wait did not help (the check failed again with it, with the privilege this time), and the failing archive pages do not hold the user's own photo at all (82 results ending at its echo), so the cause is not slowness. It stays open: an ordering issue between the fake's history (kept in log order) and account.Page is suspected. The check counts as rerun-green in the gate. 2026-10-27 17:54:49 +02:00
tools Animated stickers as Telegram's static preview (the user's ruling of 2026-10-27 for the 0.3 line: no conversion, no sandbox, no extra complexity). A TGS or WebM sticker, in a message or a set, is given by the engine as the largest of its document's thumbnails of at most 512 px either way (account.stickerPreview over PhotoSize and PhotoSizeProgressive, file id t<document id>, fetched as an InputDocumentFileLocation with that thumb size, renewed as the sticker's file is), image/webp with the thumbnail's own size and dimensions, its bytes forwarded as Telegram gives them; without such a thumbnail the sticker has no file and the component sends the emoji alone. The component (sticker.tcl, packs.tcl, mam.tcl) uploads the preview as it does a static sticker, imports it into a pack the same way and skips one without a preview. Gone: the engine's sticker.convert and the package convert (the converters' runner and sandbox, the WebM and Lottie checks, the converted WebPs' cache, the per-user converter place and the failure memory), convert/stickerconv.c and convert/convjail.c, paperplaned's -stickerconv, -ffmpeg and -convjail, the Dockerfile's convert stage (rlottie, libwebp, the minimal ffmpeg; libvpx stays in the base stage for the video calls) and the tools beside paperplaned in dist/ and the image, which is 168 MB again as 0.3.6's was (the converters added about 12 MB); the fake's real TGS and WebM files give way to two still previews (128 and 256 px) and "broken sticker" is a TGS without one. With the converter the delta audit's converter findings D1, D2, D5 and D7 are moot. README (features, running, the options, layout), docs/DESIGN.md (Stickers, the layout, testing), docs/frames.md (Sticker, stickerset.get, the fake, the history) and tools/stats.py say so; make stats regenerated. Tests: account's TestStickerOf (a TGS's 128 px preview among a stripped, a 64 px and an 800 px thumbnail, a WebM's progressive one, a TGS without one) and the remembered preview with its thumb size, the set's; the fake's previews as still WebPs; the engine's upload of a preview and sticker.convert unknown; e2e: both animated stickers as still WebPs (VP8L) of 128 and 256 px with their metadata, uploaded once, the one without a preview its emoji alone, the packs with the WebM's preview. make build test pass (Go 99.1 %); the e2e's stickers, packs, media and history parts in both modes 428 passed, 0 failed. 2026-10-27 17:38:08 +02:00
.dockerignore tackygram ships as two executables, dist/tackygram and dist/paperplaned (the user: "We do not want to ship the Tcl! We want to ship two binaries: tackygram and paperplaned"): libtacky is a submodule (libtacky/, branch feat/stream-core, at 370ba3c1), whose dist/tackyd is libtacky.a's interpreter running a program, and make component appends component/ (main.tcl at its top, zipped by tackyd's own zipfs mkzip) to a copy of it, so the component carries its Tcl, libtacky and scripts and needs only the C library; make engine is the Go binary as before, make build both, make libtacky builds the submodule's tackyd in libtacky's own container, make clean takes dist/ and the submodule's build. The image is the two executables and the CA roots on Debian's slim base, tackygram the entrypoint running paperplaned from beside it (main.tcl looks for the engine beside the executable, then on PATH); deploy/ goes (the user: tackygram builds a binary or an image and nothing more; running it is anotherim-server's, whose ejabberd.yml and compose file carry what deploy/ held). make e2e runs in libtacky's build image through its ejabberd rig, the client under dist/tackyd. README and DESIGN follow. 2026-10-07 18:11:47 +03:00
.gitignore Code lines and coverage counted as libtacky counts them, after the user's ask of 2026-10-25, "please count tackygram code lines same as in libtacky (and test coverage) in next commit": tools/stats.py (standard library Python, adapted from libtacky's) counts the files git tracks, without blank lines, comments and generated files, by functional component and language (account, avatars, calls, engine, fake, gateway, history, media, roster, stickers, storage, alphabetical, one row per language, the component named on its first row, a file going to the component with the longest matching path prefix, so photo.go or history.go of the account package count under avatars and history), each row with its code, its test lines (the engine's _test.go files and the e2e's suites, e2e.tcl as the gateway's) and its coverage; the total counts code per language (Tcl · Go · C), the everything-else row (Makefile, Dockerfile, the e2e's scripts, tools/) is shown and not counted, and the libtacky submodule is not counted at all. The table is generated into README.md's Layout section and a totals line into a new Status and tests section, between stats markers, by make stats (which reads the last coverage back from the README) and checked by make stats-check (stats.py --readme --check), Python running in libtacky's build image with the repository's git directory mounted for a worktree. make coverage-all runs the engine's tests under go test -race -coverprofile in the Dockerfile's base stage (statements, per file) and both e2e runs over a dist/tackygram whose main.tcl is tools/cov-main.tcl: tools/cov.tcl, after libtacky's tests/cov.tcl (whose root is its own tacky/ and cannot be pointed here), records each procedure of component/ when it is defined and on its first call, line by line since the e2e kills and restarts the gateway, and the plain dist/tackygram is packed again afterwards; COV_DIR (_build/cov, now ignored) gets REPORT.md and RUN with the date and commit. Measured on 2026-10-25 at 0dfac5d: Tcl 96.4 % of procedures (267 of 277), Go 90.5 % of statements (4,271 of 4,720), over 13,660 lines of code (Tcl 4,002, Go 9,648, C 10) and 12,902 lines of tests; the C resolver shim and ntgcalls' Go side, tested only in the image build with the real library, are not measured. Checked: make coverage-go, make coverage-tcl (e2e 230 of 230 with the roster privilege, 253 of 253 without) and make stats-check pass. 2026-10-25 18:45:36 +02:00
.gitmodules libtacky to 4c16a158, develop's tip (v1.0.0-alpha10 and the Windows threads fix), and .gitmodules tracks develop, the branch libtacky develops on now that feat/stream-core is merged. None of what the component calls changed (tacky::xmpp::component's new, handle, reply, error, fail, disco entity, adhoc with -allow, resume and deferred stages, bob put and drop). Checked: make component builds the new tackyd, make e2e 37 of 37. 2026-10-10 12:07:18 +03:00
Dockerfile Animated stickers as Telegram's static preview (the user's ruling of 2026-10-27 for the 0.3 line: no conversion, no sandbox, no extra complexity). A TGS or WebM sticker, in a message or a set, is given by the engine as the largest of its document's thumbnails of at most 512 px either way (account.stickerPreview over PhotoSize and PhotoSizeProgressive, file id t<document id>, fetched as an InputDocumentFileLocation with that thumb size, renewed as the sticker's file is), image/webp with the thumbnail's own size and dimensions, its bytes forwarded as Telegram gives them; without such a thumbnail the sticker has no file and the component sends the emoji alone. The component (sticker.tcl, packs.tcl, mam.tcl) uploads the preview as it does a static sticker, imports it into a pack the same way and skips one without a preview. Gone: the engine's sticker.convert and the package convert (the converters' runner and sandbox, the WebM and Lottie checks, the converted WebPs' cache, the per-user converter place and the failure memory), convert/stickerconv.c and convert/convjail.c, paperplaned's -stickerconv, -ffmpeg and -convjail, the Dockerfile's convert stage (rlottie, libwebp, the minimal ffmpeg; libvpx stays in the base stage for the video calls) and the tools beside paperplaned in dist/ and the image, which is 168 MB again as 0.3.6's was (the converters added about 12 MB); the fake's real TGS and WebM files give way to two still previews (128 and 256 px) and "broken sticker" is a TGS without one. With the converter the delta audit's converter findings D1, D2, D5 and D7 are moot. README (features, running, the options, layout), docs/DESIGN.md (Stickers, the layout, testing), docs/frames.md (Sticker, stickerset.get, the fake, the history) and tools/stats.py say so; make stats regenerated. Tests: account's TestStickerOf (a TGS's 128 px preview among a stripped, a 64 px and an 800 px thumbnail, a WebM's progressive one, a TGS without one) and the remembered preview with its thumb size, the set's; the fake's previews as still WebPs; the engine's upload of a preview and sticker.convert unknown; e2e: both animated stickers as still WebPs (VP8L) of 128 and 256 px with their metadata, uploaded once, the one without a preview its emoji alone, the packs with the WebM's preview. make build test pass (Go 99.1 %); the e2e's stickers, packs, media and history parts in both modes 428 passed, 0 failed. 2026-10-27 17:38:08 +02:00
LICENSE GPL-3.0; integration test against Telegram's test servers 2026-10-07 13:11:15 +03:00
Makefile Animated stickers as Telegram's static preview (the user's ruling of 2026-10-27 for the 0.3 line: no conversion, no sandbox, no extra complexity). A TGS or WebM sticker, in a message or a set, is given by the engine as the largest of its document's thumbnails of at most 512 px either way (account.stickerPreview over PhotoSize and PhotoSizeProgressive, file id t<document id>, fetched as an InputDocumentFileLocation with that thumb size, renewed as the sticker's file is), image/webp with the thumbnail's own size and dimensions, its bytes forwarded as Telegram gives them; without such a thumbnail the sticker has no file and the component sends the emoji alone. The component (sticker.tcl, packs.tcl, mam.tcl) uploads the preview as it does a static sticker, imports it into a pack the same way and skips one without a preview. Gone: the engine's sticker.convert and the package convert (the converters' runner and sandbox, the WebM and Lottie checks, the converted WebPs' cache, the per-user converter place and the failure memory), convert/stickerconv.c and convert/convjail.c, paperplaned's -stickerconv, -ffmpeg and -convjail, the Dockerfile's convert stage (rlottie, libwebp, the minimal ffmpeg; libvpx stays in the base stage for the video calls) and the tools beside paperplaned in dist/ and the image, which is 168 MB again as 0.3.6's was (the converters added about 12 MB); the fake's real TGS and WebM files give way to two still previews (128 and 256 px) and "broken sticker" is a TGS without one. With the converter the delta audit's converter findings D1, D2, D5 and D7 are moot. README (features, running, the options, layout), docs/DESIGN.md (Stickers, the layout, testing), docs/frames.md (Sticker, stickerset.get, the fake, the history) and tools/stats.py say so; make stats regenerated. Tests: account's TestStickerOf (a TGS's 128 px preview among a stripped, a 64 px and an 800 px thumbnail, a WebM's progressive one, a TGS without one) and the remembered preview with its thumb size, the set's; the fake's previews as still WebPs; the engine's upload of a preview and sticker.convert unknown; e2e: both animated stickers as still WebPs (VP8L) of 128 and 256 px with their metadata, uploaded once, the one without a preview its emoji alone, the packs with the WebM's preview. make build test pass (Go 99.1 %); the e2e's stickers, packs, media and history parts in both modes 428 passed, 0 failed. 2026-10-27 17:38:08 +02:00
README.md 0.3.11: the engine says version 0.3.11, on libtacky v1.1.11 (47df7fd7), for the tree to be tagged v0.3.11 (the user, 2026-10-27: "tag 0.7.11 and 0.3.11 and libtacky 1.1.11"): a Telegram secret chat started from our side no longer ends at the peer's first message (libtacky b5113ca2: Telegram for iOS and macOS announce layer 46 first, which libtacky's engine took as a reason to abort; the gateway itself relayed correctly). Gates on this tree: make test (go vet, go test -race, coverage) green; make e2e with E2E_PORT_BASE 30000, both privilege modes over 12 rigs, 2,244 passed, 0 failed. 2026-10-28 00:25:03 +02:00

Tackygram

A Telegram gateway for XMPP. Run it beside your own XMPP server, link your Telegram account, and your Telegram contacts become XMPP contacts. One-to-one chats only for now. Groups and channels come later.

  • Its own Telegram engine on gotd, no TDLib.
  • Sessions are encrypted at rest. A copy of the data directory is useless without the key, which is kept outside it. The XMPP side stores nothing: the engine's sealed store holds your login, its working state and your two gateway settings; the rest is derived again after a restart.
  • Plain XMPP: service discovery, in-band registration (XEP-0077, XEP-0100), ad-hoc commands, and chat-line commands for clients without them.

Licence: GPL-3.0 (LICENSE).

Features

One-to-one chats; messages go both ways.

  • Linking by QR code or phone number, with 2FA; a QR login left behind is dropped by the next start
  • Contacts and chats in the roster (XEP-0356): mutual contacts only, not the phone book's; Saved Messages as one contact. Without the roster privilege they are a list in service discovery (node contacts, paged with XEP-0059) and each comes in on first use; either way at most -roster-pace contacts' stanzas a second, however many chats
  • Telegram's chat folders as roster groups, the archive as "Archived Chats", following Telegram's changes; your own groups stay
  • Pinned and muted chats as "Pinned Chats" and "Muted Chats" (a mute's end too); a contact's commands Mute, Pin and Archive (and their undoing) change them on Telegram
  • The blocked list: Telegram's blocks in your server's blocked list and yours on Telegram (XEP-0191, with the server's IQ privilege), and Telegram's list at the gateway (XEP-0191, the blocked command)
  • Lookup by @username, phone number or id
  • Texts, replies, delivery and read receipts
  • Reactions (XEP-0444)
  • Corrections and retractions (XEP-0308, XEP-0424)
  • Typing (XEP-0085)
  • Silent and scheduled messages
  • Your messages from other Telegram devices, mirrored as yours
  • Photos, videos, GIFs, audio and files
  • Albums and captions
  • Voice notes and round videos
  • Stickers (XEP-0449), the animated ones as Telegram's still preview of them, their sets imported into the server's catalogue
  • Places and live locations, contact cards, polls, forwards
  • Formatting (XEP-0393, XEP-0394); custom emoji as the plain emoji they stand for
  • Auto-delete timers as ephemeral messages (XEP-0466), both ways
  • Report spam or abuse with a block (XEP-0191, XEP-0377)
  • Pins and other service messages
  • History as an archive at each contact's JID (XEP-0313), searched with Telegram's search (XEP-0431)
  • Avatars and profile cards (vcard-temp and vCard4): name, @username, phone when shared, birthday, bio
  • A contact's commands: block, unblock, mute, pin, archive, a bot's own commands
  • Bots' buttons (XEP-0439): inline buttons to press, reply keyboards as quick responses, links in the text
  • Your Telegram bio, name, username and photo, and Sync my XMPP profile: your XMPP nickname and avatar as your Telegram name and photo, once, when you ask
  • Your Telegram sessions, two-step password and privacy settings
  • Online on Telegram only while you are online on XMPP, so your phone keeps notifying while you are away
  • The gateway's commands follow the link: before it, Link and Status alone
  • Last seen in your own time zone
  • Voice and video calls, one to one, both ways (VP8 towards XMPP)
  • Telegram secret chats, relayed to one of your devices; the gateway holds no key

Not bridged: groups and channels, custom-emoji and paid reactions, custom emoji from XMPP to Telegram. Mirroring your own messages, reactions and edits needs the server's message privilege (XEP-0356).

Details: docs/DESIGN.md ("What is bridged, in detail"). For client authors: docs/linking.md and docs/history.md.

Status

The latest release is v0.3.11, on libtacky v1.1.11; another.im runs the released versions (v0.3.2 when this was written). The tests run against ejabberd and a fake Telegram (paperplaned -fake). Calls, and what came after v0.3.2 (silent and scheduled messages, corrections, albums, the content above, profiles and commands, the account's sessions, password and privacy), have not been tried against real Telegram yet.

33,626 lines of code (Tcl 11,759 · Go 21,857 · C 10) and 33,905 lines of tests (the table under Layout); 753 Go test functions and 907 e2e checks defined. Coverage: Tcl 97.0 % procs · Go 99.1 % stmts (the make coverage-all run on 2026-10-27 at commit 326e043).

The full coverage report of the last run is _build/cov/REPORT.md.

Running it

dist/tackygram is the XMPP component. It runs dist/paperplaned (the engine) from beside it. anotherim-server runs both in its compose deployment: make env ... TACKYGRAM=1 adds telegram.<domain>.

paperplaned keygen > /etc/tackygram/key && chmod 0400 /etc/tackygram/key
COMPONENT_SECRET=… TG_API_ID=… TG_API_HASH=… \
    tackygram -domain telegram.example.org -data /var/lib/tackygram \
    -engine-args '-key /etc/tackygram/key'

TG_API_ID and TG_API_HASH come from https://my.telegram.org. Without -key the engine reads the systemd credential gateway-key.

What the XMPP server needs

  • An XEP-0114 component listener on localhost (port 5347). Never expose it: XEP-0114 has no TLS.
  • mod_privilege granting the gateway roster (both) and message (outgoing), and IQ (both) on urn:xmpp:blocking for the blocked lists. Without the roster privilege the contacts are not put in the roster at once: a client finds them in the list at the gateway's node contacts (disco#items with RSM), and a contact comes in (an XEP-0144 offer and its subscription request) the first time you write to it or it writes to you; the status command says so. Without the message privilege your messages from other Telegram devices are not mirrored. Without the IQ privilege your server's blocked list and Telegram's are apart: Telegram's is at the gateway (XEP-0191 addressed to it, the blocked command).
  • An XEP-0363 upload service that gives the gateway's domain slots.
  • For sticker sets: the gateway allowed to create pubsub nodes (ejabberd's pubsub_createnode), and on ejabberd with mod_privilege anotherim-server's mod_component_iq (processone/ejabberd#4472).

anotherim-server's configuration has all of it (the IQ privilege on urn:xmpp:blocking since its master 819f3b7).

Component options

option default meaning
-domain (TACKYGRAM_DOMAIN) required the gateway's JID
COMPONENT_SECRET required the XEP-0114 secret, from the environment only
-data required the engine's data directory
-host, -port 127.0.0.1, 5347 the server's component listener
-server -domain minus its first label the server's domain
-allow -server domains whose users may use the gateway
-upload upload.<server> the XEP-0363 service files go to
-pubsub pubsub.<server> the sticker catalogue's service
-catalogue-publishers admin@<server> publishers of the catalogue's node
-name Telegram the name in service discovery
-phone-cooldown 60 seconds between two phone logins of one account
-login-idle 60 seconds a client's QR login lives with no request of its held (then it is cancelled)
-roster-pace 50 the contacts' presences, avatars, roster pushes and subscription requests sent per account a second
-log info debug logs where each message went (never its text)

Settings

Environment variables of the engine (or paperplaned flags):

variable flag default meaning
TACKYGRAM_MEDIA_MAX -media-max 100MiB the largest file the gateway moves, either way; 0 for no limit of its own. TACKYGRAM_MEDIA_MAX_MIB (a number of MiB) is the old name and still read
TACKYGRAM_MEDIA_MAX_FORCED -media-max-forced 4000MiB the largest file over the limit a user may still fetch on request; 0 turns that off
TACKYGRAM_FETCH_ALLOW -fetch-allow hosts besides the upload service's that shared files may be fetched from, host or host:port, comma-separated. A host without a port is every port of it, plain http included: name host:port for one on the loopback or the LAN (the server's own services listen there); the engine warns at start about a loopback host listed without one
TACKYGRAM_RETENTION_MIN -retention-min 30d how long a stored item the size of the media limit is kept
TACKYGRAM_RETENTION_MAX -retention-max 365d how long a small one is kept; 0 keeps all until the quota needs room
TACKYGRAM_STORE_MAX -store-max 0 (none) a quota on what traffic stores, all users together
TACKYGRAM_E2E_QUEUE_MAX -e2e-queue-max 10000 items kept per secret chat for an offline device; -1 unlimited
TACKYGRAM_E2E_QUEUE_MAX_MIB -e2e-queue-max-mib 64 the same in MiB
TACKYGRAM_CALL_PORTS -call-ports any the UDP port range for calls, e.g. 50000-50199
TACKYGRAM_CALL_IP -call-ip the public address(es) for calls' candidates
TACKYGRAM_CALL_STUN -call-stun stun: server URL(s), instead of or beside TACKYGRAM_CALL_IP

The media limit is 100 MiB because that is what upload services usually take (ejabberd's default), and because one 4 GB Telegram file would tie up the gateway and the upload service for everyone. A file over it arrives as a notice that says how to fetch it anyway (fetch ID to the gateway, within seven days).

For calls, publish the UDP range one to one, open it in the firewall and set TACKYGRAM_CALL_IP to the host's public address. The Telegram side needs nothing opened. paperplaned -no-calls turns calls off. TACKYGRAM_CALL_CAPTURE_DIR records every call's audio to files; it is for debugging only.

Layout

path what
engine/ paperplaned (Go): Telegram sessions, the encrypted store, the JSON-lines protocol
component/ the XMPP component (Tcl 9 on libtacky's tacky::xmpp::component)
libtacky/ libtacky, a submodule: the interpreter and the tacky::* packages
tests/e2e/ the end-to-end test: ejabberd, the component, the fake engine, a user with two devices
docs/DESIGN.md the design, and what is bridged in detail
docs/frames.md the engine ⇄ component protocol
docs/linking.md, docs/history.md what a client may rely on: linking; history and avatars

The code by component and language, generated by tools/stats.py (make stats; the coverage column by make coverage-all):

component language what it is code tests coverage
account Tcl register.tcl: linking an account, by ad-hoc command or chat line 715 0 95.2 % procs
Go internal/account: one Telegram session on gotd (login, updates, contacts, sending); internal/qrtext: the login QR code 1,963 3,042 99.8 % stmts
auto-delete, reports Tcl ephemeral.tcl: auto-delete as XEP-0466; spam.tcl: XEP-0377 reports in blocks 137 116 100.0 % procs
Go account/ephemeral.go, report.go, engine/extras.go, fake/extras.go: the same in the engine 373 382 93.5 % stmts
avatars Tcl avatar.tcl: profile photos as avatars (XEP-0084, XEP-0153) 124 0 90.0 % procs
Go account/photo.go: profile photos fetched and announced 227 133 98.4 % stmts
calls Tcl calls.tcl: call signalling (Jingle, XEP-0353) 506 372 97.1 % procs
Go internal/calls: the audio and video bridge, ntgcalls to Telegram, pion to XMPP; account/calls.go 4,042 5,339 98.3 % stmts
C calls/ntg/resolv_shim.c: resolver names ntgcalls' glib needs 10 0 --
buttons Tcl buttons.tcl: bots' buttons as XEP-0439, the press 142 86 85.7 % procs
Go account/markup.go, engine/buttons.go, fake/buttons.go: the keyboards and the callback 256 262 98.3 % stmts
content Tcl content.tcl: places, cards, polls, service messages, forwards; format.tcl: formatting both ways 1,182 394 100.0 % procs
Go account/content.go, entities.go, service.go, engine/content.go, fake/content.go: the same in the engine 2,391 2,829 99.7 % stmts
edits Tcl edits.tcl: corrections and retractions (XEP-0308, XEP-0424) 247 164 100.0 % procs
Go account/edits.go, engine/edits.go, store/msgs.go, fake/edits.go: edits, deletions and the message index 672 806 99.5 % stmts
engine Tcl engine.tcl: the link to paperplaned 142 0 84.6 % procs
Go cmd/paperplaned: the process; internal/engine: requests and events; internal/frame: the wire 1,832 3,362 99.0 % stmts
fake Go internal/fake: a scripted Telegram account for tests (-fake) 1,807 1,779 99.2 % stmts
gateway Tcl main.tcl: options and the XEP-0114 component; gateway.tcl: presence, disco, lookup, messages, receipts, reactions, mirroring 1,154 1,435 98.2 % procs
history Tcl mam.tcl: an XEP-0313 archive per contact, paged from Telegram, searched (XEP-0431) 251 219 100.0 % procs
Go account/history.go: a chat's history, a page at a time 200 319 100.0 % stmts
media Tcl media.tcl, albums.tcl: files, albums and captions both ways; upload.tcl: the upload service; held.tcl: files over the limit 1,624 432 98.2 % procs
Go account/media.go, attachments.go, files.go: media down and up; internal/fetch: HTTP; held.go: held files 1,644 1,902 98.9 % stmts
organize Tcl blocking.tcl: the blocked lists (XEP-0191 at the gateway; Telegram's kept in step with the user's own under the XEP-0356 IQ permission); the folders as groups are in roster.tcl 343 320 100.0 % procs
Go account/organize.go, engine/organize.go, fake/organize.go: folders, the blocked list; chatstate.go of each: pinned, muted, archived chats 695 779 99.7 % stmts
profile Tcl vcard.tcl: a contact's card (vcard-temp, vCard4); bots.tcl: its full user and commands; account.tcl: the user's own profile; online.tcl, prefs.tcl: online status, the user's settings; tz.tcl: time zone and last seen 1,726 338 94.1 % procs
Go account/online.go, engine/online.go, fake/online.go: the online status 50 115 100.0 % stmts
roster Tcl roster.tcl: the contacts and one-to-one chats in the user's roster (XEP-0356 pushes; without them a list at node contacts, each added on first use), paced, the subscriptions they ask for and their record, Telegram's folders as groups 922 301 98.2 % procs
scheduled Tcl schedule.tcl: silent and scheduled messages 421 207 100.0 % procs
Go account/schedule.go, engine/schedule.go, store/schedule.go, fake/schedule.go: the same in the engine 760 880 97.2 % stmts
secret chats Tcl e2e.tcl: secret chats relayed to the owning device, no key held 851 1,055 98.2 % procs
Go internal/e2e: the courier and its durable queue; account/e2e.go, engine/e2e.go, store/e2e.go, fake/e2e.go, fake/peer.go 2,490 2,736 99.3 % stmts
security Tcl security.tcl: the account's sessions, two-step password and privacy 347 106 100.0 % procs
Go account/security.go, engine/security.go, fake/security.go: the same in the engine 705 694 100.0 % stmts
stickers Tcl sticker.tcl: stickers both ways (XEP-0449), the animated ones as Telegram's still preview; packs.tcl: sticker sets into the server's catalogue 748 0 96.0 % procs
storage Go internal/seal: encryption at rest; internal/store: the sealed per-user store; engine/retain.go: retention and quota 907 2,092 99.8 % stmts
typing Tcl typing.tcl: chat states (XEP-0085) both ways 177 84 100.0 % procs
Go account/typing.go, engine/typing.go, fake/typing.go: Telegram's typing 136 207 100.0 % stmts
voice and round notes Tcl in media.tcl; tests/e2e/notes.tcl tests it 0 43 --
Go internal/notes: WebM to Ogg Opus, waveforms, round video checks; account/notes.go, fake/notes.go 707 575 100.0 % stmts
everything else make, Dockerfile, sh, Python, Tcl the Makefile, Dockerfile, e2e scripts and tools/ 745 118 --
total (without everything else) Tcl 11,759 · Go 21,857 · C 10 33,626 33,905 Tcl 97.0 % procs · Go 99.1 % stmts

code: lines without blanks and comments; libtacky is not counted, and the everything else row is not in the total. tests: test lines per component (COMPONENTS in tools/stats.py). coverage: Go statements run by the engine's tests; Tcl procedures called under the e2e in both privilege modes, from the make coverage-all run on 2026-10-27 at commit 326e043.

Build and test

Everything builds and runs in a container (podman, else docker).

make build         # dist/paperplaned and dist/tackygram
make test          # the engine's go vet and race tests
make e2e           # end to end against ejabberd, both privilege modes, sharded
make coverage-all  # both test runs under coverage, into _build/cov and this README
make stats         # this README's tables (make stats-check: fail when stale)
make image         # the runtime image with both executables
make clean

make e2e runs eight rigs on ports from 20000 (E2E_PORT_BASE), logs in _build/e2e; tests/e2e/gate.sh has its knobs. The sticker-set import is tested without the privilege, or with E2E_PACKS=1 against a server that loads mod_component_iq.

Try the engine without a Telegram account:

paperplaned keygen > /tmp/key && chmod 0400 /tmp/key
echo '{"id":1,"m":"list"}' | dist/paperplaned -fake -data /tmp/tg -key /tmp/key