Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15f354d06b | ||
|
|
0ce5285da1 |
@@ -4,6 +4,7 @@
|
|||||||
# --- Telegram (#4, #25) ---
|
# --- Telegram (#4, #25) ---
|
||||||
TELEGRAM_BOT_TOKEN= # (secret) токен от @BotFather
|
TELEGRAM_BOT_TOKEN= # (secret) токен от @BotFather
|
||||||
ADMIN_IDS= # Telegram ID первых администраторов через запятую
|
ADMIN_IDS= # Telegram ID первых администраторов через запятую
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
# --- База данных ---
|
# --- База данных ---
|
||||||
POSTGRES_PASSWORD=tgit # (secret на сервере) пароль Postgres в Compose
|
POSTGRES_PASSWORD=tgit # (secret на сервере) пароль Postgres в Compose
|
||||||
|
|||||||
@@ -20,4 +20,6 @@ COPY --from=build /opt/venv /opt/venv
|
|||||||
WORKDIR /srv/tgit
|
WORKDIR /srv/tgit
|
||||||
COPY config ./config
|
COPY config ./config
|
||||||
USER tgit
|
USER tgit
|
||||||
|
# Liveness: the bot touches a heartbeat file while its event loop runs (app/health.py)
|
||||||
|
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 CMD ["python", "-m", "app.health"]
|
||||||
CMD ["python", "-m", "app"]
|
CMD ["python", "-m", "app"]
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""FSM storage factory. In-memory for now; Postgres-backed storage that survives restarts arrives with the DB (#5)."""
|
||||||
|
|
||||||
|
from aiogram.fsm.storage.base import BaseStorage
|
||||||
|
from aiogram.fsm.storage.memory import MemoryStorage
|
||||||
|
|
||||||
|
|
||||||
|
def make_storage() -> BaseStorage:
|
||||||
|
return MemoryStorage()
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Main menu (reply keyboard) and in-section navigation (inline keyboard), SPEC §4.0."""
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
from aiogram.filters.callback_data import CallbackData
|
||||||
|
from aiogram.types import InlineKeyboardMarkup, ReplyKeyboardMarkup
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
|
||||||
|
|
||||||
|
from app.texts import Texts
|
||||||
|
|
||||||
|
|
||||||
|
class Section(StrEnum):
|
||||||
|
COMMIT = "commit"
|
||||||
|
STATUS = "status"
|
||||||
|
LOGS = "logs"
|
||||||
|
ALERTS = "alerts"
|
||||||
|
MEMBERS = "members"
|
||||||
|
|
||||||
|
|
||||||
|
class NavAction(StrEnum):
|
||||||
|
BACK = "back"
|
||||||
|
CANCEL = "cancel"
|
||||||
|
|
||||||
|
|
||||||
|
class Nav(CallbackData, prefix="nav"):
|
||||||
|
# Telegram limits callback_data to 64 bytes: only short ids here, never paths (ARCHITECTURE §2.1).
|
||||||
|
action: NavAction
|
||||||
|
|
||||||
|
|
||||||
|
def section_by_button(texts: Texts) -> dict[str, Section]:
|
||||||
|
m = texts.menu
|
||||||
|
return {
|
||||||
|
m.commit: Section.COMMIT,
|
||||||
|
m.status: Section.STATUS,
|
||||||
|
m.logs: Section.LOGS,
|
||||||
|
m.alerts: Section.ALERTS,
|
||||||
|
m.members: Section.MEMBERS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main_menu(texts: Texts, *, is_admin: bool) -> ReplyKeyboardMarkup:
|
||||||
|
m = texts.menu
|
||||||
|
kb = ReplyKeyboardBuilder()
|
||||||
|
kb.button(text=m.commit)
|
||||||
|
kb.button(text=m.status)
|
||||||
|
kb.button(text=m.logs)
|
||||||
|
kb.button(text=m.alerts)
|
||||||
|
if is_admin:
|
||||||
|
kb.button(text=m.members)
|
||||||
|
kb.adjust(2, 2, 1)
|
||||||
|
return kb.as_markup(resize_keyboard=True, is_persistent=True, input_field_placeholder=m.placeholder)
|
||||||
|
|
||||||
|
|
||||||
|
def section_nav(texts: Texts) -> InlineKeyboardMarkup:
|
||||||
|
kb = InlineKeyboardBuilder()
|
||||||
|
kb.button(text=texts.menu.back, callback_data=Nav(action=NavAction.BACK))
|
||||||
|
kb.button(text=texts.menu.cancel, callback_data=Nav(action=NavAction.CANCEL))
|
||||||
|
return kb.as_markup()
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Access gate on every update — messages and button presses alike (SPEC A-5).
|
||||||
|
|
||||||
|
Until invites land (#25) the only members are the bootstrap admins from `ADMIN_IDS`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiogram import BaseMiddleware
|
||||||
|
from aiogram.types import CallbackQuery, Message, TelegramObject
|
||||||
|
|
||||||
|
from app.texts import Texts
|
||||||
|
|
||||||
|
IsMember = Callable[[int], Awaitable[bool]]
|
||||||
|
|
||||||
|
NO_ACCESS_REPLY_EVERY = 24 * 3600.0
|
||||||
|
|
||||||
|
|
||||||
|
class AccessMiddleware(BaseMiddleware):
|
||||||
|
def __init__(self, is_member: IsMember, texts: Texts, clock: Callable[[], float] = time.monotonic) -> None:
|
||||||
|
self._is_member = is_member
|
||||||
|
self._texts = texts
|
||||||
|
self._clock = clock
|
||||||
|
# tg_id -> when we last told this outsider "invite only"; answering every message would let them spam us.
|
||||||
|
self._last_refusal: dict[int, float] = {}
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||||
|
event: TelegramObject,
|
||||||
|
data: dict[str, Any],
|
||||||
|
) -> Any:
|
||||||
|
user = data.get("event_from_user")
|
||||||
|
if user is not None and await self._is_member(user.id):
|
||||||
|
return await handler(event, data)
|
||||||
|
if user is not None:
|
||||||
|
await self._refuse(event, user.id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _refuse(self, event: TelegramObject, tg_id: int) -> None:
|
||||||
|
if isinstance(event, CallbackQuery):
|
||||||
|
await event.answer() # stop the spinner on a stale button, say nothing
|
||||||
|
return
|
||||||
|
now = self._clock()
|
||||||
|
last = self._last_refusal.get(tg_id)
|
||||||
|
if last is not None and now - last < NO_ACCESS_REPLY_EVERY:
|
||||||
|
return
|
||||||
|
self._last_refusal[tg_id] = now
|
||||||
|
if isinstance(event, Message):
|
||||||
|
await event.answer(self._texts.start.no_access)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""/start, /help, /menu, main menu buttons and in-section navigation."""
|
||||||
|
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.filters import Command, CommandStart
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
|
||||||
|
from app.bot.menu import Nav, NavAction, Section, main_menu, section_by_button, section_nav
|
||||||
|
from app.texts import Texts
|
||||||
|
|
||||||
|
|
||||||
|
def build_router(texts: Texts, admin_ids: frozenset[int]) -> Router:
|
||||||
|
router = Router(name="common")
|
||||||
|
sections = section_by_button(texts)
|
||||||
|
section_text = {
|
||||||
|
Section.COMMIT: texts.section.commit,
|
||||||
|
Section.STATUS: texts.section.status,
|
||||||
|
Section.LOGS: texts.section.logs,
|
||||||
|
Section.ALERTS: texts.section.alerts,
|
||||||
|
Section.MEMBERS: texts.section.members,
|
||||||
|
}
|
||||||
|
|
||||||
|
def is_admin(message: Message) -> bool:
|
||||||
|
return message.from_user is not None and message.from_user.id in admin_ids
|
||||||
|
|
||||||
|
@router.message(CommandStart())
|
||||||
|
async def start(message: Message, state: FSMContext) -> None:
|
||||||
|
await state.clear()
|
||||||
|
await message.answer(texts.start.welcome, reply_markup=main_menu(texts, is_admin=is_admin(message)))
|
||||||
|
|
||||||
|
@router.message(Command("menu"))
|
||||||
|
async def menu(message: Message, state: FSMContext) -> None:
|
||||||
|
await state.clear()
|
||||||
|
await message.answer(texts.menu.placeholder, reply_markup=main_menu(texts, is_admin=is_admin(message)))
|
||||||
|
|
||||||
|
@router.message(Command("help"))
|
||||||
|
async def help_(message: Message) -> None:
|
||||||
|
await message.answer(texts.help.text)
|
||||||
|
|
||||||
|
@router.message(F.text.in_(sections))
|
||||||
|
async def open_section(message: Message, state: FSMContext) -> None:
|
||||||
|
section = sections[message.text or ""]
|
||||||
|
if section is Section.MEMBERS and not is_admin(message):
|
||||||
|
return
|
||||||
|
await state.clear()
|
||||||
|
await message.answer(section_text[section], reply_markup=section_nav(texts))
|
||||||
|
|
||||||
|
@router.callback_query(Nav.filter())
|
||||||
|
async def navigate(query: CallbackQuery, callback_data: Nav, state: FSMContext) -> None:
|
||||||
|
# At a section's root "Back" and "Cancel" both close it; nested screens will override "Back" per section.
|
||||||
|
await state.clear()
|
||||||
|
text = texts.section.cancelled if callback_data.action is NavAction.CANCEL else texts.menu.placeholder
|
||||||
|
if isinstance(query.message, Message):
|
||||||
|
await query.message.edit_text(text)
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
return router
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Typed configuration from the environment / `.env` — one settings class per subsystem (ARCHITECTURE §2.11)."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from pydantic import Field, SecretStr, field_validator
|
||||||
|
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||||
|
|
||||||
|
_ENV = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramSettings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(**_ENV, env_prefix="TELEGRAM_")
|
||||||
|
|
||||||
|
bot_token: SecretStr
|
||||||
|
|
||||||
|
|
||||||
|
class AccessSettings(BaseSettings):
|
||||||
|
model_config = _ENV
|
||||||
|
|
||||||
|
# Bootstrap admins (SPEC A-8). Comma-separated in the env: ADMIN_IDS=1,2
|
||||||
|
admin_ids: Annotated[frozenset[int], NoDecode] = frozenset()
|
||||||
|
|
||||||
|
@field_validator("admin_ids", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _split_ids(cls, value: object) -> object:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return frozenset(int(part) for part in value.replace(" ", "").split(",") if part)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeSettings(BaseSettings):
|
||||||
|
model_config = _ENV
|
||||||
|
|
||||||
|
log_level: str = "INFO"
|
||||||
|
# Non-secret configs (texts, thresholds, prompts). Relative to the working dir: /srv/tgit in Docker.
|
||||||
|
config_dir: Path = Path("config")
|
||||||
|
# The heartbeat file is touched while the event loop is alive; the Docker healthcheck reads its age.
|
||||||
|
heartbeat_file: Path = Path("/tmp/tgit-heartbeat")
|
||||||
|
heartbeat_interval: float = Field(default=10.0, gt=0)
|
||||||
|
heartbeat_max_age: float = Field(default=30.0, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = _ENV
|
||||||
|
|
||||||
|
telegram: TelegramSettings = Field(default_factory=TelegramSettings)
|
||||||
|
access: AccessSettings = Field(default_factory=AccessSettings)
|
||||||
|
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Liveness: a background task touches a file while the event loop runs; the Docker healthcheck checks its age.
|
||||||
|
|
||||||
|
A stuck loop stops touching the file, so the container turns unhealthy even though the process is still up.
|
||||||
|
`python -m app.health` exits 0 when healthy, 1 otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import RuntimeSettings
|
||||||
|
|
||||||
|
|
||||||
|
async def heartbeat(path: Path, interval: float) -> None:
|
||||||
|
while True:
|
||||||
|
await asyncio.to_thread(path.touch)
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
|
|
||||||
|
def is_alive(path: Path, max_age: float, now: float | None = None) -> bool:
|
||||||
|
try:
|
||||||
|
age = (time.time() if now is None else now) - path.stat().st_mtime
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
return age <= max_age
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
rt = RuntimeSettings()
|
||||||
|
sys.exit(0 if is_alive(rt.heartbeat_file, rt.heartbeat_max_age) else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""JSON logs to stdout, one object per line. Never log message contents or secrets (SPEC §5.3)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
|
||||||
|
class JsonFormatter(logging.Formatter):
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
entry = {
|
||||||
|
"ts": datetime.fromtimestamp(record.created, UTC).isoformat(timespec="milliseconds"),
|
||||||
|
"level": record.levelname,
|
||||||
|
"logger": record.name,
|
||||||
|
"msg": record.getMessage(),
|
||||||
|
}
|
||||||
|
if record.exc_info:
|
||||||
|
entry["exc"] = self.formatException(record.exc_info)
|
||||||
|
return json.dumps(entry, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(level: str) -> None:
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(JsonFormatter())
|
||||||
|
logging.basicConfig(level=level.upper(), handlers=[handler], force=True)
|
||||||
+44
-16
@@ -1,28 +1,56 @@
|
|||||||
"""Entry point. For now a stub that idles until stopped; the bot itself lands in #4."""
|
"""Entry point: settings → texts → dispatcher → long polling (ADR-002)."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import signal
|
|
||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
|
||||||
from app import __version__
|
from app import __version__
|
||||||
|
from app.bot.fsm import make_storage
|
||||||
|
from app.bot.middlewares.access import AccessMiddleware
|
||||||
|
from app.bot.routers.common import build_router
|
||||||
|
from app.config import Settings
|
||||||
|
from app.health import heartbeat
|
||||||
|
from app.logs_setup import setup_logging
|
||||||
|
from app.texts import Texts, load_texts
|
||||||
|
|
||||||
log = logging.getLogger("tgit")
|
log = logging.getLogger("tgit")
|
||||||
|
|
||||||
|
|
||||||
async def run(stop: asyncio.Event) -> None:
|
def build_dispatcher(settings: Settings, texts: Texts) -> Dispatcher:
|
||||||
log.info("tgit %s started", __version__)
|
admin_ids = settings.access.admin_ids
|
||||||
await stop.wait()
|
|
||||||
log.info("tgit stopped")
|
async def is_member(tg_id: int) -> bool:
|
||||||
|
return tg_id in admin_ids
|
||||||
|
|
||||||
|
dp = Dispatcher(storage=make_storage())
|
||||||
|
access = AccessMiddleware(is_member, texts)
|
||||||
|
# Outer middleware: runs before filters, so nothing — not even a stale button — reaches a handler unchecked.
|
||||||
|
dp.message.outer_middleware(access)
|
||||||
|
dp.callback_query.outer_middleware(access)
|
||||||
|
dp.include_router(build_router(texts, admin_ids))
|
||||||
|
return dp
|
||||||
|
|
||||||
|
|
||||||
|
async def run(settings: Settings) -> None:
|
||||||
|
texts = load_texts(settings.runtime.config_dir)
|
||||||
|
dp = build_dispatcher(settings, texts)
|
||||||
|
rt = settings.runtime
|
||||||
|
beat = asyncio.create_task(heartbeat(rt.heartbeat_file, rt.heartbeat_interval))
|
||||||
|
bot = Bot(settings.telegram.bot_token.get_secret_value())
|
||||||
|
log.info("tgit %s starting, %d bootstrap admin(s)", __version__, len(settings.access.admin_ids))
|
||||||
|
try:
|
||||||
|
await dp.start_polling(bot, handle_signals=True)
|
||||||
|
finally:
|
||||||
|
beat.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await beat
|
||||||
|
await bot.session.close()
|
||||||
|
log.info("tgit stopped")
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
settings = Settings()
|
||||||
|
setup_logging(settings.runtime.log_level)
|
||||||
async def _main() -> None:
|
asyncio.run(run(settings))
|
||||||
stop = asyncio.Event()
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
||||||
loop.add_signal_handler(sig, stop.set)
|
|
||||||
await run(stop)
|
|
||||||
|
|
||||||
asyncio.run(_main())
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""UI texts from `config/texts.yaml`, validated at startup so a missing key fails fast instead of mid-dialog."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class _Strict(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||||
|
|
||||||
|
|
||||||
|
class MenuTexts(_Strict):
|
||||||
|
commit: str
|
||||||
|
status: str
|
||||||
|
logs: str
|
||||||
|
alerts: str
|
||||||
|
members: str
|
||||||
|
back: str
|
||||||
|
cancel: str
|
||||||
|
placeholder: str
|
||||||
|
|
||||||
|
|
||||||
|
class StartTexts(_Strict):
|
||||||
|
welcome: str
|
||||||
|
no_access: str
|
||||||
|
|
||||||
|
|
||||||
|
class HelpTexts(_Strict):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class SectionTexts(_Strict):
|
||||||
|
commit: str
|
||||||
|
status: str
|
||||||
|
logs: str
|
||||||
|
alerts: str
|
||||||
|
members: str
|
||||||
|
cancelled: str
|
||||||
|
|
||||||
|
|
||||||
|
class Texts(_Strict):
|
||||||
|
menu: MenuTexts
|
||||||
|
start: StartTexts
|
||||||
|
help: HelpTexts
|
||||||
|
section: SectionTexts
|
||||||
|
|
||||||
|
|
||||||
|
def load_texts(config_dir: Path) -> Texts:
|
||||||
|
with (config_dir / "texts.yaml").open(encoding="utf-8") as f:
|
||||||
|
return Texts.model_validate(yaml.safe_load(f))
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Тексты интерфейса бота. Ключи читает app/texts.py; отсутствующий ключ — ошибка при старте.
|
||||||
|
|
||||||
|
menu:
|
||||||
|
commit: "📝 Коммит"
|
||||||
|
status: "🩺 Статус"
|
||||||
|
logs: "📜 Логи"
|
||||||
|
alerts: "🚨 Алерты"
|
||||||
|
members: "👥 Участники"
|
||||||
|
back: "⬅️ Назад"
|
||||||
|
cancel: "✖️ Отмена"
|
||||||
|
placeholder: "Выберите раздел"
|
||||||
|
|
||||||
|
start:
|
||||||
|
welcome: |-
|
||||||
|
Привет! Я tgit — git в телеграме.
|
||||||
|
|
||||||
|
📝 Коммит — изменить файлы и запушить
|
||||||
|
🩺 Статус — контейнеры и их health
|
||||||
|
📜 Логи — хвост, свой grep или вопрос агенту
|
||||||
|
🚨 Алерты — падения, перегрузка, список
|
||||||
|
no_access: "Доступ к боту — только по приглашению. Попросите QR-код у администратора."
|
||||||
|
|
||||||
|
help:
|
||||||
|
text: |-
|
||||||
|
/menu — главное меню из любого места
|
||||||
|
/help — эта справка
|
||||||
|
|
||||||
|
Разделы открываются кнопками внизу. В любом шаге есть «⬅️ Назад» и «✖️ Отмена».
|
||||||
|
|
||||||
|
section:
|
||||||
|
commit: "📝 Коммит\n\nРаздел в разработке."
|
||||||
|
status: "🩺 Статус\n\nРаздел в разработке."
|
||||||
|
logs: "📜 Логи\n\nРаздел в разработке."
|
||||||
|
alerts: "🚨 Алерты\n\nРаздел в разработке."
|
||||||
|
members: "👥 Участники\n\nРаздел в разработке."
|
||||||
|
cancelled: "Отменено. Главное меню — кнопками внизу."
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import pytest
|
||||||
|
from aiogram.methods import AnswerCallbackQuery, EditMessageText, SendMessage
|
||||||
|
from aiogram.types import InlineKeyboardMarkup, ReplyKeyboardMarkup
|
||||||
|
|
||||||
|
from app.bot.menu import Nav, NavAction
|
||||||
|
from app.config import Settings
|
||||||
|
from app.main import build_dispatcher
|
||||||
|
from app.texts import Texts
|
||||||
|
from tests.conftest import ADMIN, OUTSIDER
|
||||||
|
from tests.fakes import TgClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tg(settings: Settings, texts: Texts) -> TgClient:
|
||||||
|
return TgClient(build_dispatcher(settings, texts))
|
||||||
|
|
||||||
|
|
||||||
|
def buttons(markup: ReplyKeyboardMarkup) -> list[str]:
|
||||||
|
return [b.text for row in markup.keyboard for b in row]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_start_shows_welcome_and_full_menu(tg: TgClient, texts: Texts) -> None:
|
||||||
|
await tg.send(ADMIN, "/start")
|
||||||
|
|
||||||
|
[reply] = tg.session.sent(SendMessage)
|
||||||
|
assert reply.text == texts.start.welcome
|
||||||
|
assert isinstance(reply.reply_markup, ReplyKeyboardMarkup)
|
||||||
|
m = texts.menu
|
||||||
|
assert buttons(reply.reply_markup) == [m.commit, m.status, m.logs, m.alerts, m.members]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_outsider_gets_one_refusal_then_silence(tg: TgClient, texts: Texts) -> None:
|
||||||
|
await tg.send(OUTSIDER, "/start")
|
||||||
|
await tg.send(OUTSIDER, "hello")
|
||||||
|
await tg.send(OUTSIDER, texts.menu.commit)
|
||||||
|
|
||||||
|
assert [r.text for r in tg.session.sent(SendMessage)] == [texts.start.no_access]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_outsider_stale_button_is_swallowed(tg: TgClient) -> None:
|
||||||
|
await tg.press(OUTSIDER, Nav(action=NavAction.BACK).pack())
|
||||||
|
|
||||||
|
assert tg.session.sent(EditMessageText) == []
|
||||||
|
assert len(tg.session.sent(AnswerCallbackQuery)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("section", ["commit", "status", "logs", "alerts", "members"])
|
||||||
|
async def test_menu_button_opens_section_with_nav(tg: TgClient, texts: Texts, section: str) -> None:
|
||||||
|
await tg.send(ADMIN, getattr(texts.menu, section))
|
||||||
|
|
||||||
|
[reply] = tg.session.sent(SendMessage)
|
||||||
|
assert reply.text == getattr(texts.section, section)
|
||||||
|
assert isinstance(reply.reply_markup, InlineKeyboardMarkup)
|
||||||
|
labels = [b.text for row in reply.reply_markup.inline_keyboard for b in row]
|
||||||
|
assert labels == [texts.menu.back, texts.menu.cancel]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("action", "expected"),
|
||||||
|
[(NavAction.BACK, "placeholder"), (NavAction.CANCEL, "cancelled")],
|
||||||
|
)
|
||||||
|
async def test_nav_edits_section_message_in_place(tg: TgClient, texts: Texts, action: NavAction, expected: str) -> None:
|
||||||
|
await tg.press(ADMIN, Nav(action=action).pack())
|
||||||
|
|
||||||
|
[edit] = tg.session.sent(EditMessageText)
|
||||||
|
want = texts.menu.placeholder if expected == "placeholder" else texts.section.cancelled
|
||||||
|
assert edit.text == want
|
||||||
|
assert tg.session.sent(SendMessage) == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_menu_and_help_commands(tg: TgClient, texts: Texts) -> None:
|
||||||
|
await tg.send(ADMIN, "/menu")
|
||||||
|
await tg.send(ADMIN, "/help")
|
||||||
|
|
||||||
|
assert [r.text for r in tg.session.sent(SendMessage)] == [texts.menu.placeholder, texts.help.text]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.config import AccessSettings, RuntimeSettings, Settings, TelegramSettings
|
||||||
|
from app.texts import Texts, load_texts
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
ADMIN = 1001
|
||||||
|
OUTSIDER = 2002
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def texts() -> Texts:
|
||||||
|
return load_texts(ROOT / "config")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def settings(tmp_path: Path) -> Settings:
|
||||||
|
return Settings(
|
||||||
|
telegram=TelegramSettings(bot_token="42:TEST-TOKEN"),
|
||||||
|
access=AccessSettings(admin_ids=frozenset({ADMIN})),
|
||||||
|
runtime=RuntimeSettings(config_dir=ROOT / "config", heartbeat_file=tmp_path / "hb"),
|
||||||
|
)
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
"""Offline Telegram: a fake aiogram session that records outgoing API calls and fabricates replies."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from itertools import count
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
from aiogram.client.session.base import BaseSession
|
||||||
|
from aiogram.methods import EditMessageText, SendMessage, TelegramMethod
|
||||||
|
from aiogram.methods.base import TelegramType
|
||||||
|
from aiogram.types import Chat, Message, Update, User
|
||||||
|
|
||||||
|
BOT_TOKEN = "42:TEST-TOKEN"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSession(BaseSession):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.requests: list[TelegramMethod[Any]] = []
|
||||||
|
self._ids = count(1000)
|
||||||
|
|
||||||
|
async def make_request(
|
||||||
|
self,
|
||||||
|
bot: Bot,
|
||||||
|
method: TelegramMethod[TelegramType],
|
||||||
|
timeout: int | None = None, # noqa: ASYNC109 - signature fixed by aiogram BaseSession
|
||||||
|
) -> TelegramType:
|
||||||
|
self.requests.append(method)
|
||||||
|
if isinstance(method, SendMessage | EditMessageText):
|
||||||
|
chat_id = method.chat_id if isinstance(method.chat_id, int) else 0
|
||||||
|
return Message( # type: ignore[return-value]
|
||||||
|
message_id=next(self._ids),
|
||||||
|
date=datetime.now(UTC),
|
||||||
|
chat=Chat(id=chat_id, type="private"),
|
||||||
|
text=method.text,
|
||||||
|
)
|
||||||
|
return True # type: ignore[return-value]
|
||||||
|
|
||||||
|
async def stream_content(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
headers: dict[str, Any] | None = None,
|
||||||
|
timeout: int = 30, # noqa: ASYNC109 - signature fixed by aiogram BaseSession
|
||||||
|
chunk_size: int = 65536,
|
||||||
|
raise_for_status: bool = True,
|
||||||
|
) -> AsyncGenerator[bytes, None]: # pragma: no cover - not used
|
||||||
|
raise NotImplementedError
|
||||||
|
yield b""
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def sent(self, method_type: type[TelegramMethod[Any]]) -> list[Any]:
|
||||||
|
return [r for r in self.requests if isinstance(r, method_type)]
|
||||||
|
|
||||||
|
|
||||||
|
class TgClient:
|
||||||
|
"""Feeds updates through a real Dispatcher (filters, middlewares, FSM) on top of FakeSession."""
|
||||||
|
|
||||||
|
def __init__(self, dp: Dispatcher) -> None:
|
||||||
|
self.dp = dp
|
||||||
|
self.session = FakeSession()
|
||||||
|
self.bot = Bot(BOT_TOKEN, session=self.session)
|
||||||
|
self._update_ids = count(1)
|
||||||
|
self._message_ids = count(1)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def user(tg_id: int) -> dict[str, Any]:
|
||||||
|
return {"id": tg_id, "is_bot": False, "first_name": f"u{tg_id}", "username": f"u{tg_id}"}
|
||||||
|
|
||||||
|
async def send(self, tg_id: int, text: str) -> None:
|
||||||
|
message = {
|
||||||
|
"message_id": next(self._message_ids),
|
||||||
|
"date": int(datetime.now(UTC).timestamp()),
|
||||||
|
"chat": {"id": tg_id, "type": "private"},
|
||||||
|
"from": self.user(tg_id),
|
||||||
|
"text": text,
|
||||||
|
}
|
||||||
|
if text.startswith("/"):
|
||||||
|
cmd = text.split()[0]
|
||||||
|
message["entities"] = [{"type": "bot_command", "offset": 0, "length": len(cmd)}]
|
||||||
|
await self._feed({"message": message})
|
||||||
|
|
||||||
|
async def press(self, tg_id: int, data: str, message_text: str = "section") -> None:
|
||||||
|
callback = {
|
||||||
|
"id": str(next(self._update_ids)),
|
||||||
|
"from": self.user(tg_id),
|
||||||
|
"chat_instance": "ci",
|
||||||
|
"data": data,
|
||||||
|
"message": {
|
||||||
|
"message_id": next(self._message_ids),
|
||||||
|
"date": int(datetime.now(UTC).timestamp()),
|
||||||
|
"chat": {"id": tg_id, "type": "private"},
|
||||||
|
"from": {"id": 42, "is_bot": True, "first_name": "tgit"},
|
||||||
|
"text": message_text,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await self._feed({"callback_query": callback})
|
||||||
|
|
||||||
|
async def _feed(self, payload: dict[str, Any]) -> None:
|
||||||
|
update = Update.model_validate({"update_id": next(self._update_ids), **payload}, context={"bot": self.bot})
|
||||||
|
await self.dp.feed_update(self.bot, update)
|
||||||
|
|
||||||
|
|
||||||
|
def as_user(tg_id: int) -> User:
|
||||||
|
return User(id=tg_id, is_bot=False, first_name=f"u{tg_id}")
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.config import AccessSettings
|
||||||
|
from app.texts import load_texts
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_ids_parsed_from_comma_separated_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("ADMIN_IDS", "1, 2,3,")
|
||||||
|
assert AccessSettings(_env_file=None).admin_ids == frozenset({1, 2, 3}) # type: ignore[call-arg]
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_ids_default_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.delenv("ADMIN_IDS", raising=False)
|
||||||
|
assert AccessSettings(_env_file=None).admin_ids == frozenset() # type: ignore[call-arg]
|
||||||
|
|
||||||
|
|
||||||
|
def test_texts_with_missing_key_fail_fast(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "texts.yaml").write_text("menu: {commit: x}\n", encoding="utf-8")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
load_texts(tmp_path)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import health
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_file_is_dead(tmp_path: Path) -> None:
|
||||||
|
assert not health.is_alive(tmp_path / "hb", max_age=30)
|
||||||
|
|
||||||
|
|
||||||
|
def test_age_decides(tmp_path: Path) -> None:
|
||||||
|
hb = tmp_path / "hb"
|
||||||
|
hb.touch()
|
||||||
|
os.utime(hb, (1000, 1000))
|
||||||
|
assert health.is_alive(hb, max_age=30, now=1029)
|
||||||
|
assert not health.is_alive(hb, max_age=30, now=1031)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heartbeat_touches_file(tmp_path: Path) -> None:
|
||||||
|
hb = tmp_path / "hb"
|
||||||
|
task = asyncio.create_task(health.heartbeat(hb, interval=10))
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert hb.exists()
|
||||||
|
task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("touch", "code"), [(True, 0), (False, 1)])
|
||||||
|
def test_cli_exit_code(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, touch: bool, code: int) -> None:
|
||||||
|
hb = tmp_path / "hb"
|
||||||
|
if touch:
|
||||||
|
hb.touch()
|
||||||
|
monkeypatch.setenv("HEARTBEAT_FILE", str(hb))
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
health.main()
|
||||||
|
assert exc.value.code == code
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.logs_setup import JsonFormatter
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_formatter_one_object_per_line() -> None:
|
||||||
|
record = logging.LogRecord("tgit", logging.INFO, __file__, 1, "hello %s", ("мир",), None)
|
||||||
|
entry = json.loads(JsonFormatter().format(record))
|
||||||
|
assert entry["msg"] == "hello мир"
|
||||||
|
assert entry["level"] == "INFO"
|
||||||
|
assert entry["logger"] == "tgit"
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
|
|
||||||
from app.main import run
|
|
||||||
|
|
||||||
|
|
||||||
async def test_run_returns_when_stop_is_set() -> None:
|
|
||||||
stop = asyncio.Event()
|
|
||||||
task = asyncio.create_task(run(stop))
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
assert not task.done()
|
|
||||||
|
|
||||||
stop.set()
|
|
||||||
await asyncio.wait_for(task, timeout=1)
|
|
||||||
Reference in New Issue
Block a user