- Go 64.7%
- Tcl 33.7%
- Python 0.9%
- Shell 0.3%
- Makefile 0.2%
- Other 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| component | ||
| docs | ||
| engine | ||
| libtacky@47df7fd7c2 | ||
| tests/e2e | ||
| tools | ||
| .dockerignore | ||
| .gitignore | ||
| .gitmodules | ||
| Dockerfile | ||
| LICENSE | ||
| Makefile | ||
| README.md | ||
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-pacecontacts' 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
blockedcommand) - 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_privilegegranting the gateway roster (both) and message (outgoing), and IQ (both) onurn:xmpp:blockingfor 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 nodecontacts(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, theblockedcommand).- 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 withmod_privilegeanotherim-server'smod_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