Django reusable app: a Telegram post constructor that outputs Bot API-ready {text, entities} payloads.
Project description
django-tgcms
A reusable Django app for building Telegram posts. Compose posts from blocks
(heading, formatted text, photo, video) using a WYSIWYG editor embedded in
Django admin. Post.render() returns a Bot API-ready payload — 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",
]
# Optional — only needed for the send_post test command
TGCMS = {
"BOT_TOKEN": env("YOUR_BOT_TOKEN"), # map whatever name your project uses
}
urls.py (only if you need the built-in views):
urlpatterns += [
path("tg/", include("tgcms.urls")),
]
Run migrations:
python manage.py migrate
Content — Django admin
Posts are edited in the standard Django admin at /admin/tgcms/post/.
Block types (drag-and-drop reordering inside each post):
| Type | Stores |
|---|---|
heading |
Plain text title |
text |
Formatted text — bold, italic, underline, strikethrough, spoiler, code, pre, blockquote, links |
photo |
Media asset + caption |
video |
Media asset + caption |
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.
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.
Testing — management command
# Token is read from settings.TGCMS["BOT_TOKEN"] automatically
python manage.py send_post <post_id> <chat_id>
# Or pass it explicitly
python manage.py send_post 1 @mychannel --token 123456:ABC...
# Or via env var
TELEGRAM_BOT_TOKEN=123456:ABC... python manage.py send_post 1 123456789
Token lookup order: --token → settings.TGCMS["BOT_TOKEN"] → TELEGRAM_BOT_TOKEN env var.
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
Project details
Release history Release notifications | RSS feed
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.1.1.tar.gz.
File metadata
- Download URL: django_tgcms-0.1.1.tar.gz
- Upload date:
- Size: 21.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
42558979d27e6fbdccee847ad027eba0854e99b45bdd9fa65471b25c82c7ed9b
|
|
| MD5 |
3c5730750af860bb434c255cff662f11
|
|
| BLAKE2b-256 |
69b05eece6f87702a2afbe1477edb6268e2e2c3f3eff3116a98fc72e90f941ab
|
File details
Details for the file django_tgcms-0.1.1-py3-none-any.whl.
File metadata
- Download URL: django_tgcms-0.1.1-py3-none-any.whl
- Upload date:
- Size: 27.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 |
482d794f0cba0bfd984326ebfd48d59997e59b1820924cd51583f30d00d0a617
|
|
| MD5 |
94462c729818dcda372a464d96334307
|
|
| BLAKE2b-256 |
22dc776d3d3e01ff6f0e703dc376053cfa7ee4539af429293e7cbb9ae89f768a
|