Metadata-Version: 2.4
Name: django-tgcms
Version: 0.1.1
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) 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

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

`settings.py`:

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

```python
urlpatterns += [
    path("tg/", include("tgcms.urls")),
]
```

Run migrations:

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

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

---

## Testing — management command

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