feat(bot): точка входа, конфиг, главное меню, доступ для ADMIN_IDS, healthcheck
- config: pydantic-settings по подсистемам, ADMIN_IDS через запятую - texts.yaml + валидация при старте (лишний/пропущенный ключ — ошибка сразу) - меню: reply-клавиатура разделов (Участники — только админам), инлайн «Назад»/«Отмена» с редактированием сообщения на месте, /start, /menu, /help - AccessMiddleware (outer) на сообщениях и кнопках: до #25 пускает только ADMIN_IDS, постороннему — одна фраза в сутки, устаревшие кнопки молча гасятся - heartbeat-файл + HEALTHCHECK в Dockerfile; JSON-логи - FSM пока в памяти (фабрика), хранилище в БД — в #5 - тесты на фейковой сессии aiogram через настоящий Dispatcher Refs #4 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 contextlib
|
||||
import logging
|
||||
import signal
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
|
||||
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")
|
||||
|
||||
|
||||
async def run(stop: asyncio.Event) -> None:
|
||||
log.info("tgit %s started", __version__)
|
||||
await stop.wait()
|
||||
log.info("tgit stopped")
|
||||
def build_dispatcher(settings: Settings, texts: Texts) -> Dispatcher:
|
||||
admin_ids = settings.access.admin_ids
|
||||
|
||||
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:
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
|
||||
async def _main() -> None:
|
||||
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())
|
||||
settings = Settings()
|
||||
setup_logging(settings.runtime.log_level)
|
||||
asyncio.run(run(settings))
|
||||
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user