added RAG, Multiuser, TG bot
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# Telegram Bot API token from @BotFather
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
|
||||
# Home Assistant backend URL (must be reachable from VPS)
|
||||
HA_API_BASE_URL=https://home.example.com/api/v1
|
||||
|
||||
# How often to poll for notices (seconds)
|
||||
POLL_INTERVAL_SEC=30
|
||||
|
||||
# SQLite data directory
|
||||
DATA_DIR=./data
|
||||
|
||||
# Optional: comma-separated Telegram user IDs allowed to use the bot
|
||||
ALLOWED_TELEGRAM_IDS=
|
||||
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
data/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY bot ./bot
|
||||
|
||||
CMD ["python", "-m", "bot.main"]
|
||||
@@ -0,0 +1,145 @@
|
||||
# Home Assistant Telegram Bot
|
||||
|
||||
Telegram-бот для удалённого доступа к домашнему ассистенту Home Assistant. Работает на отдельном VPS и общается с backend через REST API.
|
||||
|
||||
## Возможности
|
||||
|
||||
- Привязка API-токена Home Assistant через чат Telegram
|
||||
- Отдельная chat-сессия «Telegram» на каждого пользователя
|
||||
- Диалог с ассистентом (SSE-стриминг ответа)
|
||||
- Дублирование оповещений (`notice`, `character`) из всех сессий пользователя
|
||||
|
||||
## Требования
|
||||
|
||||
- Python 3.12+ или Docker
|
||||
- Telegram Bot Token ([@BotFather](https://t.me/BotFather))
|
||||
- Доступный с VPS URL backend Home Assistant (HTTPS рекомендуется)
|
||||
|
||||
Backend должен быть доступен по адресу вида:
|
||||
|
||||
```
|
||||
https://your-home-server.example.com/api/v1
|
||||
```
|
||||
|
||||
Если backend за reverse proxy, пробросьте порт `8080` или маршрут `/api/v1` наружу. CORS для бота не нужен.
|
||||
|
||||
## Быстрый старт (Docker)
|
||||
|
||||
```bash
|
||||
cd telegram-bot
|
||||
cp .env.example .env
|
||||
# заполните TELEGRAM_BOT_TOKEN и HA_API_BASE_URL
|
||||
docker compose up -d --build
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
## Быстрый старт (без Docker)
|
||||
|
||||
```bash
|
||||
cd telegram-bot
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
# заполните .env
|
||||
python -m bot.main
|
||||
```
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
| Переменная | Описание |
|
||||
|------------|----------|
|
||||
| `TELEGRAM_BOT_TOKEN` | Токен от BotFather |
|
||||
| `HA_API_BASE_URL` | Base URL API, например `https://home.example.com/api/v1` |
|
||||
| `POLL_INTERVAL_SEC` | Интервал polling оповещений (по умолчанию 30) |
|
||||
| `DATA_DIR` | Каталог для SQLite (`./data`) |
|
||||
| `ALLOWED_TELEGRAM_IDS` | Опционально: whitelist Telegram user id через запятую |
|
||||
|
||||
## Создание API-токена на домашнем сервере
|
||||
|
||||
### Через веб-интерфейс
|
||||
|
||||
Settings → Пользователи → создать пользователя. Токен показывается один раз — сохраните его.
|
||||
|
||||
### Через CLI
|
||||
|
||||
```bash
|
||||
cd ~/to_services/Home_assistant
|
||||
docker compose exec backend python scripts/create_user.py myuser --display-name "Имя"
|
||||
```
|
||||
|
||||
Токен будет выведен в консоль.
|
||||
|
||||
## Использование бота
|
||||
|
||||
1. `/start` — инструкция
|
||||
2. Отправьте API-токен одним сообщением
|
||||
3. После успешной привязки удалите сообщение с токеном из истории Telegram
|
||||
4. Пишите ассистенту обычным текстом
|
||||
|
||||
### Команды
|
||||
|
||||
| Команда | Описание |
|
||||
|---------|----------|
|
||||
| `/start` | Приветствие и инструкция |
|
||||
| `/help` | Справка |
|
||||
| `/whoami` | Текущий пользователь HA |
|
||||
| `/logout` | Отвязать токен |
|
||||
| `/newchat` | Новая сессия «Telegram» |
|
||||
|
||||
## Оповещения
|
||||
|
||||
Фоновый worker каждые `POLL_INTERVAL_SEC` секунд:
|
||||
|
||||
1. Опрашивает `GET /pomodoro/status` и `GET /reminders` (счётчики seq)
|
||||
2. Загружает новые сообщения из **всех** chat-сессий пользователя
|
||||
3. Отправляет в Telegram сообщения с ролями `notice` и `character`
|
||||
|
||||
Это покрывает напоминания, помидоро, homelab, fitness и другие системные оповещения без изменений backend.
|
||||
|
||||
## Безопасность
|
||||
|
||||
- API-токен хранится в SQLite на VPS (`DATA_DIR/bot.db`)
|
||||
- Токен виден в истории Telegram при отправке — удалите сообщение после привязки
|
||||
- Используйте `ALLOWED_TELEGRAM_IDS`, чтобы ограничить доступ к боту
|
||||
- Не коммитьте `.env` с реальными токенами
|
||||
|
||||
## Деплой на VPS
|
||||
|
||||
```bash
|
||||
git clone <repo> && cd Home_assistant/telegram-bot
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Обновление:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Проверка
|
||||
|
||||
1. Привязать токен → `/whoami` показывает имя
|
||||
2. Написать «Привет» → ответ ассистента
|
||||
3. Создать напоминание через web → notice в TG за ~30–60 с
|
||||
4. Второй Telegram-аккаунт с другим HA-токеном → изолированные чат и оповещения
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
telegram-bot/
|
||||
bot/
|
||||
main.py # entrypoint
|
||||
config.py # env
|
||||
ha_client.py # REST + SSE клиент
|
||||
sse.py # парсер SSE
|
||||
storage.py # SQLite
|
||||
notify_worker.py # polling оповещений
|
||||
handlers/ # команды и чат
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
requirements.txt
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
"""Home Assistant Telegram bridge bot."""
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from aiogram.types import Message
|
||||
|
||||
from bot.config import Settings
|
||||
|
||||
|
||||
def is_allowed(message: Message, settings: Settings) -> bool:
|
||||
if not settings.allowed_telegram_ids:
|
||||
return True
|
||||
user = message.from_user
|
||||
if not user:
|
||||
return False
|
||||
return user.id in settings.allowed_telegram_ids
|
||||
|
||||
|
||||
def access_denied_text() -> str:
|
||||
return "У вас нет доступа к этому боту."
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _parse_allowed_ids(raw: str) -> frozenset[int]:
|
||||
ids: set[int] = set()
|
||||
for part in raw.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
ids.add(int(part))
|
||||
except ValueError:
|
||||
continue
|
||||
return frozenset(ids)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
telegram_bot_token: str
|
||||
ha_api_base_url: str
|
||||
poll_interval_sec: int
|
||||
data_dir: Path
|
||||
allowed_telegram_ids: frozenset[int]
|
||||
|
||||
@property
|
||||
def db_path(self) -> Path:
|
||||
return self.data_dir / "bot.db"
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
token = os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
|
||||
if not token:
|
||||
raise RuntimeError("TELEGRAM_BOT_TOKEN is required")
|
||||
|
||||
base_url = os.getenv("HA_API_BASE_URL", "").strip().rstrip("/")
|
||||
if not base_url:
|
||||
raise RuntimeError("HA_API_BASE_URL is required")
|
||||
|
||||
poll_raw = os.getenv("POLL_INTERVAL_SEC", "30").strip()
|
||||
try:
|
||||
poll_interval = max(10, int(poll_raw))
|
||||
except ValueError:
|
||||
poll_interval = 30
|
||||
|
||||
data_dir = Path(os.getenv("DATA_DIR", "./data")).resolve()
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
allowed = _parse_allowed_ids(os.getenv("ALLOWED_TELEGRAM_IDS", ""))
|
||||
|
||||
return Settings(
|
||||
telegram_bot_token=token,
|
||||
ha_api_base_url=base_url,
|
||||
poll_interval_sec=poll_interval,
|
||||
data_dir=data_dir,
|
||||
allowed_telegram_ids=allowed,
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
from aiogram.filters import BaseFilter
|
||||
from aiogram.types import Message
|
||||
|
||||
from bot.storage import Storage
|
||||
|
||||
|
||||
class IsLinked(BaseFilter):
|
||||
async def __call__(self, message: Message, storage: Storage) -> bool:
|
||||
if not message.from_user:
|
||||
return False
|
||||
linked = await storage.get_user(message.from_user.id)
|
||||
return linked is not None
|
||||
|
||||
|
||||
class NotLinked(BaseFilter):
|
||||
async def __call__(self, message: Message, storage: Storage) -> bool:
|
||||
if not message.from_user:
|
||||
return False
|
||||
linked = await storage.get_user(message.from_user.id)
|
||||
return linked is None
|
||||
@@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from bot.sse import SseChunk, iter_sse
|
||||
|
||||
|
||||
class HaApiError(RuntimeError):
|
||||
def __init__(self, message: str, status_code: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class HaClient:
|
||||
def __init__(self, base_url: str, token: str = "", *, timeout: float = 120.0) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token = token.strip()
|
||||
self.timeout = timeout
|
||||
|
||||
def with_token(self, token: str) -> HaClient:
|
||||
return HaClient(self.base_url, token, timeout=self.timeout)
|
||||
|
||||
def _headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
|
||||
headers: dict[str, str] = {"Accept": "application/json"}
|
||||
if extra:
|
||||
headers.update(extra)
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
return headers
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
url = f"{self.base_url}{path}"
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
headers=self._headers(
|
||||
{"Content-Type": "application/json"} if json_body is not None else None
|
||||
),
|
||||
json=json_body,
|
||||
params=params,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
detail = response.text.strip() or f"HTTP {response.status_code}"
|
||||
raise HaApiError(detail, response.status_code)
|
||||
if response.status_code == 204 or not response.content:
|
||||
return None
|
||||
return response.json()
|
||||
|
||||
async def login(self, token: str) -> dict[str, Any]:
|
||||
url = f"{self.base_url}/auth/login"
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
headers={"Content-Type": "application/json"},
|
||||
json={"token": token.strip()},
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise HaApiError(response.text.strip() or "Неверный токен", response.status_code)
|
||||
return response.json()
|
||||
|
||||
async def me(self) -> dict[str, Any]:
|
||||
return await self._request("GET", "/auth/me")
|
||||
|
||||
async def list_sessions(self) -> list[dict[str, Any]]:
|
||||
result = await self._request("GET", "/chat/sessions")
|
||||
return list(result or [])
|
||||
|
||||
async def create_session(self, title: str = "Telegram") -> dict[str, Any]:
|
||||
return await self._request("POST", "/chat/sessions", json_body={"title": title})
|
||||
|
||||
async def get_messages(
|
||||
self,
|
||||
session_id: int,
|
||||
*,
|
||||
after_id: int | None = None,
|
||||
limit: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {"limit": limit}
|
||||
if after_id is not None:
|
||||
params["after_id"] = after_id
|
||||
return await self._request(
|
||||
"GET",
|
||||
f"/chat/sessions/{session_id}/messages",
|
||||
params=params,
|
||||
)
|
||||
|
||||
async def generation_status(self, session_id: int) -> dict[str, Any]:
|
||||
return await self._request("GET", f"/chat/sessions/{session_id}/generation")
|
||||
|
||||
async def get_reminders_snapshot(self) -> dict[str, Any]:
|
||||
return await self._request("GET", "/reminders")
|
||||
|
||||
async def get_pomodoro_status(self) -> dict[str, Any]:
|
||||
return await self._request("GET", "/pomodoro/status")
|
||||
|
||||
async def _stream(self, method: str, path: str, *, json_body: dict[str, Any] | None = None) -> AsyncIterator[SseChunk]:
|
||||
url = f"{self.base_url}{path}"
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
method,
|
||||
url,
|
||||
headers=self._headers(
|
||||
{"Content-Type": "application/json", "Accept": "text/event-stream"}
|
||||
if json_body is not None
|
||||
else {"Accept": "text/event-stream"}
|
||||
),
|
||||
json=json_body,
|
||||
) as response:
|
||||
if response.status_code == 404:
|
||||
return
|
||||
async for chunk in iter_sse(response):
|
||||
yield chunk
|
||||
|
||||
async def send_message_stream(self, session_id: int, content: str) -> AsyncIterator[SseChunk]:
|
||||
async for chunk in self._stream(
|
||||
"POST",
|
||||
f"/chat/sessions/{session_id}/messages",
|
||||
json_body={"content": content},
|
||||
):
|
||||
yield chunk
|
||||
|
||||
async def stream_generation(self, session_id: int) -> AsyncIterator[SseChunk]:
|
||||
async for chunk in self._stream("GET", f"/chat/sessions/{session_id}/generation/stream"):
|
||||
yield chunk
|
||||
|
||||
async def find_or_create_telegram_session(self) -> int:
|
||||
sessions = await self.list_sessions()
|
||||
for session in sessions:
|
||||
if session.get("title") == "Telegram":
|
||||
return int(session["id"])
|
||||
created = await self.create_session("Telegram")
|
||||
return int(created["id"])
|
||||
@@ -0,0 +1,12 @@
|
||||
from aiogram import Router
|
||||
|
||||
from bot.handlers.auth import router as auth_router
|
||||
from bot.handlers.chat import router as chat_router
|
||||
from bot.handlers.commands import router as commands_router
|
||||
from bot.handlers.start import router as start_router
|
||||
|
||||
router = Router()
|
||||
router.include_router(start_router)
|
||||
router.include_router(auth_router)
|
||||
router.include_router(commands_router)
|
||||
router.include_router(chat_router)
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message
|
||||
|
||||
from bot.access import access_denied_text, is_allowed
|
||||
from bot.config import Settings
|
||||
from bot.filters import NotLinked
|
||||
from bot.ha_client import HaApiError, HaClient
|
||||
from bot.storage import Storage
|
||||
|
||||
router = Router()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _link_token(message: Message, settings: Settings, storage: Storage, token: str) -> None:
|
||||
if not message.from_user:
|
||||
return
|
||||
|
||||
telegram_id = message.from_user.id
|
||||
client = HaClient(settings.ha_api_base_url)
|
||||
|
||||
try:
|
||||
login_result = await client.login(token)
|
||||
except HaApiError as exc:
|
||||
await message.answer(f"Не удалось привязать токен: {exc}")
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("Login failed for telegram_id=%s", telegram_id)
|
||||
await message.answer("Ошибка соединения с Home Assistant. Проверь HA_API_BASE_URL на сервере бота.")
|
||||
return
|
||||
|
||||
api_token = str(login_result.get("token") or token).strip()
|
||||
user_info = login_result.get("user") or {}
|
||||
ha_user_id = int(user_info.get("id") or 0)
|
||||
display_name = str(user_info.get("display_name") or user_info.get("username") or "")
|
||||
username = str(user_info.get("username") or "")
|
||||
|
||||
ha_client = HaClient(settings.ha_api_base_url, api_token)
|
||||
try:
|
||||
session_id = await ha_client.find_or_create_telegram_session()
|
||||
except Exception:
|
||||
logger.exception("Session setup failed for telegram_id=%s", telegram_id)
|
||||
await message.answer("Токен принят, но не удалось создать чат-сессию на сервере.")
|
||||
return
|
||||
|
||||
await storage.link_user(
|
||||
telegram_id=telegram_id,
|
||||
api_token=api_token,
|
||||
ha_user_id=ha_user_id,
|
||||
display_name=display_name,
|
||||
username=username,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
name = display_name or username or f"user #{ha_user_id}"
|
||||
await message.answer(
|
||||
f"Готово! Привязан аккаунт: {name}\n"
|
||||
f"Чат-сессия Telegram: #{session_id}\n\n"
|
||||
"Рекомендую удалить сообщение с токеном из истории чата.\n"
|
||||
"Можешь писать ассистенту обычными сообщениями."
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("logout"))
|
||||
async def cmd_logout(message: Message, settings: Settings, storage: Storage) -> None:
|
||||
if not is_allowed(message, settings):
|
||||
await message.answer(access_denied_text())
|
||||
return
|
||||
if not message.from_user:
|
||||
return
|
||||
|
||||
removed = await storage.unlink_user(message.from_user.id)
|
||||
if removed:
|
||||
await message.answer("API-токен отвязан. Чтобы снова пользоваться ботом, отправь новый токен.")
|
||||
else:
|
||||
await message.answer("Токен не был привязан.")
|
||||
|
||||
|
||||
@router.message(Command("whoami"))
|
||||
async def cmd_whoami(message: Message, settings: Settings, storage: Storage) -> None:
|
||||
if not is_allowed(message, settings):
|
||||
await message.answer(access_denied_text())
|
||||
return
|
||||
if not message.from_user:
|
||||
return
|
||||
|
||||
linked = await storage.get_user(message.from_user.id)
|
||||
if not linked:
|
||||
await message.answer("Токен не привязан. Отправь API-токен или /start.")
|
||||
return
|
||||
|
||||
client = HaClient(settings.ha_api_base_url, linked.api_token)
|
||||
try:
|
||||
me = await client.me()
|
||||
user = me.get("user") or {}
|
||||
name = user.get("display_name") or user.get("username") or linked.display_name
|
||||
username = user.get("username") or linked.username
|
||||
await message.answer(
|
||||
f"Home Assistant: {name} (@{username})\n"
|
||||
f"HA user id: {linked.ha_user_id}\n"
|
||||
f"Telegram-сессия: #{linked.session_id}"
|
||||
)
|
||||
except Exception:
|
||||
await message.answer(
|
||||
f"Привязан локально: {linked.display_name or linked.username}\n"
|
||||
f"Telegram-сессия: #{linked.session_id}\n"
|
||||
"(Не удалось проверить токен на сервере — возможно, он отозван.)"
|
||||
)
|
||||
|
||||
|
||||
@router.message(F.text & ~F.text.startswith("/"), NotLinked())
|
||||
async def handle_possible_token(message: Message, settings: Settings, storage: Storage) -> None:
|
||||
if not is_allowed(message, settings):
|
||||
return
|
||||
if not message.from_user or not message.text:
|
||||
return
|
||||
|
||||
token = message.text.strip()
|
||||
if len(token) < 8:
|
||||
return
|
||||
|
||||
await _link_token(message, settings, storage, token)
|
||||
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.enums import ChatAction
|
||||
from aiogram.types import Message
|
||||
|
||||
from bot.access import is_allowed
|
||||
from bot.config import Settings
|
||||
from bot.filters import IsLinked
|
||||
from bot.ha_client import HaClient
|
||||
from bot.sse import SseChunk
|
||||
from bot.notify_worker import advance_cursors, send_text
|
||||
from bot.storage import LinkedUser, Storage
|
||||
|
||||
router = Router()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_generation_locks: dict[int, asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _user_lock(telegram_id: int) -> asyncio.Lock:
|
||||
if telegram_id not in _generation_locks:
|
||||
_generation_locks[telegram_id] = asyncio.Lock()
|
||||
return _generation_locks[telegram_id]
|
||||
|
||||
|
||||
async def _iter_stream(stream: AsyncIterator[SseChunk]) -> AsyncIterator[SseChunk]:
|
||||
async for chunk in stream:
|
||||
yield chunk
|
||||
|
||||
|
||||
async def _run_chat_stream(
|
||||
message: Message,
|
||||
settings: Settings,
|
||||
storage: Storage,
|
||||
linked: LinkedUser,
|
||||
stream: AsyncIterator[SseChunk],
|
||||
) -> None:
|
||||
accumulated = ""
|
||||
|
||||
async for chunk in stream:
|
||||
if chunk.event == "status":
|
||||
await message.bot.send_chat_action(message.chat.id, ChatAction.TYPING)
|
||||
elif chunk.event == "token":
|
||||
piece = str(chunk.data.get("content") or "")
|
||||
if piece:
|
||||
accumulated += piece
|
||||
elif chunk.event == "notice":
|
||||
content = str(chunk.data.get("content") or "").strip()
|
||||
if content:
|
||||
await send_text(message.bot, message.chat.id, content)
|
||||
elif chunk.event == "error":
|
||||
err = str(chunk.data.get("message") or "Ошибка генерации")
|
||||
await message.answer(err)
|
||||
return
|
||||
elif chunk.event == "done":
|
||||
break
|
||||
|
||||
if accumulated.strip():
|
||||
parts = _split_for_edit_or_send(accumulated)
|
||||
for part in parts:
|
||||
await message.answer(part)
|
||||
|
||||
client = HaClient(settings.ha_api_base_url, linked.api_token)
|
||||
try:
|
||||
await advance_cursors(storage, client, linked)
|
||||
except Exception:
|
||||
logger.exception("Failed to advance cursors for telegram_id=%s", linked.telegram_id)
|
||||
|
||||
|
||||
def _split_for_edit_or_send(text: str, limit: int = 4096) -> list[str]:
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
parts: list[str] = []
|
||||
remaining = text
|
||||
while remaining:
|
||||
if len(remaining) <= limit:
|
||||
parts.append(remaining)
|
||||
break
|
||||
cut = remaining.rfind("\n", 0, limit)
|
||||
if cut <= 0:
|
||||
cut = limit
|
||||
parts.append(remaining[:cut])
|
||||
remaining = remaining[cut:].lstrip("\n")
|
||||
return parts
|
||||
|
||||
|
||||
@router.message(F.text & ~F.text.startswith("/"), IsLinked())
|
||||
async def handle_chat_message(message: Message, settings: Settings, storage: Storage) -> None:
|
||||
if not is_allowed(message, settings):
|
||||
return
|
||||
if not message.from_user or not message.text:
|
||||
return
|
||||
|
||||
linked = await storage.get_user(message.from_user.id)
|
||||
if not linked:
|
||||
return
|
||||
|
||||
lock = _user_lock(message.from_user.id)
|
||||
if lock.locked():
|
||||
await message.answer("Подожди, предыдущий ответ ещё генерируется.")
|
||||
return
|
||||
|
||||
async with lock:
|
||||
client = HaClient(settings.ha_api_base_url, linked.api_token)
|
||||
content = message.text.strip()
|
||||
if not content:
|
||||
return
|
||||
|
||||
try:
|
||||
status = await client.generation_status(linked.session_id)
|
||||
if status.get("active"):
|
||||
stream = client.stream_generation(linked.session_id)
|
||||
else:
|
||||
stream = client.send_message_stream(linked.session_id, content)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to start chat for telegram_id=%s", message.from_user.id)
|
||||
await message.answer(f"Ошибка связи с Home Assistant: {exc}")
|
||||
return
|
||||
|
||||
try:
|
||||
await _run_chat_stream(message, settings, storage, linked, _iter_stream(stream))
|
||||
except Exception as exc:
|
||||
logger.exception("Chat stream failed for telegram_id=%s", message.from_user.id)
|
||||
await message.answer(f"Ошибка при получении ответа: {exc}")
|
||||
@@ -0,0 +1,33 @@
|
||||
from aiogram import Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message
|
||||
|
||||
from bot.access import access_denied_text, is_allowed
|
||||
from bot.config import Settings
|
||||
from bot.ha_client import HaClient
|
||||
from bot.storage import Storage
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.message(Command("newchat"))
|
||||
async def cmd_newchat(message: Message, settings: Settings, storage: Storage) -> None:
|
||||
if not is_allowed(message, settings):
|
||||
await message.answer(access_denied_text())
|
||||
return
|
||||
if not message.from_user:
|
||||
return
|
||||
|
||||
linked = await storage.get_user(message.from_user.id)
|
||||
if not linked:
|
||||
await message.answer("Сначала привяжи API-токен (/start).")
|
||||
return
|
||||
|
||||
client = HaClient(settings.ha_api_base_url, linked.api_token)
|
||||
try:
|
||||
session = await client.create_session("Telegram")
|
||||
session_id = int(session["id"])
|
||||
await storage.set_session_id(message.from_user.id, session_id)
|
||||
await message.answer(f"Новая сессия создана: #{session_id}. История с прошлой сессии здесь не продолжается.")
|
||||
except Exception as exc:
|
||||
await message.answer(f"Не удалось создать сессию: {exc}")
|
||||
@@ -0,0 +1,44 @@
|
||||
from aiogram import Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message
|
||||
|
||||
from bot.access import access_denied_text, is_allowed
|
||||
from bot.config import Settings
|
||||
|
||||
router = Router()
|
||||
|
||||
HELP_TEXT = """Команды:
|
||||
/start — приветствие и привязка
|
||||
/help — эта справка
|
||||
/whoami — текущий пользователь Home Assistant
|
||||
/logout — отвязать API-токен
|
||||
/newchat — новая сессия чата в Telegram
|
||||
|
||||
Обычный текст — сообщение домашнему ассистенту.
|
||||
|
||||
Чтобы привязать аккаунт, отправь API-токен одним сообщением (из Settings → Пользователи на домашнем сервере). После привязки лучше удалить сообщение с токеном."""
|
||||
|
||||
|
||||
@router.message(Command("start"))
|
||||
async def cmd_start(message: Message, settings: Settings) -> None:
|
||||
if not is_allowed(message, settings):
|
||||
await message.answer(access_denied_text())
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
"Привет! Я мост к домашнему ассистенту Home Assistant.\n\n"
|
||||
"Отправь API-токен одним сообщением, чтобы привязать аккаунт. "
|
||||
"Токен можно создать в веб-интерфейсе (Settings → Пользователи) "
|
||||
"или через create_user.py на сервере.\n\n"
|
||||
"После привязки все оповещения (напоминания, помидоро и т.д.) "
|
||||
"будут дублироваться сюда.\n\n"
|
||||
"Справка: /help"
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("help"))
|
||||
async def cmd_help(message: Message, settings: Settings) -> None:
|
||||
if not is_allowed(message, settings):
|
||||
await message.answer(access_denied_text())
|
||||
return
|
||||
await message.answer(HELP_TEXT)
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
|
||||
from bot.config import load_settings
|
||||
from bot.handlers import router as root_router
|
||||
from bot.middleware import InjectMiddleware
|
||||
from bot.notify_worker import run_notify_worker
|
||||
from bot.storage import Storage
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
settings = load_settings()
|
||||
storage = Storage(str(settings.db_path))
|
||||
await storage.connect()
|
||||
|
||||
bot = Bot(token=settings.telegram_bot_token)
|
||||
dp = Dispatcher()
|
||||
dp.update.middleware(InjectMiddleware(settings, storage))
|
||||
dp.include_router(root_router)
|
||||
|
||||
worker_task = asyncio.create_task(
|
||||
run_notify_worker(
|
||||
bot,
|
||||
storage,
|
||||
settings.ha_api_base_url,
|
||||
settings.poll_interval_sec,
|
||||
)
|
||||
)
|
||||
|
||||
logger.info("Bot started, HA API: %s", settings.ha_api_base_url)
|
||||
try:
|
||||
await dp.start_polling(bot)
|
||||
finally:
|
||||
worker_task.cancel()
|
||||
try:
|
||||
await worker_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await storage.close()
|
||||
await bot.session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject
|
||||
|
||||
from bot.config import Settings
|
||||
from bot.storage import Storage
|
||||
|
||||
|
||||
class InjectMiddleware(BaseMiddleware):
|
||||
def __init__(self, settings: Settings, storage: Storage) -> None:
|
||||
self.settings = settings
|
||||
self.storage = storage
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
data["settings"] = self.settings
|
||||
data["storage"] = self.storage
|
||||
return await handler(event, data)
|
||||
@@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiogram import Bot
|
||||
|
||||
from bot.ha_client import HaClient
|
||||
from bot.storage import LinkedUser, Storage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NOTICE_ROLES = frozenset({"notice", "character"})
|
||||
TG_MAX_LEN = 4096
|
||||
|
||||
|
||||
def split_telegram_message(text: str, limit: int = TG_MAX_LEN) -> list[str]:
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
chunks: list[str] = []
|
||||
remaining = text
|
||||
while remaining:
|
||||
if len(remaining) <= limit:
|
||||
chunks.append(remaining)
|
||||
break
|
||||
split_at = remaining.rfind("\n", 0, limit)
|
||||
if split_at <= 0:
|
||||
split_at = limit
|
||||
chunks.append(remaining[:split_at])
|
||||
remaining = remaining[split_at:].lstrip("\n")
|
||||
return chunks
|
||||
|
||||
|
||||
async def send_text(bot: Bot, chat_id: int, text: str) -> None:
|
||||
for chunk in split_telegram_message(text):
|
||||
await bot.send_message(chat_id, chunk)
|
||||
|
||||
|
||||
async def advance_cursors(
|
||||
storage: Storage,
|
||||
client: HaClient,
|
||||
user: LinkedUser,
|
||||
) -> None:
|
||||
sessions = await client.list_sessions()
|
||||
for session in sessions:
|
||||
session_id = int(session["id"])
|
||||
after_id = await storage.get_last_message_id(user.telegram_id, session_id)
|
||||
page = await client.get_messages(session_id, after_id=after_id or None, limit=100)
|
||||
messages = page.get("messages") or []
|
||||
max_id = after_id
|
||||
for message in messages:
|
||||
msg_id = int(message["id"])
|
||||
max_id = max(max_id, msg_id)
|
||||
if max_id > after_id:
|
||||
await storage.set_last_message_id(user.telegram_id, session_id, max_id)
|
||||
|
||||
|
||||
async def sync_notices_for_user(
|
||||
bot: Bot,
|
||||
storage: Storage,
|
||||
ha_base_url: str,
|
||||
user: LinkedUser,
|
||||
*,
|
||||
send: bool = True,
|
||||
) -> None:
|
||||
client = HaClient(ha_base_url, user.api_token)
|
||||
|
||||
try:
|
||||
reminders = await client.get_reminders_snapshot()
|
||||
pomodoro = await client.get_pomodoro_status()
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch notify seq for telegram_id=%s", user.telegram_id)
|
||||
reminders = {}
|
||||
pomodoro = {}
|
||||
|
||||
reminder_seq = int(reminders.get("notify_seq") or 0)
|
||||
pomodoro_seq = int((pomodoro.get("cycle") or {}).get("chat_notify_seq") or 0)
|
||||
|
||||
sessions = await client.list_sessions()
|
||||
pending: list[tuple[int, str]] = []
|
||||
|
||||
for session in sessions:
|
||||
session_id = int(session["id"])
|
||||
after_id = await storage.get_last_message_id(user.telegram_id, session_id)
|
||||
try:
|
||||
page = await client.get_messages(session_id, after_id=after_id or None, limit=100)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to fetch messages session_id=%s telegram_id=%s",
|
||||
session_id,
|
||||
user.telegram_id,
|
||||
)
|
||||
continue
|
||||
|
||||
messages = page.get("messages") or []
|
||||
max_id = after_id
|
||||
for message in messages:
|
||||
msg_id = int(message["id"])
|
||||
max_id = max(max_id, msg_id)
|
||||
role = str(message.get("role") or "")
|
||||
if role in NOTICE_ROLES and send:
|
||||
content = str(message.get("content") or "").strip()
|
||||
if content:
|
||||
pending.append((msg_id, content))
|
||||
|
||||
if max_id > after_id:
|
||||
await storage.set_last_message_id(user.telegram_id, session_id, max_id)
|
||||
|
||||
if send:
|
||||
pending.sort(key=lambda item: item[0])
|
||||
for _, content in pending:
|
||||
try:
|
||||
await send_text(bot, user.telegram_id, content)
|
||||
except Exception:
|
||||
logger.exception("Failed to send notice to telegram_id=%s", user.telegram_id)
|
||||
|
||||
await storage.update_seq(
|
||||
user.telegram_id,
|
||||
reminder_seq=reminder_seq,
|
||||
pomodoro_seq=pomodoro_seq,
|
||||
)
|
||||
|
||||
|
||||
async def run_notify_worker(
|
||||
bot: Bot,
|
||||
storage: Storage,
|
||||
ha_base_url: str,
|
||||
poll_interval_sec: int,
|
||||
) -> None:
|
||||
import asyncio
|
||||
|
||||
logger.info("Notify worker started (interval=%ss)", poll_interval_sec)
|
||||
while True:
|
||||
users = await storage.list_linked_users()
|
||||
for user in users:
|
||||
try:
|
||||
await sync_notices_for_user(bot, storage, ha_base_url, user, send=True)
|
||||
except Exception:
|
||||
logger.exception("Notify sync failed for telegram_id=%s", user.telegram_id)
|
||||
await asyncio.sleep(poll_interval_sec)
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class SseChunk:
|
||||
__slots__ = ("event", "data")
|
||||
|
||||
def __init__(self, event: str, data: dict[str, Any]) -> None:
|
||||
self.event = event
|
||||
self.data = data
|
||||
|
||||
|
||||
def _parse_sse_part(part: str) -> SseChunk | None:
|
||||
if not part.strip():
|
||||
return None
|
||||
event = "message"
|
||||
data = ""
|
||||
for line in part.split("\n"):
|
||||
if line.startswith("event: "):
|
||||
event = line[7:]
|
||||
elif line.startswith("data: "):
|
||||
data = line[6:]
|
||||
if not data:
|
||||
return None
|
||||
return SseChunk(event=event, data=json.loads(data))
|
||||
|
||||
|
||||
async def iter_sse(response: httpx.Response) -> AsyncIterator[SseChunk]:
|
||||
if response.status_code >= 400:
|
||||
detail = (await response.aread()).decode("utf-8", errors="replace")
|
||||
raise RuntimeError(detail or f"HTTP {response.status_code}")
|
||||
|
||||
buffer = ""
|
||||
async for chunk in response.aiter_text():
|
||||
buffer += chunk
|
||||
parts = buffer.split("\n\n")
|
||||
buffer = parts.pop() if parts else ""
|
||||
for part in parts:
|
||||
parsed = _parse_sse_part(part)
|
||||
if parsed:
|
||||
yield parsed
|
||||
|
||||
if buffer.strip():
|
||||
parsed = _parse_sse_part(buffer)
|
||||
if parsed:
|
||||
yield parsed
|
||||
@@ -0,0 +1,196 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import aiosqlite
|
||||
|
||||
|
||||
@dataclass
|
||||
class LinkedUser:
|
||||
telegram_id: int
|
||||
api_token: str
|
||||
ha_user_id: int
|
||||
display_name: str
|
||||
username: str
|
||||
session_id: int
|
||||
reminder_seq: int
|
||||
pomodoro_seq: int
|
||||
|
||||
|
||||
class Storage:
|
||||
def __init__(self, db_path: str) -> None:
|
||||
self.db_path = db_path
|
||||
self._db: aiosqlite.Connection | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
self._db = await aiosqlite.connect(self.db_path)
|
||||
self._db.row_factory = aiosqlite.Row
|
||||
await self._db.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
telegram_id INTEGER PRIMARY KEY,
|
||||
api_token TEXT NOT NULL,
|
||||
ha_user_id INTEGER NOT NULL,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
session_id INTEGER NOT NULL,
|
||||
reminder_seq INTEGER NOT NULL DEFAULT 0,
|
||||
pomodoro_seq INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_cursors (
|
||||
telegram_id INTEGER NOT NULL,
|
||||
session_id INTEGER NOT NULL,
|
||||
last_message_id INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (telegram_id, session_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
await self._db.commit()
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._db:
|
||||
await self._db.close()
|
||||
self._db = None
|
||||
|
||||
@property
|
||||
def db(self) -> aiosqlite.Connection:
|
||||
if not self._db:
|
||||
raise RuntimeError("Storage is not connected")
|
||||
return self._db
|
||||
|
||||
async def get_user(self, telegram_id: int) -> LinkedUser | None:
|
||||
cursor = await self.db.execute(
|
||||
"""
|
||||
SELECT telegram_id, api_token, ha_user_id, display_name, username,
|
||||
session_id, reminder_seq, pomodoro_seq
|
||||
FROM users WHERE telegram_id = ?
|
||||
""",
|
||||
(telegram_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return LinkedUser(
|
||||
telegram_id=int(row["telegram_id"]),
|
||||
api_token=str(row["api_token"]),
|
||||
ha_user_id=int(row["ha_user_id"]),
|
||||
display_name=str(row["display_name"] or ""),
|
||||
username=str(row["username"] or ""),
|
||||
session_id=int(row["session_id"]),
|
||||
reminder_seq=int(row["reminder_seq"]),
|
||||
pomodoro_seq=int(row["pomodoro_seq"]),
|
||||
)
|
||||
|
||||
async def list_linked_users(self) -> list[LinkedUser]:
|
||||
cursor = await self.db.execute(
|
||||
"""
|
||||
SELECT telegram_id, api_token, ha_user_id, display_name, username,
|
||||
session_id, reminder_seq, pomodoro_seq
|
||||
FROM users
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
LinkedUser(
|
||||
telegram_id=int(row["telegram_id"]),
|
||||
api_token=str(row["api_token"]),
|
||||
ha_user_id=int(row["ha_user_id"]),
|
||||
display_name=str(row["display_name"] or ""),
|
||||
username=str(row["username"] or ""),
|
||||
session_id=int(row["session_id"]),
|
||||
reminder_seq=int(row["reminder_seq"]),
|
||||
pomodoro_seq=int(row["pomodoro_seq"]),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
async def link_user(
|
||||
self,
|
||||
*,
|
||||
telegram_id: int,
|
||||
api_token: str,
|
||||
ha_user_id: int,
|
||||
display_name: str,
|
||||
username: str,
|
||||
session_id: int,
|
||||
) -> None:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await self.db.execute(
|
||||
"""
|
||||
INSERT INTO users (
|
||||
telegram_id, api_token, ha_user_id, display_name, username,
|
||||
session_id, reminder_seq, pomodoro_seq, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?)
|
||||
ON CONFLICT(telegram_id) DO UPDATE SET
|
||||
api_token = excluded.api_token,
|
||||
ha_user_id = excluded.ha_user_id,
|
||||
display_name = excluded.display_name,
|
||||
username = excluded.username,
|
||||
session_id = excluded.session_id,
|
||||
reminder_seq = 0,
|
||||
pomodoro_seq = 0
|
||||
""",
|
||||
(telegram_id, api_token, ha_user_id, display_name, username, session_id, now),
|
||||
)
|
||||
await self.db.commit()
|
||||
|
||||
async def unlink_user(self, telegram_id: int) -> bool:
|
||||
cursor = await self.db.execute("DELETE FROM users WHERE telegram_id = ?", (telegram_id,))
|
||||
await self.db.execute(
|
||||
"DELETE FROM session_cursors WHERE telegram_id = ?",
|
||||
(telegram_id,),
|
||||
)
|
||||
await self.db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
async def set_session_id(self, telegram_id: int, session_id: int) -> None:
|
||||
await self.db.execute(
|
||||
"UPDATE users SET session_id = ? WHERE telegram_id = ?",
|
||||
(session_id, telegram_id),
|
||||
)
|
||||
await self.db.commit()
|
||||
|
||||
async def update_seq(
|
||||
self,
|
||||
telegram_id: int,
|
||||
*,
|
||||
reminder_seq: int | None = None,
|
||||
pomodoro_seq: int | None = None,
|
||||
) -> None:
|
||||
if reminder_seq is not None:
|
||||
await self.db.execute(
|
||||
"UPDATE users SET reminder_seq = ? WHERE telegram_id = ?",
|
||||
(reminder_seq, telegram_id),
|
||||
)
|
||||
if pomodoro_seq is not None:
|
||||
await self.db.execute(
|
||||
"UPDATE users SET pomodoro_seq = ? WHERE telegram_id = ?",
|
||||
(pomodoro_seq, telegram_id),
|
||||
)
|
||||
await self.db.commit()
|
||||
|
||||
async def get_last_message_id(self, telegram_id: int, session_id: int) -> int:
|
||||
cursor = await self.db.execute(
|
||||
"""
|
||||
SELECT last_message_id FROM session_cursors
|
||||
WHERE telegram_id = ? AND session_id = ?
|
||||
""",
|
||||
(telegram_id, session_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return int(row["last_message_id"]) if row else 0
|
||||
|
||||
async def set_last_message_id(self, telegram_id: int, session_id: int, message_id: int) -> None:
|
||||
await self.db.execute(
|
||||
"""
|
||||
INSERT INTO session_cursors (telegram_id, session_id, last_message_id)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(telegram_id, session_id) DO UPDATE SET
|
||||
last_message_id = excluded.last_message_id
|
||||
""",
|
||||
(telegram_id, session_id, message_id),
|
||||
)
|
||||
await self.db.commit()
|
||||
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
telegram-bot:
|
||||
build: .
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,3 @@
|
||||
aiogram>=3.15.0
|
||||
httpx>=0.28.0
|
||||
aiosqlite>=0.20.0
|
||||
Reference in New Issue
Block a user