added RAG, Multiuser, TG bot

This commit is contained in:
2026-06-13 20:20:56 +00:00
parent 66e1b0e29e
commit c8a9429bed
142 changed files with 19901 additions and 8790 deletions
+14
View File
@@ -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=
+5
View File
@@ -0,0 +1,5 @@
.env
data/
.venv/
__pycache__/
*.pyc
+13
View File
@@ -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"]
+145
View File
@@ -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 за ~3060 с
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
```
+1
View File
@@ -0,0 +1 @@
"""Home Assistant Telegram bridge bot."""
+18
View File
@@ -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 "У вас нет доступа к этому боту."
+60
View File
@@ -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,
)
+20
View File
@@ -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
+143
View File
@@ -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"])
+12
View File
@@ -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)
+126
View File
@@ -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)
+129
View File
@@ -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}")
+33
View File
@@ -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}")
+44
View File
@@ -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)
+54
View File
@@ -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())
+25
View File
@@ -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)
+143
View File
@@ -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)
+51
View File
@@ -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
+196
View File
@@ -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()
+7
View File
@@ -0,0 +1,7 @@
services:
telegram-bot:
build: .
env_file: .env
volumes:
- ./data:/app/data
restart: unless-stopped
+3
View File
@@ -0,0 +1,3 @@
aiogram>=3.15.0
httpx>=0.28.0
aiosqlite>=0.20.0