From f5d7c80ffe56aded53a30f1939eb3873f7aaa38b Mon Sep 17 00:00:00 2001 From: Roman Ivanov Date: Fri, 7 Nov 2025 12:26:57 +0200 Subject: [PATCH] Add aiogram chatbot with streaming and web app --- Makefile | 20 +++++ README.md | 119 ++++++++++++++++++++++++++++ app/__init__.py | 0 app/bot/__init__.py | 0 app/bot/handlers/__init__.py | 14 ++++ app/bot/handlers/errors.py | 20 +++++ app/bot/handlers/inline.py | 112 ++++++++++++++++++++++++++ app/bot/handlers/message.py | 92 ++++++++++++++++++++++ app/bot/keyboards/__init__.py | 0 app/bot/keyboards/common.py | 59 ++++++++++++++ app/bot/main.py | 36 +++++++++ app/bot/middlewares/__init__.py | 0 app/bot/utils/__init__.py | 0 app/bot/utils/streaming.py | 116 +++++++++++++++++++++++++++ app/config.py | 30 +++++++ app/services/__init__.py | 0 app/services/markdown.py | 104 +++++++++++++++++++++++++ app/services/openai_client.py | 101 ++++++++++++++++++++++++ app/storage/__init__.py | 0 app/storage/models.py | 32 ++++++++ app/storage/repo.py | 91 ++++++++++++++++++++++ app/web/__init__.py | 0 app/web/auth.py | 31 ++++++++ app/web/main.py | 134 ++++++++++++++++++++++++++++++++ app/web/static/webapp.css | 88 +++++++++++++++++++++ app/web/static/webapp.js | 125 +++++++++++++++++++++++++++++ app/web/templates/webapp.html | 30 +++++++ main.py | 40 ++++++++++ pyproject.toml | 45 +++++++++++ tests/test_markdown.py | 12 +++ tests/test_streaming.py | 32 ++++++++ 31 files changed, 1483 insertions(+) create mode 100644 Makefile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/bot/__init__.py create mode 100644 app/bot/handlers/__init__.py create mode 100644 app/bot/handlers/errors.py create mode 100644 app/bot/handlers/inline.py create mode 100644 app/bot/handlers/message.py create mode 100644 app/bot/keyboards/__init__.py create mode 100644 app/bot/keyboards/common.py create mode 100644 app/bot/main.py create mode 100644 app/bot/middlewares/__init__.py create mode 100644 app/bot/utils/__init__.py create mode 100644 app/bot/utils/streaming.py create mode 100644 app/config.py create mode 100644 app/services/__init__.py create mode 100644 app/services/markdown.py create mode 100644 app/services/openai_client.py create mode 100644 app/storage/__init__.py create mode 100644 app/storage/models.py create mode 100644 app/storage/repo.py create mode 100644 app/web/__init__.py create mode 100644 app/web/auth.py create mode 100644 app/web/main.py create mode 100644 app/web/static/webapp.css create mode 100644 app/web/static/webapp.js create mode 100644 app/web/templates/webapp.html create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 tests/test_markdown.py create mode 100644 tests/test_streaming.py diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8eaacd6 --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +.PHONY: install run-bot run-web dev lint test + +install: +pip install -e . + +run-bot: +python -m main bot + +run-web: +python -m main web + +dev: +uvicorn app.web.main:app --reload --host 0.0.0.0 --port 8000 + +lint: +ruff check app tests + +test: +pytest + diff --git a/README.md b/README.md new file mode 100644 index 0000000..82fc68b --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# Telegram AI Assistant + +Полнофункциональный Telegram-бот с тремя режимами работы: личный чат, инлайн и мини-приложение для просмотра ответов. + +## Возможности + +- Стриминг ответов из OpenAI Chat Completions с обновлением сообщений раз в ~1 секунду. +- Inline-режим `@bot <запрос>` с подсказками и кнопками перехода. +- Мини-приложение (Telegram WebApp) с поддержкой Markdown, таблиц, подсветки кода и кнопкой «Сгенерировать изображение» для инлайн режима. +- Хранение ответов и метаданных в базе (SQLite по умолчанию). +- FastAPI-сервер: вебхук для бота, API мини-приложения, статика. + +## Архитектура + +``` +app/ + bot/ + handlers/ # message, inline, ошибки + keyboards/ # инлайн-клавиатуры и web_app кнопки + utils/ # стриминг ответов + services/ # OpenAI клиент, Markdown утилиты + storage/ # SQLAlchemy модели и репозитории + web/ # FastAPI приложение и мини-апп +main.py # точка входа (см. ниже) +``` + +## Быстрый старт + +1. **Установите зависимости** + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -U pip +pip install -e .[dev] +``` + +2. **Настройте переменные окружения** + +| Переменная | Описание | +| ---------- | -------- | +| `TELEGRAM_BOT_TOKEN` | токен бота от BotFather | +| `TELEGRAM_BOT_USERNAME` | юзернейм бота без `@` (опционально) | +| `OPENAI_API_KEY` | API ключ OpenAI | +| `BASE_WEBAPP_URL` | публичный URL мини-аппа, например `https://example.com` | +| `WEBHOOK_URL` | публичный URL вебхука `https://example.com/webhook` | +| `DATABASE_URL` | строка подключения SQLAlchemy, по умолчанию `sqlite+aiosqlite:///./bot.db` | +| `OPENAI_MODEL` | имя модели, по умолчанию `gpt-4o-mini` | +| `OPENAI_TEMPERATURE` | температура выборки | + +Создайте файл `.env` и заполните значения: + +``` +TELEGRAM_BOT_TOKEN=123456:ABC... +TELEGRAM_BOT_USERNAME=my_bot +OPENAI_API_KEY=sk-... +BASE_WEBAPP_URL=https://your.domain +WEBHOOK_URL=https://your.domain/webhook +DATABASE_URL=sqlite+aiosqlite:///./bot.db +``` + +3. **Запуск локально** + +```bash +make dev +``` + +Команда запускает FastAPI (uvicorn) на `http://127.0.0.1:8000`. Для приёма обновлений используйте туннель, например: + +```bash +# Ngrok +ngrok http 8000 +# либо Cloudflare Tunnel +cloudflared tunnel --url http://localhost:8000 +``` + +Укажите публичный адрес туннеля в `BASE_WEBAPP_URL` и `WEBHOOK_URL`, затем выполните: + +```bash +curl -X POST https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook \ + -d url=$WEBHOOK_URL +``` + +4. **Альтернативный запуск (polling)** + +Для разработки можно запустить бота в режиме polling: + +```bash +python -m main bot +``` + +FastAPI-приложение отдельно: + +```bash +python -m main web +``` + +## Тесты и линтинг + +```bash +make lint +make test +``` + +## Mini-app + +- `/view?mid=` — отображение сохранённого ответа (Markdown → HTML). +- `/inline` — страница для инлайн режима с кнопкой «Сгенерировать изображение» (подставляет `Generate image ` в поле ввода через WebApp API). +- Подписи `initData` проверяются на сервере (HMAC с токеном бота). + +## Настройка BotFather + +1. Создайте бота, получите токен. +2. Включите режим inline (`/setinline`), задайте placeholder. +3. Включите WebApp кнопку (меню `Bot Settings` → `Menu Button`). +4. Укажите домен мини-аппа (раздел *Web Apps* в BotFather) — должен совпадать с `BASE_WEBAPP_URL`. + +После деплоя убедитесь, что `/healthz` возвращает `{ "status": "ok" }`. + diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bot/__init__.py b/app/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bot/handlers/__init__.py b/app/bot/handlers/__init__.py new file mode 100644 index 0000000..762a69e --- /dev/null +++ b/app/bot/handlers/__init__.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from aiogram import Router + +from . import errors, inline, message + + +def setup_router() -> Router: + router = Router() + router.include_router(message.router) + router.include_router(inline.router) + router.include_router(errors.router) + return router + diff --git a/app/bot/handlers/errors.py b/app/bot/handlers/errors.py new file mode 100644 index 0000000..cd7988e --- /dev/null +++ b/app/bot/handlers/errors.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import logging + +from aiogram import Router +from aiogram.exceptions import TelegramBadRequest +from aiogram.types import ErrorEvent + +router = Router() +logger = logging.getLogger(__name__) + + +@router.errors() +async def log_errors(event: ErrorEvent) -> None: + if isinstance(event.exception, TelegramBadRequest) and "message is not modified" in str( + event.exception, + ).lower(): + return + logger.exception("Unhandled bot error", exc_info=event.exception) + diff --git a/app/bot/handlers/inline.py b/app/bot/handlers/inline.py new file mode 100644 index 0000000..7eb52c8 --- /dev/null +++ b/app/bot/handlers/inline.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator + +from aiogram import Bot, Router, types +from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter + +from app.bot.keyboards.common import inline_answer_keyboard, inline_suggestions_keyboard +from app.bot.utils.streaming import OpenAIStreamAggregator, StreamUpdate, drain_stream +from app.config import get_settings +from app.services.openai_client import resilient_stream +from app.storage.repo import AnswerRepository, session_scope + +router = Router() + + +@router.inline_query() +async def inline_query_handler(query: types.InlineQuery) -> None: + user_query = query.query.strip() + if not user_query: + user_query = "Сформулируйте запрос" + description = f"{user_query[:64]}" if user_query else "Введите текст" + results = [ + types.InlineQueryResultArticle( + id="text", + title="Написать ответ", + description=description, + input_message_content=types.InputTextMessageContent( + message_text="Генерирую…", + parse_mode="HTML", + ), + reply_markup=inline_suggestions_keyboard(), + ), + types.InlineQueryResultArticle( + id="image", + title="Сгенерировать картинку", + description=description, + input_message_content=types.InputTextMessageContent( + message_text="Сорри, пока не готово", + ), + reply_markup=inline_suggestions_keyboard(), + ), + ] + await query.answer(results=results, cache_time=0, is_personal=True) + + +async def _edit_inline(bot: Bot, inline_message_id: str, text: str) -> None: + delay = 1.0 + while True: + try: + await bot.edit_message_text( + inline_message_id=inline_message_id, + text=text, + parse_mode="HTML", + ) + break + except TelegramRetryAfter as exc: + await asyncio.sleep(exc.retry_after) + except TelegramBadRequest as exc: + if "message is not modified" in exc.message.lower(): + break + await asyncio.sleep(delay) + delay = min(delay * 2, 8.0) + + +@router.chosen_inline_result() +async def chosen_inline(result: types.ChosenInlineResult, bot: Bot) -> None: + if result.result_id != "text" or not result.inline_message_id: + return + prompt = result.query + if not prompt: + await _edit_inline(bot, result.inline_message_id, "Нужен текст запроса") + return + aggregator = OpenAIStreamAggregator() + + async def sse_iterator() -> AsyncIterator[str]: + async for chunk in resilient_stream( + [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": prompt}, + ], + ): + yield chunk + + async def on_update(update: StreamUpdate) -> None: + await _edit_inline(bot, result.inline_message_id, update.payload.text) + + settings = get_settings() + try: + await drain_stream(aggregator, sse_iterator(), on_update) + full_text = await aggregator.get_full_text() + async with session_scope() as session: + repo = AnswerRepository(session) + answer = await repo.create_answer( + chat_id=None, + message_id=None, + inline_query_id=result.inline_message_id, + mode="inline", + prompt=prompt, + answer_md=full_text, + model=settings.openai_model, + ) + keyboard = inline_answer_keyboard(answer.id) + await bot.edit_message_reply_markup( + inline_message_id=result.inline_message_id, + reply_markup=keyboard, + ) + except Exception: + await _edit_inline(bot, result.inline_message_id, "Не удалось сгенерировать ответ") + raise + diff --git a/app/bot/handlers/message.py b/app/bot/handlers/message.py new file mode 100644 index 0000000..afebe7e --- /dev/null +++ b/app/bot/handlers/message.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator + +from aiogram import Router, types +from aiogram.enums import ChatAction +from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter +from aiogram.filters import Command + +from app.bot.keyboards.common import answer_keyboard, start_keyboard +from app.bot.utils.streaming import OpenAIStreamAggregator, StreamUpdate, drain_stream +from app.config import get_settings +from app.services.openai_client import resilient_stream +from app.storage.repo import AnswerRepository, session_scope + +router = Router() + + +@router.message(Command("start")) +async def cmd_start(message: types.Message) -> None: + settings = get_settings() + text = ( + "Привет! Я бот-помощник с тремя режимами:\n\n" + "• Обычный чат — напиши запрос, и я отвечу стримингом.\n" + "• Инлайн — попробуй в любом чате: @%s <вопрос>.\n" + "• Мини-апп — сохраню полный ответ и красиво его покажу." % ( + settings.telegram_bot_username or "bot" + ) + ) + await message.answer( + text, + reply_markup=start_keyboard(), + input_field_placeholder="Спроси что-нибудь...", + ) + + +async def _edit_with_backoff(message: types.Message, text: str) -> None: + delay = 1.0 + while True: + try: + await message.edit_text(text, parse_mode="HTML") + break + except TelegramRetryAfter as exc: + await asyncio.sleep(exc.retry_after) + except TelegramBadRequest as exc: + if "message is not modified" in exc.message.lower(): + break + await asyncio.sleep(delay) + delay = min(delay * 2, 8.0) + + +@router.message() +async def handle_text(message: types.Message) -> None: + if not message.text: + return + await message.answer_chat_action(ChatAction.TYPING) + status = await message.reply("Генерирую…") + aggregator = OpenAIStreamAggregator() + + async def sse_iterator() -> AsyncIterator[str]: + async for chunk in resilient_stream( + [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": message.text}, + ], + ): + yield chunk + + async def on_update(update: StreamUpdate) -> None: + await _edit_with_backoff(status, update.payload.text) + + try: + await drain_stream(aggregator, sse_iterator(), on_update) + full_text = await aggregator.get_full_text() + settings = get_settings() + async with session_scope() as session: + repo = AnswerRepository(session) + answer = await repo.create_answer( + chat_id=str(message.chat.id), + message_id=status.message_id, + inline_query_id=None, + mode="chat", + prompt=message.text, + answer_md=full_text, + model=settings.openai_model, + ) + await status.edit_reply_markup(reply_markup=answer_keyboard(answer.id)) + except Exception: + await status.edit_text("Произошла ошибка. Попробуйте позже.") + raise + diff --git a/app/bot/keyboards/__init__.py b/app/bot/keyboards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bot/keyboards/common.py b/app/bot/keyboards/common.py new file mode 100644 index 0000000..43ea2aa --- /dev/null +++ b/app/bot/keyboards/common.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo + +from app.config import get_settings + + +def webapp_button(text: str, *, path: str = "") -> InlineKeyboardButton: + settings = get_settings() + url = f"{settings.base_webapp_url.rstrip('/')}/{path.lstrip('/')}" + return InlineKeyboardButton(text=text, web_app=WebAppInfo(url=url)) + + +def start_keyboard() -> InlineKeyboardMarkup: + button = webapp_button("Открыть мини-апп", path="view") + return InlineKeyboardMarkup( + inline_keyboard=[[button]], + ) + + +def answer_keyboard(answer_id: str) -> InlineKeyboardMarkup: + settings = get_settings() + url = f"{settings.base_webapp_url.rstrip('/')}/view?mid={answer_id}" + button = InlineKeyboardButton(text="Открыть полный ответ", web_app=WebAppInfo(url=url)) + return InlineKeyboardMarkup( + inline_keyboard=[[button]], + ) + + +def inline_suggestions_keyboard() -> InlineKeyboardMarkup: + settings = get_settings() + username = settings.telegram_bot_username or settings.telegram_bot_token.split(":")[0] + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="Перейти в бота", url=f"https://t.me/{username}")], + [InlineKeyboardButton( + text="Мини-апп", + web_app=WebAppInfo( + url=f"{settings.base_webapp_url.rstrip('/')}/inline?source=inline", + ), + )], + ], + ) + + +def inline_answer_keyboard(answer_id: str) -> InlineKeyboardMarkup: + settings = get_settings() + username = settings.telegram_bot_username or settings.telegram_bot_token.split(":")[0] + view_button = InlineKeyboardButton( + text="Открыть полный ответ", + web_app=WebAppInfo( + url=f"{settings.base_webapp_url.rstrip('/')}/view?mid={answer_id}", + ), + ) + open_bot = InlineKeyboardButton(text="Перейти в бота", url=f"https://t.me/{username}") + return InlineKeyboardMarkup( + inline_keyboard=[[view_button], [open_bot]], + ) + diff --git a/app/bot/main.py b/app/bot/main.py new file mode 100644 index 0000000..d5880e0 --- /dev/null +++ b/app/bot/main.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import logging + +from aiogram import Bot, Dispatcher +from aiogram.enums import ParseMode + +from app.bot.handlers import setup_router +from app.config import get_settings +from app.storage.repo import init_db + +logger = logging.getLogger(__name__) + + +async def create_bot() -> Bot: + settings = get_settings() + return Bot(token=settings.telegram_bot_token, parse_mode=ParseMode.HTML) + + +async def create_dispatcher() -> Dispatcher: + dispatcher = Dispatcher() + dispatcher.include_router(setup_router()) + return dispatcher + + +async def on_startup(bot: Bot) -> None: + await init_db() + settings = get_settings() + await bot.set_webhook(settings.webhook_url, drop_pending_updates=True) + logger.info("Webhook set to %s", settings.webhook_url) + + +async def on_shutdown(bot: Bot) -> None: + await bot.delete_webhook() + logger.info("Webhook removed") + diff --git a/app/bot/middlewares/__init__.py b/app/bot/middlewares/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bot/utils/__init__.py b/app/bot/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bot/utils/streaming.py b/app/bot/utils/streaming.py new file mode 100644 index 0000000..9d3765a --- /dev/null +++ b/app/bot/utils/streaming.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import asyncio +import time +from dataclasses import dataclass + +from app.services.markdown import RenderedMessage, render_for_telegram, truncate_for_telegram + + +@dataclass(slots=True) +class StreamUpdate: + payload: RenderedMessage + is_final: bool + + +class OpenAIStreamAggregator: + """Collects streamed deltas and produces throttled Telegram-ready updates.""" + + def __init__( + self, + min_edit_interval: float = 1.0, + chunk_char_limit: int = 480, + max_message_length: int = 3900, + ) -> None: + self._min_edit_interval = min_edit_interval + self._chunk_char_limit = chunk_char_limit + self._max_message_length = max_message_length + self._full_text: list[str] = [] + self._sent_index = 0 + self._last_emit = 0.0 + self._truncated = False + self._lock = asyncio.Lock() + + async def push(self, delta: str) -> None: + async with self._lock: + self._full_text.append(delta) + + async def build_update(self) -> StreamUpdate | None: + async with self._lock: + now = time.monotonic() + if self._truncated: + return None + if now - self._last_emit < self._min_edit_interval: + return None + consumed = self._consume_chunk() + if consumed is None: + return None + text = "".join(self._full_text)[: self._sent_index] + html_message = render_for_telegram(text) + rendered = truncate_for_telegram(html_message, self._max_message_length) + if rendered.truncated: + self._truncated = True + self._last_emit = now + return StreamUpdate(payload=rendered, is_final=False) + + async def finalize(self) -> StreamUpdate: + async with self._lock: + text = "".join(self._full_text) + self._sent_index = len(text) + html_message = render_for_telegram(text) + rendered = truncate_for_telegram(html_message, self._max_message_length) + if rendered.truncated: + self._truncated = True + return StreamUpdate(payload=rendered, is_final=True) + + async def get_full_text(self) -> str: + async with self._lock: + return "".join(self._full_text) + + def _consume_chunk(self) -> str | None: + full_text = "".join(self._full_text) + if self._sent_index >= len(full_text): + return None + remaining = full_text[self._sent_index :] + if not remaining.strip(): + return None + boundary = self._find_boundary(remaining) + if boundary == 0: + return None + self._sent_index += boundary + return remaining[:boundary] + + def _find_boundary(self, text: str) -> int: + limit = min(len(text), self._chunk_char_limit) + preferred_delimiters = [". ", "!", "?", "\n"] + slice_text = text[:limit] + best = -1 + for delimiter in preferred_delimiters: + idx = slice_text.rfind(delimiter) + if idx > best: + best = idx + (len(delimiter) if idx != -1 else 0) + if best <= 0 and len(text) > limit: + return limit + if best <= 0: + return len(text) + return best + + +async def drain_stream( + aggregator: OpenAIStreamAggregator, + sse_iterator: asyncio.AsyncIterator[str], + on_update: callable[[StreamUpdate], asyncio.Future | asyncio.Task | None], +) -> StreamUpdate: + async for delta in sse_iterator: + await aggregator.push(delta) + update = await aggregator.build_update() + if update: + task = on_update(update) + if task is not None: + await asyncio.shield(task) + final_update = await aggregator.finalize() + task = on_update(final_update) + if task is not None: + await asyncio.shield(task) + return final_update + diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..a2df25e --- /dev/null +++ b/app/config.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from functools import lru_cache +from typing import Literal + +from pydantic import BaseSettings, HttpUrl + + +class Settings(BaseSettings): + telegram_bot_token: str + telegram_bot_username: str | None = None + openai_api_key: str + base_webapp_url: HttpUrl + webhook_url: HttpUrl + database_url: str = "sqlite+aiosqlite:///./bot.db" + openai_model: str = "gpt-4o-mini" + openai_temperature: float = 0.7 + openai_max_tokens: int | None = None + environment: Literal["development", "production"] = "development" + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + case_sensitive = False + + +@lru_cache +def get_settings() -> Settings: + return Settings() + diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/markdown.py b/app/services/markdown.py new file mode 100644 index 0000000..55c7b89 --- /dev/null +++ b/app/services/markdown.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import html +from dataclasses import dataclass + +import bleach +from markdown_it import MarkdownIt +from mdit_py_plugins.amsmath import amsmath_plugin +from mdit_py_plugins.dollarmath import dollarmath_plugin +from mdit_py_plugins.footnote import footnote_plugin +from mdit_py_plugins.tasklists import tasklists_plugin + +__all__ = [ + "markdown_to_html", + "render_for_telegram", + "truncate_for_telegram", + "RenderedMessage", +] + +_markdown = MarkdownIt("commonmark", {'linkify': True, 'typographer': True}) +_markdown.enable('table') +_markdown.enable('strikethrough') +_markdown.use(tasklists_plugin) +_markdown.use(footnote_plugin) +_markdown.use(dollarmath_plugin) +_markdown.use(amsmath_plugin) + +_ALLOWED_TAGS = { + "a", + "abbr", + "b", + "blockquote", + "code", + "del", + "em", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "img", + "li", + "ol", + "p", + "pre", + "span", + "strong", + "sup", + "sub", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + "ul", +} + +_ALLOWED_ATTRS = { + "a": ["href", "title", "target", "rel"], + "img": ["src", "alt", "title", "width", "height"], + "th": ["align"], + "td": ["align"], + "span": ["class"], + "code": ["class"], +} + +_ALLOWED_PROTOCOLS = ["http", "https", "tg", "mailto", "data"] + + +def markdown_to_html(markdown_text: str) -> str: + """Render Markdown into sanitized HTML suitable for the mini-app.""" + raw_html = _markdown.render(markdown_text) + cleaned = bleach.clean( + raw_html, + tags=_ALLOWED_TAGS, + attributes=_ALLOWED_ATTRS, + protocols=_ALLOWED_PROTOCOLS, + strip=True, + ) + return cleaned + + +def render_for_telegram(text: str) -> str: + """Escape text for Telegram HTML parse mode.""" + return html.escape(text, quote=False) + + +@dataclass(slots=True) +class RenderedMessage: + text: str + truncated: bool + + +def truncate_for_telegram(text: str, limit: int = 3900) -> RenderedMessage: + if len(text) <= limit: + return RenderedMessage(text=text, truncated=False) + + truncated_text = text[:limit - 40].rsplit(" ", 1)[0] + truncated_text = truncated_text.rstrip() + "\n\nСм. полностью в мини-аппе" + return RenderedMessage(text=truncated_text, truncated=True) + diff --git a/app/services/openai_client.py b/app/services/openai_client.py new file mode 100644 index 0000000..4b967f4 --- /dev/null +++ b/app/services/openai_client.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncGenerator, Iterable + +import httpx + +from app.config import get_settings + + +class OpenAIError(RuntimeError): + pass + + +class OpenAIClient: + def __init__(self) -> None: + self._settings = get_settings() + self._client = httpx.AsyncClient( + base_url="https://api.openai.com/v1", + headers={ + "Authorization": f"Bearer {self._settings.openai_api_key}", + "Content-Type": "application/json", + }, + timeout=httpx.Timeout(60.0, read=None), + ) + + async def stream_chat_completion( + self, + messages: Iterable[dict[str, str]], + temperature: float | None = None, + max_tokens: int | None = None, + ) -> AsyncGenerator[str, None]: + payload = { + "model": self._settings.openai_model, + "messages": list(messages), + "stream": True, + } + if temperature is not None: + payload["temperature"] = temperature + if max_tokens is not None: + payload["max_tokens"] = max_tokens + async with self._client.stream("POST", "/chat/completions", json=payload) as response: + if response.status_code >= 400: + text = await response.aread() + raise OpenAIError(text.decode()) + async for line in response.aiter_lines(): + if not line: + continue + if line.startswith("data: "): + data = line.removeprefix("data: ").strip() + if data == "[DONE]": + break + chunk = json.loads(data) + delta = chunk["choices"][0]["delta"].get("content") + if delta: + yield delta + + async def generate_image(self, prompt: str, size: str = "1024x1024") -> dict[str, str]: + payload = { + "model": "gpt-image-1", + "prompt": prompt, + "size": size, + } + response = await self._client.post("/images/generations", json=payload) + if response.status_code >= 400: + raise OpenAIError(response.text) + result = response.json() + return result["data"][0] + + async def close(self) -> None: + await self._client.aclose() + + +async def stream_chat(messages: Iterable[dict[str, str]]) -> AsyncGenerator[str, None]: + client = OpenAIClient() + try: + async for chunk in client.stream_chat_completion(messages): + yield chunk + finally: + await client.close() + + +async def resilient_stream( + messages: Iterable[dict[str, str]], + *, + retries: int = 3, + base_delay: float = 1.0, +) -> AsyncGenerator[str, None]: + attempt = 0 + while True: + try: + async for chunk in stream_chat(messages): + yield chunk + break + except OpenAIError: + if attempt >= retries: + raise + await asyncio.sleep(base_delay * (2 ** attempt)) + attempt += 1 + diff --git a/app/storage/__init__.py b/app/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/storage/models.py b/app/storage/models.py new file mode 100644 index 0000000..f5c46b0 --- /dev/null +++ b/app/storage/models.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import datetime as dt +import uuid + +from sqlalchemy import DateTime, Integer, MetaData, String, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +metadata = MetaData() + + +class Base(DeclarativeBase): + metadata = metadata + + +class Answer(Base): + __tablename__ = "answers" + + id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid.uuid4().hex) + chat_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + message_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + inline_query_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + mode: Mapped[str] = mapped_column(String(32)) + prompt: Mapped[str] = mapped_column(Text) + answer_md: Mapped[str] = mapped_column(Text) + answer_html: Mapped[str] = mapped_column(Text) + model: Mapped[str] = mapped_column(String(64)) + tokens_used: Mapped[int | None] = mapped_column(Integer, nullable=True) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), default=lambda: dt.datetime.now(dt.UTC), + ) + diff --git a/app/storage/repo.py b/app/storage/repo.py new file mode 100644 index 0000000..210ec00 --- /dev/null +++ b/app/storage/repo.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from app.config import get_settings +from app.services.markdown import markdown_to_html +from app.storage.models import Answer, Base + +_ENGINE: AsyncEngine | None = None +_SESSION_FACTORY: async_sessionmaker[AsyncSession] | None = None + + +def _create_engine() -> AsyncEngine: + global _ENGINE, _SESSION_FACTORY + if _ENGINE is None: + settings = get_settings() + echo_flag = settings.environment == "development" + _ENGINE = create_async_engine(settings.database_url, echo=echo_flag) + _SESSION_FACTORY = async_sessionmaker(_ENGINE, expire_on_commit=False) + return _ENGINE + + +def get_session_factory() -> async_sessionmaker[AsyncSession]: + if _SESSION_FACTORY is None: + _create_engine() + if _SESSION_FACTORY is None: + raise RuntimeError("Session factory is not initialized") + return _SESSION_FACTORY + + +async def init_db() -> None: + engine = _create_engine() + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +@asynccontextmanager +def session_scope() -> AsyncIterator[AsyncSession]: + factory = get_session_factory() + async with factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + +class AnswerRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create_answer( + self, + *, + chat_id: str | None, + message_id: int | None, + inline_query_id: str | None, + mode: str, + prompt: str, + answer_md: str, + model: str, + tokens_used: int | None = None, + ) -> Answer: + answer_html = markdown_to_html(answer_md) + answer = Answer( + chat_id=chat_id, + message_id=message_id, + inline_query_id=inline_query_id, + mode=mode, + prompt=prompt, + answer_md=answer_md, + answer_html=answer_html, + model=model, + tokens_used=tokens_used, + ) + self._session.add(answer) + await self._session.flush() + return answer + + async def get_answer(self, answer_id: str) -> Answer | None: + return await self._session.get(Answer, answer_id) + diff --git a/app/web/__init__.py b/app/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/web/auth.py b/app/web/auth.py new file mode 100644 index 0000000..55ea5c5 --- /dev/null +++ b/app/web/auth.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import hashlib +import hmac +from urllib.parse import parse_qsl + +from fastapi import HTTPException + +from app.config import get_settings + + +def verify_init_data(init_data: str) -> None: + settings = get_settings() + if settings.environment == "development": + return + if not init_data: + raise HTTPException(status_code=401, detail="Missing init data") + data = dict(parse_qsl(init_data, keep_blank_values=True)) + received_hash = data.pop("hash", None) + if not received_hash: + raise HTTPException(status_code=401, detail="Missing hash") + data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(data.items())) + secret_key = hmac.new( + key=b"WebAppData", + msg=settings.telegram_bot_token.encode(), + digestmod=hashlib.sha256, + ).digest() + calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(calculated_hash, received_hash): + raise HTTPException(status_code=401, detail="Invalid init data") + diff --git a/app/web/main.py b/app/web/main.py new file mode 100644 index 0000000..071a21c --- /dev/null +++ b/app/web/main.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import logging +from pathlib import Path + +from aiogram import Bot, Dispatcher +from aiogram.types import Update +from fastapi import FastAPI, Header, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel + +from app.bot.main import create_bot, create_dispatcher, on_shutdown, on_startup +from app.config import get_settings +from app.services.markdown import markdown_to_html +from app.storage.repo import AnswerRepository, session_scope +from app.web.auth import verify_init_data + +logger = logging.getLogger(__name__) + +app = FastAPI(title="Telegram AI Assistant") + +templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates")) +app.mount( + "/static", + StaticFiles(directory=str(Path(__file__).parent / "static")), + name="static", +) + +_bot: Bot | None = None +dispatcher: Dispatcher | None = None + + +@app.on_event("startup") +async def startup_event() -> None: + global _bot, dispatcher + _bot = await create_bot() + dispatcher = await create_dispatcher() + await on_startup(_bot) + logger.info("FastAPI application started") + + +@app.on_event("shutdown") +async def shutdown_event() -> None: + if _bot is not None: + await on_shutdown(_bot) + await _bot.session.close() + logger.info("FastAPI application stopped") + + +class RenderPayload(BaseModel): + markdown: str + + +@app.get("/healthz") +async def health() -> dict[str, str]: + return {"status": "ok"} + + +@app.post("/api/render") +async def render_endpoint( + payload: RenderPayload, + init_data: str = Header(default="", alias="X-Telegram-Init-Data"), +) -> dict[str, str]: + verify_init_data(init_data) + return {"html": markdown_to_html(payload.markdown)} + + +@app.get("/api/answers") +async def answers( + mid: str, + init_data: str = Header(default="", alias="X-Telegram-Init-Data"), +) -> dict[str, object]: + verify_init_data(init_data) + async with session_scope() as session: + repo = AnswerRepository(session) + answer = await repo.get_answer(mid) + if not answer: + raise HTTPException(status_code=404, detail="Answer not found") + return { + "markdown": answer.answer_md, + "html": answer.answer_html, + "meta": { + "model": answer.model, + "created_at": answer.created_at.isoformat(), + "mode": answer.mode, + }, + } + + +@app.post("/webhook") +async def telegram_webhook(request: Request) -> JSONResponse: + if _bot is None or dispatcher is None: + raise HTTPException(status_code=503, detail="Bot not ready") + data = await request.json() + update = Update.model_validate(data) + await dispatcher.feed_update(_bot, update) + return JSONResponse({"ok": True}) + + +@app.get("/view", response_class=HTMLResponse) +async def view_page(request: Request, mid: str | None = None) -> HTMLResponse: + settings = get_settings() + return templates.TemplateResponse( + "webapp.html", + { + "request": request, + "mode": "view", + "answer_id": mid, + "bot_username": ( + settings.telegram_bot_username + or settings.telegram_bot_token.split(":")[0] + ), + }, + ) + + +@app.get("/inline", response_class=HTMLResponse) +async def inline_page(request: Request) -> HTMLResponse: + settings = get_settings() + return templates.TemplateResponse( + "webapp.html", + { + "request": request, + "mode": "inline", + "answer_id": None, + "bot_username": ( + settings.telegram_bot_username + or settings.telegram_bot_token.split(":")[0] + ), + }, + ) + diff --git a/app/web/static/webapp.css b/app/web/static/webapp.css new file mode 100644 index 0000000..96ab1d5 --- /dev/null +++ b/app/web/static/webapp.css @@ -0,0 +1,88 @@ +:root { + color-scheme: light dark; + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +body { + margin: 0; + background-color: var(--tg-theme-bg-color, #0f1117); + color: var(--tg-theme-text-color, #f5f6f8); +} + +.app { + min-height: 100vh; + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + box-sizing: border-box; +} + +.app__header h1 { + margin: 0; + font-size: 1.25rem; +} + +.app__content { + flex: 1; + overflow-y: auto; +} + +.markdown { + line-height: 1.6; +} + +.markdown pre { + background: rgba(0, 0, 0, 0.2); + padding: 12px; + border-radius: 8px; + overflow-x: auto; +} + +.markdown table { + width: 100%; + border-collapse: collapse; + margin: 12px 0; +} + +.markdown th, +.markdown td { + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 6px 8px; +} + +.meta { + margin-top: 12px; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.7); +} + +.app__footer { + display: flex; + flex-direction: column; + gap: 10px; +} + +.btn { + padding: 12px; + border-radius: 8px; + border: none; + font-size: 1rem; + cursor: pointer; +} + +.btn--primary { + background: var(--tg-theme-button-color, #2a8af6); + color: var(--tg-theme-button-text-color, #fff); +} + +.btn--secondary { + background: transparent; + color: inherit; + border: 1px solid rgba(255, 255, 255, 0.3); +} + +.hidden { + display: none; +} + diff --git a/app/web/static/webapp.js b/app/web/static/webapp.js new file mode 100644 index 0000000..12dcb38 --- /dev/null +++ b/app/web/static/webapp.js @@ -0,0 +1,125 @@ +const tg = window.Telegram?.WebApp; +const root = document.body; +const mode = root.dataset.mode; +const answerId = root.dataset.answerId; +const botUsername = root.dataset.botUsername; +const statusEl = document.getElementById('status'); +const answerEl = document.getElementById('answer'); +const metaEl = document.getElementById('meta'); +const shareBtn = document.getElementById('share-btn'); +const openBotBtn = document.getElementById('open-bot-btn'); +const generateImageBtn = document.getElementById('generate-image-btn'); + +let hljs = null; + +async function ensureHighlight() { + if (hljs) return hljs; + const mod = await import('https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/es/core.min.js'); + const js = await import('https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/es/languages/javascript.min.js'); + const py = await import('https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/es/languages/python.min.js'); + const bash = await import('https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/es/languages/bash.min.js'); + mod.default.registerLanguage('javascript', js.default); + mod.default.registerLanguage('python', py.default); + mod.default.registerLanguage('bash', bash.default); + hljs = mod.default; + return hljs; +} + +function applyTheme() { + if (!tg) return; + root.style.setProperty('--tg-theme-bg-color', tg.themeParams.bg_color || '#0f1117'); + root.style.setProperty('--tg-theme-text-color', tg.themeParams.text_color || '#f5f6f8'); +} + +async function renderAnswer(data) { + const sanitized = DOMPurify.sanitize(data.html || ''); + answerEl.innerHTML = sanitized; + const info = data.meta || {}; + const created = info.created_at ? new Date(info.created_at).toLocaleString() : ''; + metaEl.textContent = `Модель: ${info.model || '—'} • Режим: ${info.mode || '—'} • Время: ${created}`; + statusEl.textContent = 'Готово'; + const highlighter = await ensureHighlight(); + document.querySelectorAll('pre code').forEach((block) => { + highlighter.highlightElement(block); + }); +} + +async function fetchAnswer(mid) { + if (!mid) { + statusEl.textContent = 'Ответ не найден'; + return; + } + try { + const resp = await fetch(`/api/answers?mid=${encodeURIComponent(mid)}`, { + headers: { + 'X-Telegram-Init-Data': tg?.initData || '', + }, + }); + if (!resp.ok) { + throw new Error('Ответ не найден'); + } + const data = await resp.json(); + await renderAnswer(data); + } catch (error) { + statusEl.textContent = error.message || 'Ошибка загрузки'; + } +} + +function setupButtons() { + shareBtn.addEventListener('click', async () => { + if (!answerId) return; + const url = new URL(window.location.href); + url.searchParams.set('mid', answerId); + await navigator.clipboard.writeText(url.toString()); + statusEl.textContent = 'Ссылка скопирована'; + setTimeout(() => (statusEl.textContent = 'Готово'), 2000); + }); + + openBotBtn.addEventListener('click', () => { + const link = `https://t.me/${botUsername}`; + if (tg?.openTelegramLink) { + tg.openTelegramLink(link); + } else { + window.open(link, '_blank'); + } + }); + + generateImageBtn.addEventListener('click', () => { + if (tg?.switchInlineQuery) { + tg.switchInlineQuery('Generate image ', true); + } else { + statusEl.textContent = 'Откройте бот в Telegram'; + } + }); +} + +function configureInlineMode() { + if (mode !== 'inline') { + generateImageBtn.classList.add('hidden'); + return; + } + generateImageBtn.classList.remove('hidden'); + statusEl.textContent = 'Готово к генерации'; + if (tg) { + tg.MainButton.setText('Сгенерировать изображение'); + tg.MainButton.onClick(() => tg.switchInlineQuery('Generate image ', true)); + tg.MainButton.show(); + tg.BackButton.show(); + tg.BackButton.onClick(() => tg.close()); + } +} + +(async function init() { + if (tg) { + tg.ready(); + applyTheme(); + } + setupButtons(); + configureInlineMode(); + if (mode === 'view' && answerId) { + await fetchAnswer(answerId); + } else if (mode === 'view') { + statusEl.textContent = 'Передан пустой идентификатор ответа'; + } +})(); + diff --git a/app/web/templates/webapp.html b/app/web/templates/webapp.html new file mode 100644 index 0000000..adf800d --- /dev/null +++ b/app/web/templates/webapp.html @@ -0,0 +1,30 @@ + + + + + + AI Assistant + + + + + + +
+
+

AI Assistant

+

Загружаем ответ…

+
+
+
+
+
+
+ + + +
+
+ + + diff --git a/main.py b/main.py new file mode 100644 index 0000000..970bce0 --- /dev/null +++ b/main.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import argparse +import asyncio +import logging + +import uvicorn + +from app.bot.main import create_bot, create_dispatcher +from app.storage.repo import init_db + +logging.basicConfig(level=logging.INFO) + + +async def run_bot_polling() -> None: + await init_db() + bot = await create_bot() + dispatcher = await create_dispatcher() + try: + await dispatcher.start_polling(bot) + finally: + await bot.session.close() + + +def main() -> None: + parser = argparse.ArgumentParser(description="Telegram AI Assistant") + parser.add_argument("mode", choices=["bot", "web"], help="Запуск бота или веб-сервера") + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=8000) + args = parser.parse_args() + + if args.mode == "bot": + asyncio.run(run_bot_polling()) + elif args.mode == "web": + uvicorn.run("app.web.main:app", host=args.host, port=args.port, reload=False) + + +if __name__ == "__main__": + main() + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b274d3a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "tg-ai-assistant" +version = "0.1.0" +description = "Telegram bot with streaming AI responses and WebApp viewer" +authors = [{name = "Senior Dev"}] +dependencies = [ + "aiogram==3.4.1", + "fastapi==0.110.0", + "uvicorn[standard]==0.29.0", + "pydantic-settings==2.2.1", + "SQLAlchemy==2.0.29", + "aiosqlite==0.19.0", + "httpx==0.27.0", + "markdown-it-py[plugins]==3.0.0", + "bleach==6.1.0", + "python-multipart==0.0.9", +] +requires-python = ">=3.11" + +[project.optional-dependencies] +dev = [ + "pytest==8.2.0", + "pytest-asyncio==0.23.6", + "anyio==4.2.0", + "fakeredis==2.22.0", + "ruff==0.4.0", +] + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "N", "ANN", "ASYNC", "S", "B", "COM", "C4"] +ignore = ["ANN101", "ANN102"] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101", "ANN201", "ANN001"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" + diff --git a/tests/test_markdown.py b/tests/test_markdown.py new file mode 100644 index 0000000..cc690fe --- /dev/null +++ b/tests/test_markdown.py @@ -0,0 +1,12 @@ +from app.services.markdown import markdown_to_html, render_for_telegram + + +def test_markdown_sanitized(): + html = markdown_to_html("**bold** ") + assert "script" not in html + assert "bold" in html + + +def test_render_for_telegram(): + assert render_for_telegram('test') == '<b>test</b>' + diff --git a/tests/test_streaming.py b/tests/test_streaming.py new file mode 100644 index 0000000..41763da --- /dev/null +++ b/tests/test_streaming.py @@ -0,0 +1,32 @@ +import pytest + +from app.bot.utils.streaming import OpenAIStreamAggregator +from app.services.markdown import truncate_for_telegram + + +@pytest.mark.asyncio +async def test_streaming_emits_chunk(monkeypatch): + aggregator = OpenAIStreamAggregator(min_edit_interval=0.0, chunk_char_limit=50) + await aggregator.push("Hello bold world.") + monkeypatch.setattr("app.bot.utils.streaming.time.monotonic", lambda: 1.0) + update = await aggregator.build_update() + assert update is not None + assert "<b>" in update.payload.text + assert not update.is_final + + +@pytest.mark.asyncio +async def test_streaming_finalize(monkeypatch): + aggregator = OpenAIStreamAggregator(min_edit_interval=0.0) + await aggregator.push("Sentence one. Sentence two.") + final = await aggregator.finalize() + assert final.is_final + assert "Sentence two" in final.payload.text + + +def test_truncate_for_telegram(): + long_text = "A" * 5000 + rendered = truncate_for_telegram(long_text) + assert rendered.truncated + assert "См. полностью" in rendered.text +