Skip to main content

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 bold
  • text — formatted text: bold, italic, underline, strikethrough, spoiler, code, pre, blockquote, links
  • photo / 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

django_tgcms-0.2.6.tar.gz (22.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_tgcms-0.2.6-py3-none-any.whl (27.4 kB view details)

Uploaded Python 3

File details

Details for the file django_tgcms-0.2.6.tar.gz.

File metadata

  • Download URL: django_tgcms-0.2.6.tar.gz
  • Upload date:
  • Size: 22.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.8.8

File hashes

Hashes for django_tgcms-0.2.6.tar.gz
Algorithm Hash digest
SHA256 9523b5bf0d5392b54eb5b45cad1eb6a86f717d0ad9f2605624bf35b592a29332
MD5 594bca651994f717823df1292267ca8c
BLAKE2b-256 7a76e74e6b694f284357a42d7ae26811cbb82111406fb2379028b85c6a9e316d

See more details on using hashes here.

File details

Details for the file django_tgcms-0.2.6-py3-none-any.whl.

File metadata

File hashes

Hashes for django_tgcms-0.2.6-py3-none-any.whl
Algorithm Hash digest
SHA256 5dfdae871f5f9c37d66ea3ec8f801e8e27c5609d77e6a41ce87487961c94b33f
MD5 6c6cf7475800386cd74e56fcdf1d6e98
BLAKE2b-256 f2101e7a0c730fa1f8f79210adf322a79c4aaf4b7333d6a2711e3c4424c5ce3b

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page