django-tgcms
A reusable Django app for building and sending Telegram posts, fully embedded
in Django admin — no project-side code needed. Compose posts from blocks
(heading, formatted text, photo, video) with a WYSIWYG editor, send them to
configured channels via an admin action, or take Post.render() — a Bot
API-ready payload — and send however you like.
No dependencies beyond Django. Sending uses stdlib urllib only.
Installation
pip install django-tgcms
# or
uv add django-tgcms
settings.py:
INSTALLED_APPS = [
...
"tgcms",
]
# Optional — enables the "Send to Telegram: <name>" admin actions
TGCMS = {
"BOT_TOKEN": env("YOUR_BOT_TOKEN"), # map whatever name your project uses
"CHANNELS": {
"My channel": "-1001234567890", # name → chat_id (or "@username")
},
}
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
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.
Sending from admin
With BOT_TOKEN and CHANNELS configured, the Post changelist gets a
"Send to Telegram: " action per channel:
- select posts → choose the action → each block is sent as a separate message
- successful posts are marked published; failures are reported per post
photo/videoblocks cachetelegram_file_idon their MediaAsset after the first upload
The same logic is importable for workers/cron:
from tgcms.publish import send_post, PublishError
send_post(post, chat_id="-1001234567890", token=BOT_TOKEN)
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
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.0.tar.gz.
File metadata
- Download URL: django_tgcms-0.2.0.tar.gz
- Upload date:
- Size: 20.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c30f8aae495062495250e541d27dbb9cc74a187a9d01a8fe138b99a6c71fdfec
|
|
| MD5 |
ef1f8e0eccb49587da0cbcba20f3a59e
|
|
| BLAKE2b-256 |
135a3f34c3d7f6a2d9a8a6c7583c1ee0b2d0d02f350f78ed18d17fcce86a1577
|
File details
Details for the file django_tgcms-0.2.0-py3-none-any.whl.
File metadata
- Download URL: django_tgcms-0.2.0-py3-none-any.whl
- Upload date:
- Size: 25.8 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 |
90f2958b31105d91ba6d65ad779c51aaefba21ab1760a5dceb1b805720e5d8ff
|
|
| MD5 |
fdb6d639e5a4a57b5b9aaf0a18836dbe
|
|
| BLAKE2b-256 |
cf2b6007c3146e89d1ed63564531bf50275cfa4bd3c9d6c2e8124ca7ad3fbbdd
|