django-tgcms
A reusable Django app for building Telegram posts. Compose posts from blocks
(heading, formatted text, photo, video) with a WYSIWYG editor embedded in
Django admin. Post.render() returns a Bot API-ready payload, compose()
merges blocks into ready-to-send messages — sending is entirely up to you.
No dependencies beyond Django. No bot logic, no HTTP calls.
Installation
pip install django-tgcms
# or
uv add django-tgcms
settings.py:
INSTALLED_APPS = [
...
"tgcms",
]
Run migrations:
python manage.py migrate
Content — Django admin
Posts are edited in the standard Django admin at /admin/tgcms/post/.
The change page shows a collapsible "Bot API payload" preview of render().
Block types (drag-and-drop reordering inside each post):
heading— plain text title, sent boldtext— formatted text: bold, italic, underline, strikethrough, spoiler, code, pre, blockquote, linksphoto/video— media asset + caption
Inline buttons: each post can carry URL buttons (text + url, row/order
for layout — several per row, several rows). Edited as a table under the
blocks; post.inline_keyboard() returns the ready [[{"text", "url"}]]
structure for reply_markup, and render() includes it as "buttons".
MediaAsset (/admin/tgcms/mediaasset/) is a shared media registry. One
asset can be referenced by any number of blocks across any number of posts.
After the first Telegram send the telegram_file_id is cached on the asset —
subsequent sends reuse it without re-uploading.
compose() — merged messages
render() gives you raw per-block payloads. compose() merges heading/text
blocks into whole messages the way channels usually post:
from tgcms.compose import compose, CAPTION_LIMIT
chunks, media = compose(post)
# chunks: [(text, entities), ...] — headings become bold entities,
# offsets recalculated in UTF-16 units, split at 4096
# media: photo/video blocks in post order
Typical delivery strategy: one media block + one chunk that fits
CAPTION_LIMIT → a single sendPhoto with caption_entities; otherwise
send media, then each chunk via sendMessage with entities.
Bot integration
from tgcms.models import Post
post = Post.objects.prefetch_related("blocks__media").get(pk=post_id)
payload = post.render()
# {
# "blocks": [
# {"type": "heading", "text": "Title"},
# {"type": "text", "text": "Hello!", "entities": [{"type": "bold", "offset": 0, "length": 5}]},
# {"type": "photo", "media_asset_id": 3, "file": "AgACAgI...", "caption": "..."},
# ]
# }
aiogram broadcast pattern:
from asgiref.sync import sync_to_async
async def send_post(bot, chat_id: int, post_id: int):
post = await sync_to_async(
Post.objects.prefetch_related("blocks__media").get
)(pk=post_id)
for block in post.blocks.all():
data = block.render()
if data["type"] == "heading":
await bot.send_message(chat_id, f"<b>{data['text']}</b>", parse_mode="HTML")
elif data["type"] == "text":
await bot.send_message(chat_id, data["text"])
elif data["type"] == "photo":
msg = await bot.send_photo(
chat_id,
photo=data["file"], # telegram_file_id, S3 URL, or local path
caption=data.get("caption"),
)
# Cache file_id after first upload — all future renders return it
if block.media and not block.media.telegram_file_id:
await sync_to_async(block.media.cache_file_id)(msg.photo[-1].file_id)
elif data["type"] == "video":
msg = await bot.send_video(chat_id, video=data["file"], caption=data.get("caption"))
if block.media and not block.media.telegram_file_id:
await sync_to_async(block.media.cache_file_id)(msg.video.file_id)
Once cache_file_id() is called, block.media.source returns the cached
telegram_file_id for every subsequent post that references the same asset.
Models
MediaAsset
file FileField — upload from disk
file_url URLField — S3 / CDN link
telegram_file_id Cached after first send (read-only in admin)
.source Property: returns the best available file reference
.cache_file_id() Persists telegram_file_id; call once after the first send
Post
title, status draft / published
.render() Returns {"blocks": [...]}
.mark_published() Sets status and published_at
Block FK → Post, FK → MediaAsset (nullable)
type heading / text / photo / video
order Managed by drag-and-drop in admin
text, entities heading and text blocks
media FK → MediaAsset, photo and video blocks
caption, caption_entities
.render() Returns one block in Bot API format
UTF-16 offsets
MessageEntity.offset and length are counted in UTF-16 code units, not
Python characters. Non-BMP characters (e.g. 😀 U+1F600) occupy 2 units, not 1.
All offset arithmetic in tgcms.formatting goes through utf16_len().
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_tgcms-0.2.4.tar.gz.
File metadata
- Download URL: django_tgcms-0.2.4.tar.gz
- Upload date:
- Size: 21.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c921df72b7f5898c29bf8c1467a6da9cf360c2d77bac4e4686ee7bceb3890a0e
|
|
| MD5 |
d53bb41f5929d40f72e57c73628ae240
|
|
| BLAKE2b-256 |
f75c60c600fd00b1e3f2632966764044b87505d681c22a7905270ade88054475
|
File details
Details for the file django_tgcms-0.2.4-py3-none-any.whl.
File metadata
- Download URL: django_tgcms-0.2.4-py3-none-any.whl
- Upload date:
- Size: 26.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
aca019e038598a5f0f7fbe61718672f4fa01b26322201a4ce53c328351dddef5
|
|
| MD5 |
0f6e85fb0d8f78fdead7a9628471db02
|
|
| BLAKE2b-256 |
acbd29c911452fc84261299dd06c328c0b1accb5ebb916fb34003b0fa874df54
|