Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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

119 changes: 119 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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=<id>` — отображение сохранённого ответа (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" }`.

Empty file added app/__init__.py
Empty file.
Empty file added app/bot/__init__.py
Empty file.
14 changes: 14 additions & 0 deletions app/bot/handlers/__init__.py
Original file line number Diff line number Diff line change
@@ -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

20 changes: 20 additions & 0 deletions app/bot/handlers/errors.py
Original file line number Diff line number Diff line change
@@ -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)

112 changes: 112 additions & 0 deletions app/bot/handlers/inline.py
Original file line number Diff line number Diff line change
@@ -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

92 changes: 92 additions & 0 deletions app/bot/handlers/message.py
Original file line number Diff line number Diff line change
@@ -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

Empty file added app/bot/keyboards/__init__.py
Empty file.
Loading