I built this for my own home server and printer — sharing it in case it's useful, or you pinch something handy.
A Telegram bot that prints to a thermal receipt printer. Send it a message and it prints a note. Send a photo and it prints the photo. Run /receipt and it prints a mock store receipt filled with live data (weather, FX rates, and whatever else you decide to put on it). Built for a Linux home server — or that little Raspberry Pi forgotten in a closet — with any USB ESC/POS printer.
A couple of reasons to bother. Hand the bot to friends and it turns into a tiny shared printer — their messages, photos and memes pile up on your desk as little thermal-paper postcards. Or maybe you just like numbers: print a /receipt every Sunday and watch your week turn into steps, kilometres, heart rate, expenses and one completely meaningless total at the bottom.
- Plain text — any message is printed as a note (capped at an arbitrary 355 characters — bump
MAX_NOTE_LENGTHinbot.py). - Photos — printed dithered to black & white, scaled to the tape.
/receipt— a mock receipt filled by pluggable data providers, with subtotal/tax/total, an upside-down joke, and a barcode./list— a checklist receipt:/list milk, bread, eggsor one item per line.- Optional access control via a Telegram user-ID allowlist.
Everything is rendered to an image and sent as a bitmap, so the layout looks the same on any ESC/POS printer regardless of its built-in fonts.
- A Linux host (tested on Debian/Ubuntu; a Raspberry Pi works fine)
- A USB ESC/POS thermal printer, 80 mm tape (576 px — what the layout is tuned for). Tested with an Xprinter XP-T80A; any ESC/POS model should work.
- Docker (the easy path) — or Python 3.10+ and
libusbto run from source
git clone https://github.com/vsgusev/text2paper
cd text2paper
cp .env.example .envThen fill in .env — see Configure.
.env holds everything the bot needs. The essentials are your bot token and the
printer's USB ids; everything else has a sane default.
| Variable | Required | Description |
|---|---|---|
BOT_TOKEN |
yes | Token from @BotFather |
PRINTER_USB_VID |
yes | Printer USB vendor id in hex, e.g. 0x0483 |
PRINTER_USB_PID |
yes | Printer USB product id in hex, e.g. 0x5743 |
PAPER_WIDTH_PX |
no | Printable width in dots; default 576 (80 mm). 58 mm tape is 384, but the FONT_* sizes in render.py are tuned for 576 — expect to shrink them yourself |
FONT_PATH |
no | Path to a monospaced TTF (defaults to DejaVu Sans Mono) |
LOGO_PATH |
no | Receipt header logo (defaults to media/logo.png) |
ALLOWED_USER_IDS |
no | Comma-separated Telegram user ids allowed to use the bot; empty = everyone |
TZ |
no | IANA timezone for quiet hours, e.g. Europe/Moscow; defaults to UTC |
QUIET_HOURS |
no | Local-time window when the bot won't print — it replies to say so, nothing is queued, e.g. 01:00-09:00 (may wrap midnight); empty = the bot always prints |
MAX_MESSAGE_AGE |
no | Ignore messages older than N seconds (e.g. stale after downtime); 0/empty = print everything |
lsusb
# Bus 001 Device 005: ID 0483:5743 ...
# ^^^^ ^^^^
# VID PIDSo PRINTER_USB_VID=0x0483 and PRINTER_USB_PID=0x5743.
Add a udev rule so your user can talk to the printer directly:
echo 'SUBSYSTEM=="usb", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="5743", MODE="0666"' \
| sudo tee /etc/udev/rules.d/99-text2paper.rules
sudo udevadm control --reload-rules && sudo udevadm triggerReplace 0483/5743 with your own ids (no 0x prefix here). Re-plug the printer.
Set ALLOWED_USER_IDS in .env to a comma-separated list of Telegram user ids.
Leave it unset to allow everyone:
ALLOWED_USER_IDS=123456789,987654321Get your id from @userinfobot.
The easy path. With .env filled in and the udev rule in place:
docker compose up -d --build
docker compose logs -f # follow logsMessage /start to your bot, then send any text.
The container restarts on boot (restart: unless-stopped), so you don't need the
systemd unit (that's only for the from-source path). USB is passed through with
devices: /dev/bus/usb:/dev/bus/usb — the whole bus, because a printer's device
number changes on re-plug while the bot finds it by VID/PID. The host still needs
the udev rule above so the container may open the device.
For hacking on the code or writing your own providers — edit a file and rerun, no image rebuild:
sudo apt update
sudo apt install -y python3-venv libusb-1.0-0 fonts-dejavu-core
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python bot.pyMessage /start to your bot, then send any text.
fonts-dejavu-core provides DejaVu Sans Mono, which the renderer finds
automatically. Any monospaced TTF works — point FONT_PATH at it instead.
Only for the from-source path — the Docker container already restarts on boot.
Create /etc/systemd/system/text2paper.service:
[Unit]
Description=Text2Paper Telegram printer bot
After=network-online.target
[Service]
WorkingDirectory=/home/youruser/text2paper
ExecStart=/home/youruser/text2paper/venv/bin/python bot.py
Restart=on-failure
User=youruser
[Install]
WantedBy=multi-user.targetsudo systemctl enable --now text2paper
journalctl -u text2paper -f # logsUSBError: Access denied— the udev rule isn't applied. Re-check USB permissions and re-plug the printer.- Printer not found (
DeviceNotFoundError) — runlsusbon the host and confirmPRINTER_USB_VID/PRINTER_USB_PIDin.envmatch itsID. In Docker, also check thedevices:USB passthrough indocker-compose.ymland that the udev rule is applied on the host. - Cyrillic (or other glyphs) print as squares — the font lacks those glyphs.
Point
FONT_PATHat a TTF that has them (DejaVu Sans Mono covers Latin + Cyrillic).
The receipt header is an image. Replace media/logo.png with your own, or set
LOGO_PATH. If the file is missing, the receipt simply prints without a logo.
The bot's chat replies ("printed!", "access denied", …) are in Russian — they're
all in one block at the top of bot.py, marked localize the bot here. Edit them
to your language; nothing else reads them.
The items on /receipt come from modules in providers/. Each one is fully
self-contained — there is no central config for receipt data:
- All settings live at the top of the provider's own file (e.g. your
coordinates in
providers/weather.py). - Want different data? Add a file. Drop a module exposing
fetch() -> list[Item] | None; it's auto-discovered, nothing else to edit. - Don't want some data? Delete the file. That's the whole removal step.
The bundled weather and rates providers are just examples — rates happens
to print RUB and BTC because that's what the author watches. Edit them, delete
them, or add your own (other currencies, crypto, sensors, anything):
# providers/cpu.py
from blocks import Item
def fetch():
with open("/sys/class/thermal/thermal_zone0/temp") as f:
celsius = int(f.read()) / 1000
return [Item("CPU TEMP", f"{celsius:.0f}")]Numeric values are summed into the receipt's subtotal/total (part of the joke —
your data is "sold" on the receipt). Return Items only — other block types are
ignored. A provider that raises or returns None is skipped without breaking the
receipt.
| File / dir | Responsibility |
|---|---|
bot.py |
Telegram transport — commands and message handlers |
receipt.py |
Composes receipts from blocks |
blocks.py |
Block primitives (Item, Banner, Total, …) |
providers/ |
Data sources for /receipt (auto-discovered) |
render.py |
Lays a receipt out into a single image |
images.py |
PIL helpers — fonts, text blocks, barcodes |
printer.py |
Renders, then hands the image to the backend |
backend.py |
Sends the image to the ESC/POS printer over USB |
config.py |
Printer & rendering settings, read from the environment |
Issues and PRs are welcome — but this is a weekend project I run for myself, so no promises on response time or scope.
MIT — see LICENSE.
