Metadata-Version: 2.4
Name: django-tgcms
Version: 0.2.4
Summary: Django reusable app: a Telegram post constructor that outputs Bot API-ready {text, entities} payloads.
Project-URL: Homepage, https://gitlab.com/ddnsupp/django-tgcms
Author: ddnsupp
License: MIT
License-File: LICENSE
Keywords: bot-api,cms,django,entities,telegram
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.0
Classifier: Framework :: Django :: 5.1
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.11
Requires-Dist: django>=4.2
Description-Content-Type: text/markdown

# 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

```bash
pip install django-tgcms
# or
uv add django-tgcms
```

`settings.py`:

```python
INSTALLED_APPS = [
    ...
    "tgcms",
]
```

Run migrations:

```bash
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:

```python
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

```python
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:**

```python
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