release: dev → main #54
@@ -0,0 +1,13 @@
|
|||||||
|
import io
|
||||||
|
|
||||||
|
import segno
|
||||||
|
|
||||||
|
|
||||||
|
def invite_link(bot_username: str, token: str) -> str:
|
||||||
|
return f"https://t.me/{bot_username}?start={token}"
|
||||||
|
|
||||||
|
|
||||||
|
def qr_png(data: str) -> bytes:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
segno.make(data, error="m").save(buf, kind="png", scale=10, border=2)
|
||||||
|
return buf.getvalue()
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""Invites, members and revocation (SPEC §3.1, ADR-011)."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from sqlalchemy import CursorResult, delete, func, select, update
|
||||||
|
|
||||||
|
from app.config import AccessSettings
|
||||||
|
from app.db.models import AccessAttempt, Draft, FsmState, Invite, Role, User, utcnow
|
||||||
|
from app.db.session import SessionMaker
|
||||||
|
|
||||||
|
LAST_SEEN_EVERY = timedelta(minutes=5)
|
||||||
|
ATTEMPT_WINDOW = timedelta(hours=1)
|
||||||
|
|
||||||
|
|
||||||
|
class RedeemStatus(StrEnum):
|
||||||
|
OK = "ok"
|
||||||
|
ALREADY_MEMBER = "already_member"
|
||||||
|
REJECTED = "rejected" # unknown, used or expired — one answer, no hints which tokens existed
|
||||||
|
THROTTLED = "throttled"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TgUser:
|
||||||
|
tg_id: int
|
||||||
|
full_name: str
|
||||||
|
username: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CreatedInvite:
|
||||||
|
token: str
|
||||||
|
expires_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class AccessService:
|
||||||
|
def __init__(self, sessions: SessionMaker, settings: AccessSettings, clock: Callable[[], datetime] = utcnow):
|
||||||
|
self._sessions = sessions
|
||||||
|
self._settings = settings
|
||||||
|
self._clock = clock
|
||||||
|
|
||||||
|
async def bootstrap_admins(self) -> None:
|
||||||
|
"""ADMIN_IDS become active admins in the DB; everybody else gets in only by invite (SPEC A-8)."""
|
||||||
|
async with self._sessions.begin() as s:
|
||||||
|
for tg_id in self._settings.admin_ids:
|
||||||
|
user = await s.get(User, tg_id)
|
||||||
|
if user is None:
|
||||||
|
s.add(User(tg_id=tg_id, full_name=f"admin {tg_id}", role=Role.ADMIN, joined_at=self._clock()))
|
||||||
|
else:
|
||||||
|
user.role, user.is_active = Role.ADMIN, True
|
||||||
|
|
||||||
|
async def get_member(self, tg_id: int) -> User | None:
|
||||||
|
async with self._sessions() as s:
|
||||||
|
user = await s.get(User, tg_id)
|
||||||
|
return user if user is not None and user.is_active else None
|
||||||
|
|
||||||
|
async def touch(self, member: User, seen: TgUser) -> None:
|
||||||
|
"""Refresh name and last-seen, at most every few minutes — not a write per update."""
|
||||||
|
now = self._clock()
|
||||||
|
last = member.last_seen_at
|
||||||
|
if (
|
||||||
|
last is not None
|
||||||
|
and now - _aware(last) < LAST_SEEN_EVERY
|
||||||
|
and (member.full_name, member.username) == (seen.full_name, seen.username)
|
||||||
|
):
|
||||||
|
return
|
||||||
|
async with self._sessions.begin() as s:
|
||||||
|
await s.execute(
|
||||||
|
update(User)
|
||||||
|
.where(User.tg_id == member.tg_id)
|
||||||
|
.values(last_seen_at=now, full_name=seen.full_name, username=seen.username)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def create_invite(self, admin_id: int) -> CreatedInvite:
|
||||||
|
token = secrets.token_urlsafe(16) # 128 bits, 22 chars of [A-Za-z0-9_-]: fits the 64-char deep link payload
|
||||||
|
expires_at = self._clock() + timedelta(hours=self._settings.invite_ttl_hours)
|
||||||
|
async with self._sessions.begin() as s:
|
||||||
|
s.add(
|
||||||
|
Invite(
|
||||||
|
token_hash=hash_token(token), created_by=admin_id, created_at=self._clock(), expires_at=expires_at
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return CreatedInvite(token=token, expires_at=expires_at)
|
||||||
|
|
||||||
|
async def redeem(self, token: str, who: TgUser) -> RedeemStatus:
|
||||||
|
now = self._clock()
|
||||||
|
try:
|
||||||
|
async with self._sessions.begin() as s:
|
||||||
|
existing = await s.get(User, who.tg_id)
|
||||||
|
if existing is not None and existing.is_active:
|
||||||
|
return RedeemStatus.ALREADY_MEMBER
|
||||||
|
failed = await s.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(AccessAttempt)
|
||||||
|
.where(
|
||||||
|
AccessAttempt.tg_id == who.tg_id,
|
||||||
|
AccessAttempt.ok.is_(False),
|
||||||
|
AccessAttempt.at > now - ATTEMPT_WINDOW,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (failed or 0) >= self._settings.max_failed_attempts:
|
||||||
|
return RedeemStatus.THROTTLED
|
||||||
|
|
||||||
|
invite = await s.scalar(select(Invite).where(Invite.token_hash == hash_token(token)))
|
||||||
|
if invite is None or invite.used_by is not None or _aware(invite.expires_at) <= now:
|
||||||
|
raise _Rejected
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
s.add(
|
||||||
|
User(
|
||||||
|
tg_id=who.tg_id,
|
||||||
|
full_name=who.full_name,
|
||||||
|
username=who.username,
|
||||||
|
role=Role.MEMBER,
|
||||||
|
invited_by=invite.created_by,
|
||||||
|
joined_at=now,
|
||||||
|
last_seen_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else: # revoked earlier, invited again
|
||||||
|
existing.is_active, existing.role, existing.invited_by = True, Role.MEMBER, invite.created_by
|
||||||
|
existing.full_name, existing.username, existing.last_seen_at = who.full_name, who.username, now
|
||||||
|
await s.flush() # the user row must exist before invites.used_by points at it
|
||||||
|
|
||||||
|
# Claim atomically: of two people scanning the same QR only one UPDATE matches the row,
|
||||||
|
# the loser's transaction (with its freshly created user) is rolled back.
|
||||||
|
result = await s.execute(
|
||||||
|
update(Invite)
|
||||||
|
.where(Invite.id == invite.id, Invite.used_by.is_(None))
|
||||||
|
.values(used_by=who.tg_id, used_at=now)
|
||||||
|
)
|
||||||
|
if cast(CursorResult[Any], result).rowcount != 1:
|
||||||
|
raise _Rejected
|
||||||
|
s.add(AccessAttempt(tg_id=who.tg_id, at=now, ok=True))
|
||||||
|
except _Rejected:
|
||||||
|
async with self._sessions.begin() as s:
|
||||||
|
s.add(AccessAttempt(tg_id=who.tg_id, at=now, ok=False))
|
||||||
|
return RedeemStatus.REJECTED
|
||||||
|
return RedeemStatus.OK
|
||||||
|
|
||||||
|
async def list_members(self) -> list[User]:
|
||||||
|
async with self._sessions() as s:
|
||||||
|
rows = await s.scalars(select(User).where(User.is_active.is_(True)).order_by(User.role, User.joined_at))
|
||||||
|
return list(rows)
|
||||||
|
|
||||||
|
async def revoke(self, tg_id: int) -> bool:
|
||||||
|
"""Cut a member off at once: their updates are ignored from now on, draft and dialog state are dropped."""
|
||||||
|
async with self._sessions.begin() as s:
|
||||||
|
user = await s.get(User, tg_id)
|
||||||
|
if user is None or not user.is_active or user.role is Role.ADMIN:
|
||||||
|
return False
|
||||||
|
user.is_active = False
|
||||||
|
await s.execute(delete(Draft).where(Draft.user_id == tg_id))
|
||||||
|
# FSM keys are "bot:chat:user:…"; in a private chat chat == user
|
||||||
|
await s.execute(delete(FsmState).where(FsmState.key.like(f"%:{tg_id}:{tg_id}:%")))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class _Rejected(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime) -> datetime:
|
||||||
|
# SQLite hands back naive datetimes even for timezone=True columns; everything we store is UTC.
|
||||||
|
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Access gate on every update — messages and button presses alike (SPEC A-5).
|
"""Access gate on every update — messages and button presses alike (SPEC A-5, ADR-011).
|
||||||
|
|
||||||
Until invites land (#25) the only members are the bootstrap admins from `ADMIN_IDS`.
|
Members (active users in the DB) pass with `member` injected into handler data. Outsiders pass only with
|
||||||
|
`/start <token>` — the invite deep link; everything else gets one "invite only" reply a day, then silence.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
@@ -8,18 +9,28 @@ from collections.abc import Awaitable, Callable
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from aiogram import BaseMiddleware
|
from aiogram import BaseMiddleware
|
||||||
from aiogram.types import CallbackQuery, Message, TelegramObject
|
from aiogram.types import CallbackQuery, Message, TelegramObject, User
|
||||||
|
|
||||||
|
from app.access.service import AccessService, TgUser
|
||||||
from app.texts import Texts
|
from app.texts import Texts
|
||||||
|
|
||||||
IsMember = Callable[[int], Awaitable[bool]]
|
|
||||||
|
|
||||||
NO_ACCESS_REPLY_EVERY = 24 * 3600.0
|
NO_ACCESS_REPLY_EVERY = 24 * 3600.0
|
||||||
|
|
||||||
|
|
||||||
|
def tg_user(user: User) -> TgUser:
|
||||||
|
return TgUser(tg_id=user.id, full_name=user.full_name, username=user.username)
|
||||||
|
|
||||||
|
|
||||||
|
def is_invite_link(event: TelegramObject) -> bool:
|
||||||
|
if not isinstance(event, Message) or not event.text:
|
||||||
|
return False
|
||||||
|
parts = event.text.split(maxsplit=1)
|
||||||
|
return len(parts) == 2 and parts[0].split("@")[0] == "/start"
|
||||||
|
|
||||||
|
|
||||||
class AccessMiddleware(BaseMiddleware):
|
class AccessMiddleware(BaseMiddleware):
|
||||||
def __init__(self, is_member: IsMember, texts: Texts, clock: Callable[[], float] = time.monotonic) -> None:
|
def __init__(self, access: AccessService, texts: Texts, clock: Callable[[], float] = time.monotonic) -> None:
|
||||||
self._is_member = is_member
|
self._access = access
|
||||||
self._texts = texts
|
self._texts = texts
|
||||||
self._clock = clock
|
self._clock = clock
|
||||||
# tg_id -> when we last told this outsider "invite only"; answering every message would let them spam us.
|
# tg_id -> when we last told this outsider "invite only"; answering every message would let them spam us.
|
||||||
@@ -31,10 +42,17 @@ class AccessMiddleware(BaseMiddleware):
|
|||||||
event: TelegramObject,
|
event: TelegramObject,
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
) -> Any:
|
) -> Any:
|
||||||
user = data.get("event_from_user")
|
user: User | None = data.get("event_from_user")
|
||||||
if user is not None and await self._is_member(user.id):
|
if user is None:
|
||||||
|
return None
|
||||||
|
member = await self._access.get_member(user.id)
|
||||||
|
if member is not None:
|
||||||
|
await self._access.touch(member, tg_user(user))
|
||||||
|
data["member"] = member
|
||||||
|
return await handler(event, data)
|
||||||
|
if is_invite_link(event):
|
||||||
|
data["member"] = None
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
if user is not None:
|
|
||||||
await self._refuse(event, user.id)
|
await self._refuse(event, user.id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
+25
-16
@@ -1,15 +1,18 @@
|
|||||||
"""/start, /help, /menu, main menu buttons and in-section navigation."""
|
"""/start (incl. invite deep links), /help, /menu, main menu buttons and in-section navigation."""
|
||||||
|
|
||||||
from aiogram import F, Router
|
from aiogram import F, Router
|
||||||
from aiogram.filters import Command, CommandStart
|
from aiogram.filters import Command, CommandObject, CommandStart
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.types import CallbackQuery, Message
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
|
||||||
|
from app.access.service import AccessService, RedeemStatus
|
||||||
from app.bot.menu import Nav, NavAction, Section, main_menu, section_by_button, section_nav
|
from app.bot.menu import Nav, NavAction, Section, main_menu, section_by_button, section_nav
|
||||||
|
from app.bot.middlewares.access import tg_user
|
||||||
|
from app.db.models import Role, User
|
||||||
from app.texts import Texts
|
from app.texts import Texts
|
||||||
|
|
||||||
|
|
||||||
def build_router(texts: Texts, admin_ids: frozenset[int]) -> Router:
|
def build_router(texts: Texts, access: AccessService) -> Router:
|
||||||
router = Router(name="common")
|
router = Router(name="common")
|
||||||
sections = section_by_button(texts)
|
sections = section_by_button(texts)
|
||||||
section_text = {
|
section_text = {
|
||||||
@@ -17,33 +20,39 @@ def build_router(texts: Texts, admin_ids: frozenset[int]) -> Router:
|
|||||||
Section.STATUS: texts.section.status,
|
Section.STATUS: texts.section.status,
|
||||||
Section.LOGS: texts.section.logs,
|
Section.LOGS: texts.section.logs,
|
||||||
Section.ALERTS: texts.section.alerts,
|
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())
|
@router.message(CommandStart())
|
||||||
async def start(message: Message, state: FSMContext) -> None:
|
async def start(message: Message, command: CommandObject, state: FSMContext, member: User | None) -> None:
|
||||||
|
if member is None:
|
||||||
|
# Only an outsider with an invite token gets here (AccessMiddleware); a member's /start <x> is a plain start
|
||||||
|
if message.from_user is None or not command.args:
|
||||||
|
return
|
||||||
|
status = await access.redeem(command.args, tg_user(message.from_user))
|
||||||
|
if status is RedeemStatus.THROTTLED:
|
||||||
|
return # brute force: stay silent (SPEC A-6)
|
||||||
|
if status is RedeemStatus.REJECTED:
|
||||||
|
await message.answer(texts.start.invite_rejected)
|
||||||
|
return
|
||||||
|
member = await access.get_member(message.from_user.id)
|
||||||
|
if member is None:
|
||||||
|
return
|
||||||
await state.clear()
|
await state.clear()
|
||||||
await message.answer(texts.start.welcome, reply_markup=main_menu(texts, is_admin=is_admin(message)))
|
await message.answer(texts.start.welcome, reply_markup=main_menu(texts, is_admin=member.role is Role.ADMIN))
|
||||||
|
|
||||||
@router.message(Command("menu"))
|
@router.message(Command("menu"))
|
||||||
async def menu(message: Message, state: FSMContext) -> None:
|
async def menu(message: Message, state: FSMContext, member: User) -> None:
|
||||||
await state.clear()
|
await state.clear()
|
||||||
await message.answer(texts.menu.placeholder, reply_markup=main_menu(texts, is_admin=is_admin(message)))
|
await message.answer(texts.menu.placeholder, reply_markup=main_menu(texts, is_admin=member.role is Role.ADMIN))
|
||||||
|
|
||||||
@router.message(Command("help"))
|
@router.message(Command("help"))
|
||||||
async def help_(message: Message) -> None:
|
async def help_(message: Message) -> None:
|
||||||
await message.answer(texts.help.text)
|
await message.answer(texts.help.text)
|
||||||
|
|
||||||
@router.message(F.text.in_(sections))
|
@router.message(F.text.in_({k for k, v in sections.items() if v is not Section.MEMBERS}))
|
||||||
async def open_section(message: Message, state: FSMContext) -> None:
|
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 state.clear()
|
||||||
await message.answer(section_text[section], reply_markup=section_nav(texts))
|
await message.answer(section_text[sections[message.text or ""]], reply_markup=section_nav(texts))
|
||||||
|
|
||||||
@router.callback_query(Nav.filter())
|
@router.callback_query(Nav.filter())
|
||||||
async def navigate(query: CallbackQuery, callback_data: Nav, state: FSMContext) -> None:
|
async def navigate(query: CallbackQuery, callback_data: Nav, state: FSMContext) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""«👥 Участники» for admins: list, invite by QR, revoke access (SPEC A-1, A-7)."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
from aiogram import Bot, F, Router
|
||||||
|
from aiogram.filters.callback_data import CallbackData
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardMarkup, Message
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from app.access.qr import invite_link, qr_png
|
||||||
|
from app.access.service import AccessService
|
||||||
|
from app.bot.menu import Nav, NavAction
|
||||||
|
from app.db.models import Role, User
|
||||||
|
from app.texts import Texts
|
||||||
|
|
||||||
|
|
||||||
|
class MembersAction(StrEnum):
|
||||||
|
INVITE = "inv"
|
||||||
|
ASK_REVOKE = "ask"
|
||||||
|
REVOKE = "rev"
|
||||||
|
LIST = "list"
|
||||||
|
|
||||||
|
|
||||||
|
class MembersCb(CallbackData, prefix="mem"):
|
||||||
|
action: MembersAction
|
||||||
|
tg_id: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(dt: datetime | None, never: str) -> str:
|
||||||
|
return dt.strftime("%d.%m %H:%M") if dt else never
|
||||||
|
|
||||||
|
|
||||||
|
def render_members(texts: Texts, members: list[User]) -> tuple[str, InlineKeyboardMarkup]:
|
||||||
|
t = texts.members
|
||||||
|
lines = [t.title.format(count=len(members)), ""]
|
||||||
|
for m in members:
|
||||||
|
lines.append(
|
||||||
|
t.line.format(
|
||||||
|
icon="👑" if m.role is Role.ADMIN else "👤",
|
||||||
|
name=m.full_name,
|
||||||
|
username=f" @{m.username}" if m.username else "",
|
||||||
|
joined=_fmt(m.joined_at, t.never),
|
||||||
|
seen=_fmt(m.last_seen_at, t.never),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
kb = InlineKeyboardBuilder()
|
||||||
|
kb.button(text=t.invite, callback_data=MembersCb(action=MembersAction.INVITE))
|
||||||
|
for m in members:
|
||||||
|
if m.role is not Role.ADMIN:
|
||||||
|
kb.button(
|
||||||
|
text=t.revoke.format(name=m.full_name),
|
||||||
|
callback_data=MembersCb(action=MembersAction.ASK_REVOKE, tg_id=m.tg_id),
|
||||||
|
)
|
||||||
|
kb.button(text=texts.menu.back, callback_data=Nav(action=NavAction.BACK))
|
||||||
|
kb.adjust(1)
|
||||||
|
return "\n".join(lines), kb.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_router(texts: Texts, access: AccessService) -> Router:
|
||||||
|
router = Router(name="members")
|
||||||
|
t = texts.members
|
||||||
|
# Every handler here is admin-only; a member who somehow has the button (e.g. was demoted) gets nothing.
|
||||||
|
|
||||||
|
def is_admin(member: User | None) -> bool:
|
||||||
|
return member is not None and member.role is Role.ADMIN
|
||||||
|
|
||||||
|
@router.message(F.text == texts.menu.members)
|
||||||
|
async def open_members(message: Message, state: FSMContext, member: User) -> None:
|
||||||
|
if not is_admin(member):
|
||||||
|
return
|
||||||
|
await state.clear()
|
||||||
|
text, kb = render_members(texts, await access.list_members())
|
||||||
|
await message.answer(text, reply_markup=kb)
|
||||||
|
|
||||||
|
@router.callback_query(MembersCb.filter())
|
||||||
|
async def on_action(query: CallbackQuery, callback_data: MembersCb, bot: Bot, member: User) -> None:
|
||||||
|
if not is_admin(member) or not isinstance(query.message, Message):
|
||||||
|
await query.answer()
|
||||||
|
return
|
||||||
|
msg = query.message
|
||||||
|
match callback_data.action:
|
||||||
|
case MembersAction.INVITE:
|
||||||
|
invite = await access.create_invite(member.tg_id)
|
||||||
|
me = await bot.me()
|
||||||
|
link = invite_link(me.username or "", invite.token)
|
||||||
|
caption = t.invite_caption.format(link=link, expires=invite.expires_at.strftime("%d.%m %H:%M"))
|
||||||
|
await msg.answer_photo(BufferedInputFile(qr_png(link), "invite.png"), caption=caption)
|
||||||
|
case MembersAction.ASK_REVOKE:
|
||||||
|
target = await access.get_member(callback_data.tg_id)
|
||||||
|
name = target.full_name if target else str(callback_data.tg_id)
|
||||||
|
kb = InlineKeyboardBuilder()
|
||||||
|
kb.button(
|
||||||
|
text=t.confirm_yes, callback_data=MembersCb(action=MembersAction.REVOKE, tg_id=callback_data.tg_id)
|
||||||
|
)
|
||||||
|
kb.button(text=t.confirm_no, callback_data=MembersCb(action=MembersAction.LIST))
|
||||||
|
await msg.edit_text(t.confirm.format(name=name), reply_markup=kb.as_markup())
|
||||||
|
case MembersAction.REVOKE:
|
||||||
|
target = await access.get_member(callback_data.tg_id)
|
||||||
|
name = target.full_name if target else str(callback_data.tg_id)
|
||||||
|
ok = await access.revoke(callback_data.tg_id)
|
||||||
|
text, kb_markup = render_members(texts, await access.list_members())
|
||||||
|
note = t.revoked.format(name=name) if ok else t.not_revoked
|
||||||
|
await msg.edit_text(f"{note}\n\n{text}", reply_markup=kb_markup)
|
||||||
|
case MembersAction.LIST:
|
||||||
|
text, kb_markup = render_members(texts, await access.list_members())
|
||||||
|
await msg.edit_text(text, reply_markup=kb_markup)
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
return router
|
||||||
@@ -20,6 +20,9 @@ class AccessSettings(BaseSettings):
|
|||||||
|
|
||||||
# Bootstrap admins (SPEC A-8). Comma-separated in the env: ADMIN_IDS=1,2
|
# Bootstrap admins (SPEC A-8). Comma-separated in the env: ADMIN_IDS=1,2
|
||||||
admin_ids: Annotated[frozenset[int], NoDecode] = frozenset()
|
admin_ids: Annotated[frozenset[int], NoDecode] = frozenset()
|
||||||
|
invite_ttl_hours: float = Field(default=24.0, gt=0)
|
||||||
|
# More failed /start <token> per hour than this — silence (SPEC A-6)
|
||||||
|
max_failed_attempts: int = Field(default=5, ge=1)
|
||||||
|
|
||||||
@field_validator("admin_ids", mode="before")
|
@field_validator("admin_ids", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
+11
-12
@@ -7,9 +7,10 @@ import logging
|
|||||||
from aiogram import Bot, Dispatcher
|
from aiogram import Bot, Dispatcher
|
||||||
|
|
||||||
from app import __version__
|
from app import __version__
|
||||||
|
from app.access.service import AccessService
|
||||||
from app.bot.fsm import make_storage
|
from app.bot.fsm import make_storage
|
||||||
from app.bot.middlewares.access import AccessMiddleware
|
from app.bot.middlewares.access import AccessMiddleware
|
||||||
from app.bot.routers.common import build_router
|
from app.bot.routers import common, members
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.db.maintenance import cleanup_loop
|
from app.db.maintenance import cleanup_loop
|
||||||
from app.db.migrate import upgrade
|
from app.db.migrate import upgrade
|
||||||
@@ -21,18 +22,14 @@ from app.texts import Texts, load_texts
|
|||||||
log = logging.getLogger("tgit")
|
log = logging.getLogger("tgit")
|
||||||
|
|
||||||
|
|
||||||
def build_dispatcher(settings: Settings, texts: Texts, sessions: SessionMaker) -> Dispatcher:
|
def build_dispatcher(texts: Texts, sessions: SessionMaker, access: AccessService) -> 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(sessions))
|
dp = Dispatcher(storage=make_storage(sessions))
|
||||||
access = AccessMiddleware(is_member, texts)
|
gate = AccessMiddleware(access, texts)
|
||||||
# Outer middleware: runs before filters, so nothing — not even a stale button — reaches a handler unchecked.
|
# Outer middleware: runs before filters, so nothing — not even a stale button — reaches a handler unchecked.
|
||||||
dp.message.outer_middleware(access)
|
dp.message.outer_middleware(gate)
|
||||||
dp.callback_query.outer_middleware(access)
|
dp.callback_query.outer_middleware(gate)
|
||||||
dp.include_router(build_router(texts, admin_ids))
|
dp.include_router(members.build_router(texts, access))
|
||||||
|
dp.include_router(common.build_router(texts, access))
|
||||||
return dp
|
return dp
|
||||||
|
|
||||||
|
|
||||||
@@ -41,7 +38,9 @@ async def run(settings: Settings) -> None:
|
|||||||
engine = make_engine(settings.database.url)
|
engine = make_engine(settings.database.url)
|
||||||
await upgrade(engine)
|
await upgrade(engine)
|
||||||
sessions = make_sessionmaker(engine)
|
sessions = make_sessionmaker(engine)
|
||||||
dp = build_dispatcher(settings, texts, sessions)
|
access = AccessService(sessions, settings.access)
|
||||||
|
await access.bootstrap_admins()
|
||||||
|
dp = build_dispatcher(texts, sessions, access)
|
||||||
rt = settings.runtime
|
rt = settings.runtime
|
||||||
background = [
|
background = [
|
||||||
asyncio.create_task(heartbeat(rt.heartbeat_file, rt.heartbeat_interval)),
|
asyncio.create_task(heartbeat(rt.heartbeat_file, rt.heartbeat_interval)),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class MenuTexts(_Strict):
|
|||||||
class StartTexts(_Strict):
|
class StartTexts(_Strict):
|
||||||
welcome: str
|
welcome: str
|
||||||
no_access: str
|
no_access: str
|
||||||
|
invite_rejected: str
|
||||||
|
|
||||||
|
|
||||||
class HelpTexts(_Strict):
|
class HelpTexts(_Strict):
|
||||||
@@ -39,11 +40,26 @@ class SectionTexts(_Strict):
|
|||||||
cancelled: str
|
cancelled: str
|
||||||
|
|
||||||
|
|
||||||
|
class MembersTexts(_Strict):
|
||||||
|
title: str
|
||||||
|
line: str
|
||||||
|
never: str
|
||||||
|
invite: str
|
||||||
|
revoke: str
|
||||||
|
confirm: str
|
||||||
|
confirm_yes: str
|
||||||
|
confirm_no: str
|
||||||
|
revoked: str
|
||||||
|
not_revoked: str
|
||||||
|
invite_caption: str
|
||||||
|
|
||||||
|
|
||||||
class Texts(_Strict):
|
class Texts(_Strict):
|
||||||
menu: MenuTexts
|
menu: MenuTexts
|
||||||
start: StartTexts
|
start: StartTexts
|
||||||
help: HelpTexts
|
help: HelpTexts
|
||||||
section: SectionTexts
|
section: SectionTexts
|
||||||
|
members: MembersTexts
|
||||||
|
|
||||||
|
|
||||||
def load_texts(config_dir: Path) -> Texts:
|
def load_texts(config_dir: Path) -> Texts:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ start:
|
|||||||
📜 Логи — хвост, свой grep или вопрос агенту
|
📜 Логи — хвост, свой grep или вопрос агенту
|
||||||
🚨 Алерты — падения, перегрузка, список
|
🚨 Алерты — падения, перегрузка, список
|
||||||
no_access: "Доступ к боту — только по приглашению. Попросите QR-код у администратора."
|
no_access: "Доступ к боту — только по приглашению. Попросите QR-код у администратора."
|
||||||
|
invite_rejected: "Приглашение недействительно: истекло, уже использовано или ссылка неверная. Попросите новое у администратора."
|
||||||
|
|
||||||
help:
|
help:
|
||||||
text: |-
|
text: |-
|
||||||
@@ -34,3 +35,20 @@ section:
|
|||||||
alerts: "🚨 Алерты\n\nРаздел в разработке."
|
alerts: "🚨 Алерты\n\nРаздел в разработке."
|
||||||
members: "👥 Участники\n\nРаздел в разработке."
|
members: "👥 Участники\n\nРаздел в разработке."
|
||||||
cancelled: "Отменено. Главное меню — кнопками внизу."
|
cancelled: "Отменено. Главное меню — кнопками внизу."
|
||||||
|
|
||||||
|
members:
|
||||||
|
title: "👥 Участники: {count}"
|
||||||
|
line: "{icon} {name}{username} · с {joined} · был(а) {seen}"
|
||||||
|
never: "—"
|
||||||
|
invite: "➕ Пригласить"
|
||||||
|
revoke: "🚫 {name}"
|
||||||
|
confirm: "Отозвать доступ у {name}? Его черновик удалится, кнопки перестанут работать."
|
||||||
|
confirm_yes: "Да, отозвать"
|
||||||
|
confirm_no: "Нет"
|
||||||
|
revoked: "Доступ отозван: {name}."
|
||||||
|
not_revoked: "Нельзя отозвать: администратор или уже без доступа."
|
||||||
|
invite_caption: |-
|
||||||
|
Приглашение в tgit — отсканируйте QR или откройте ссылку:
|
||||||
|
{link}
|
||||||
|
|
||||||
|
Одноразовое, действует до {expires} UTC.
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.access.service import AccessService, RedeemStatus, TgUser, hash_token
|
||||||
|
from app.config import AccessSettings
|
||||||
|
from app.db.models import Draft, FsmState, Invite, Role, User
|
||||||
|
from app.db.session import SessionMaker
|
||||||
|
|
||||||
|
ADMIN = 1
|
||||||
|
NEWBIE = TgUser(tg_id=500, full_name="Новичок", username="newbie")
|
||||||
|
|
||||||
|
|
||||||
|
class Clock:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.now = datetime(2026, 10, 15, 12, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
def __call__(self) -> datetime:
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def clock() -> Clock:
|
||||||
|
return Clock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def svc(sessions: SessionMaker, clock: Clock) -> AccessService:
|
||||||
|
service = AccessService(sessions, AccessSettings(admin_ids=frozenset({ADMIN}), invite_ttl_hours=24), clock=clock)
|
||||||
|
await service.bootstrap_admins()
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
async def test_bootstrap_is_idempotent_and_restores_admin(svc: AccessService, sessions: SessionMaker) -> None:
|
||||||
|
async with sessions.begin() as s:
|
||||||
|
admin = await s.get(User, ADMIN)
|
||||||
|
assert admin is not None
|
||||||
|
admin.is_active, admin.role = False, Role.MEMBER
|
||||||
|
await svc.bootstrap_admins()
|
||||||
|
await svc.bootstrap_admins()
|
||||||
|
|
||||||
|
member = await svc.get_member(ADMIN)
|
||||||
|
assert member is not None and member.role is Role.ADMIN
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invite_stores_only_hash(svc: AccessService, sessions: SessionMaker, clock: Clock) -> None:
|
||||||
|
invite = await svc.create_invite(ADMIN)
|
||||||
|
|
||||||
|
assert len(invite.token) == 22 and invite.token.replace("-", "").replace("_", "").isalnum()
|
||||||
|
assert invite.expires_at == clock.now + timedelta(hours=24)
|
||||||
|
async with sessions() as s:
|
||||||
|
stored = await s.scalar(select(Invite))
|
||||||
|
assert stored is not None and stored.token_hash == hash_token(invite.token) != invite.token
|
||||||
|
|
||||||
|
|
||||||
|
async def test_redeem_once_then_rejected_for_others(svc: AccessService, sessions: SessionMaker) -> None:
|
||||||
|
invite = await svc.create_invite(ADMIN)
|
||||||
|
|
||||||
|
assert await svc.redeem(invite.token, NEWBIE) is RedeemStatus.OK
|
||||||
|
assert await svc.redeem(invite.token, NEWBIE) is RedeemStatus.ALREADY_MEMBER
|
||||||
|
assert await svc.redeem(invite.token, TgUser(tg_id=501, full_name="x")) is RedeemStatus.REJECTED
|
||||||
|
|
||||||
|
member = await svc.get_member(NEWBIE.tg_id)
|
||||||
|
assert member is not None and member.role is Role.MEMBER and member.invited_by == ADMIN
|
||||||
|
async with sessions() as s:
|
||||||
|
stored = await s.scalar(select(Invite))
|
||||||
|
assert stored is not None and stored.used_by == NEWBIE.tg_id
|
||||||
|
assert await s.get(User, 501) is None # the loser's user row was rolled back
|
||||||
|
|
||||||
|
|
||||||
|
async def test_expired_invite_rejected(svc: AccessService, clock: Clock) -> None:
|
||||||
|
invite = await svc.create_invite(ADMIN)
|
||||||
|
clock.now += timedelta(hours=24, seconds=1)
|
||||||
|
|
||||||
|
assert await svc.redeem(invite.token, NEWBIE) is RedeemStatus.REJECTED
|
||||||
|
assert await svc.get_member(NEWBIE.tg_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_brute_force_is_throttled_even_with_a_valid_token(svc: AccessService, clock: Clock) -> None:
|
||||||
|
for i in range(5):
|
||||||
|
assert await svc.redeem(f"guess-{i}", NEWBIE) is RedeemStatus.REJECTED
|
||||||
|
invite = await svc.create_invite(ADMIN)
|
||||||
|
|
||||||
|
assert await svc.redeem(invite.token, NEWBIE) is RedeemStatus.THROTTLED
|
||||||
|
clock.now += timedelta(hours=1, seconds=1)
|
||||||
|
assert await svc.redeem(invite.token, NEWBIE) is RedeemStatus.OK
|
||||||
|
|
||||||
|
|
||||||
|
async def test_revoke_drops_access_draft_and_dialog(svc: AccessService, sessions: SessionMaker, clock: Clock) -> None:
|
||||||
|
await svc.redeem((await svc.create_invite(ADMIN)).token, NEWBIE)
|
||||||
|
async with sessions.begin() as s:
|
||||||
|
s.add(Draft(user_id=NEWBIE.tg_id, base_sha="0" * 40, expires_at=clock.now + timedelta(hours=1)))
|
||||||
|
s.add(FsmState(key=f"42:{NEWBIE.tg_id}:{NEWBIE.tg_id}:::default", state="x", data={}))
|
||||||
|
s.add(FsmState(key=f"42:{ADMIN}:{ADMIN}:::default", state="y", data={}))
|
||||||
|
|
||||||
|
assert await svc.revoke(NEWBIE.tg_id)
|
||||||
|
|
||||||
|
assert await svc.get_member(NEWBIE.tg_id) is None
|
||||||
|
async with sessions() as s:
|
||||||
|
assert await s.scalar(select(Draft)) is None
|
||||||
|
assert [r.key for r in await s.scalars(select(FsmState))] == [f"42:{ADMIN}:{ADMIN}:::default"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_cannot_be_revoked_and_unknown_is_noop(svc: AccessService) -> None:
|
||||||
|
assert not await svc.revoke(ADMIN)
|
||||||
|
assert not await svc.revoke(999)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_revoked_member_can_be_invited_again(svc: AccessService) -> None:
|
||||||
|
await svc.redeem((await svc.create_invite(ADMIN)).token, NEWBIE)
|
||||||
|
await svc.revoke(NEWBIE.tg_id)
|
||||||
|
|
||||||
|
assert await svc.redeem((await svc.create_invite(ADMIN)).token, NEWBIE) is RedeemStatus.OK
|
||||||
|
assert await svc.get_member(NEWBIE.tg_id) is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_members_listed_admins_first(svc: AccessService) -> None:
|
||||||
|
await svc.redeem((await svc.create_invite(ADMIN)).token, NEWBIE)
|
||||||
|
|
||||||
|
assert [m.tg_id for m in await svc.list_members()] == [ADMIN, NEWBIE.tg_id]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_touch_is_throttled_but_tracks_renames(svc: AccessService, clock: Clock) -> None:
|
||||||
|
member = await svc.get_member(ADMIN)
|
||||||
|
assert member is not None
|
||||||
|
await svc.touch(member, TgUser(ADMIN, "Админ", "boss"))
|
||||||
|
first = await svc.get_member(ADMIN)
|
||||||
|
assert first is not None and first.full_name == "Админ" and first.last_seen_at is not None
|
||||||
|
|
||||||
|
clock.now += timedelta(minutes=1)
|
||||||
|
await svc.touch(first, TgUser(ADMIN, "Админ", "boss"))
|
||||||
|
same = await svc.get_member(ADMIN)
|
||||||
|
assert same is not None and same.last_seen_at == first.last_seen_at # no write within 5 minutes
|
||||||
|
|
||||||
|
await svc.touch(same, TgUser(ADMIN, "Новое имя", "boss"))
|
||||||
|
renamed = await svc.get_member(ADMIN)
|
||||||
|
assert renamed is not None and renamed.full_name == "Новое имя"
|
||||||
@@ -2,8 +2,8 @@ import pytest
|
|||||||
from aiogram.methods import AnswerCallbackQuery, EditMessageText, SendMessage
|
from aiogram.methods import AnswerCallbackQuery, EditMessageText, SendMessage
|
||||||
from aiogram.types import InlineKeyboardMarkup, ReplyKeyboardMarkup
|
from aiogram.types import InlineKeyboardMarkup, ReplyKeyboardMarkup
|
||||||
|
|
||||||
|
from app.access.service import AccessService
|
||||||
from app.bot.menu import Nav, NavAction
|
from app.bot.menu import Nav, NavAction
|
||||||
from app.config import Settings
|
|
||||||
from app.db.session import SessionMaker
|
from app.db.session import SessionMaker
|
||||||
from app.main import build_dispatcher
|
from app.main import build_dispatcher
|
||||||
from app.texts import Texts
|
from app.texts import Texts
|
||||||
@@ -12,8 +12,8 @@ from tests.fakes import TgClient
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def tg(settings: Settings, texts: Texts, sessions: SessionMaker) -> TgClient:
|
def tg(texts: Texts, sessions: SessionMaker, access: AccessService) -> TgClient:
|
||||||
return TgClient(build_dispatcher(settings, texts, sessions))
|
return TgClient(build_dispatcher(texts, sessions, access))
|
||||||
|
|
||||||
|
|
||||||
def buttons(markup: ReplyKeyboardMarkup) -> list[str]:
|
def buttons(markup: ReplyKeyboardMarkup) -> list[str]:
|
||||||
@@ -45,7 +45,7 @@ async def test_outsider_stale_button_is_swallowed(tg: TgClient) -> None:
|
|||||||
assert len(tg.session.sent(AnswerCallbackQuery)) == 1
|
assert len(tg.session.sent(AnswerCallbackQuery)) == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("section", ["commit", "status", "logs", "alerts", "members"])
|
@pytest.mark.parametrize("section", ["commit", "status", "logs", "alerts"])
|
||||||
async def test_menu_button_opens_section_with_nav(tg: TgClient, texts: Texts, section: str) -> None:
|
async def test_menu_button_opens_section_with_nav(tg: TgClient, texts: Texts, section: str) -> None:
|
||||||
await tg.send(ADMIN, getattr(texts.menu, section))
|
await tg.send(ADMIN, getattr(texts.menu, section))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from aiogram.methods import AnswerCallbackQuery, EditMessageText, SendMessage, SendPhoto
|
||||||
|
from aiogram.types import ReplyKeyboardMarkup
|
||||||
|
|
||||||
|
from app.access.service import AccessService
|
||||||
|
from app.bot.routers.members import MembersAction, MembersCb
|
||||||
|
from app.db.session import SessionMaker
|
||||||
|
from app.main import build_dispatcher
|
||||||
|
from app.texts import Texts
|
||||||
|
from tests.conftest import ADMIN
|
||||||
|
from tests.fakes import TgClient
|
||||||
|
|
||||||
|
NEWBIE = 3003
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tg(texts: Texts, sessions: SessionMaker, access: AccessService) -> TgClient:
|
||||||
|
return TgClient(build_dispatcher(texts, sessions, access))
|
||||||
|
|
||||||
|
|
||||||
|
async def invite_token(tg: TgClient) -> str:
|
||||||
|
await tg.press(ADMIN, MembersCb(action=MembersAction.INVITE).pack())
|
||||||
|
[photo] = tg.session.sent(SendPhoto)
|
||||||
|
match = re.search(r"https://t\.me/tgit_test_bot\?start=([A-Za-z0-9_-]+)", photo.caption or "")
|
||||||
|
assert match, photo.caption
|
||||||
|
tg.session.requests.clear()
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_sees_members_list(tg: TgClient, texts: Texts) -> None:
|
||||||
|
await tg.send(ADMIN, texts.menu.members)
|
||||||
|
|
||||||
|
[reply] = tg.session.sent(SendMessage)
|
||||||
|
assert reply.text.startswith(texts.members.title.format(count=1))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_full_invite_flow(tg: TgClient, texts: Texts) -> None:
|
||||||
|
token = await invite_token(tg)
|
||||||
|
|
||||||
|
await tg.send(NEWBIE, f"/start {token}")
|
||||||
|
|
||||||
|
[welcome] = tg.session.sent(SendMessage)
|
||||||
|
assert welcome.text == texts.start.welcome
|
||||||
|
assert isinstance(welcome.reply_markup, ReplyKeyboardMarkup)
|
||||||
|
labels = [b.text for row in welcome.reply_markup.keyboard for b in row]
|
||||||
|
assert texts.menu.members not in labels # a member, not an admin
|
||||||
|
|
||||||
|
# the same QR again, from someone else
|
||||||
|
tg.session.requests.clear()
|
||||||
|
await tg.send(NEWBIE + 1, f"/start {token}")
|
||||||
|
assert [r.text for r in tg.session.sent(SendMessage)] == [texts.start.invite_rejected]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_bad_token_rejected_and_bare_start_refused(tg: TgClient, texts: Texts) -> None:
|
||||||
|
await tg.send(NEWBIE, "/start nope")
|
||||||
|
await tg.send(NEWBIE + 1, "/start")
|
||||||
|
|
||||||
|
assert [r.text for r in tg.session.sent(SendMessage)] == [texts.start.invite_rejected, texts.start.no_access]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_revoke_with_confirmation_cuts_access(tg: TgClient, texts: Texts) -> None:
|
||||||
|
await tg.send(NEWBIE, f"/start {await invite_token(tg)}")
|
||||||
|
tg.session.requests.clear()
|
||||||
|
|
||||||
|
await tg.press(ADMIN, MembersCb(action=MembersAction.ASK_REVOKE, tg_id=NEWBIE).pack())
|
||||||
|
await tg.press(ADMIN, MembersCb(action=MembersAction.REVOKE, tg_id=NEWBIE).pack())
|
||||||
|
|
||||||
|
confirm, done = tg.session.sent(EditMessageText)
|
||||||
|
assert confirm.text == texts.members.confirm.format(name=f"u{NEWBIE}")
|
||||||
|
assert done.text.startswith(texts.members.revoked.format(name=f"u{NEWBIE}"))
|
||||||
|
|
||||||
|
tg.session.requests.clear()
|
||||||
|
await tg.send(NEWBIE, texts.menu.commit)
|
||||||
|
assert [r.text for r in tg.session.sent(SendMessage)] == [texts.start.no_access]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_member_cannot_use_admin_actions(tg: TgClient, texts: Texts) -> None:
|
||||||
|
await tg.send(NEWBIE, f"/start {await invite_token(tg)}")
|
||||||
|
tg.session.requests.clear()
|
||||||
|
|
||||||
|
await tg.send(NEWBIE, texts.menu.members)
|
||||||
|
await tg.press(NEWBIE, MembersCb(action=MembersAction.INVITE).pack())
|
||||||
|
|
||||||
|
assert tg.session.sent(SendMessage) == [] and tg.session.sent(SendPhoto) == []
|
||||||
|
assert len(tg.session.sent(AnswerCallbackQuery)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cancel_revoke_returns_to_list(tg: TgClient, texts: Texts) -> None:
|
||||||
|
await tg.press(ADMIN, MembersCb(action=MembersAction.LIST).pack())
|
||||||
|
|
||||||
|
[edit] = tg.session.sent(EditMessageText)
|
||||||
|
assert edit.text.startswith(texts.members.title.format(count=1))
|
||||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
|
|
||||||
|
from app.access.service import AccessService
|
||||||
from app.config import AccessSettings, RuntimeSettings, Settings, TelegramSettings
|
from app.config import AccessSettings, RuntimeSettings, Settings, TelegramSettings
|
||||||
from app.db.migrate import upgrade
|
from app.db.migrate import upgrade
|
||||||
from app.db.session import SessionMaker, make_engine, make_sessionmaker
|
from app.db.session import SessionMaker, make_engine, make_sessionmaker
|
||||||
@@ -40,3 +41,10 @@ async def engine() -> AsyncIterator[AsyncEngine]:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sessions(engine: AsyncEngine) -> SessionMaker:
|
def sessions(engine: AsyncEngine) -> SessionMaker:
|
||||||
return make_sessionmaker(engine)
|
return make_sessionmaker(engine)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def access(sessions: SessionMaker, settings: Settings) -> AccessService:
|
||||||
|
service = AccessService(sessions, settings.access)
|
||||||
|
await service.bootstrap_admins()
|
||||||
|
return service
|
||||||
|
|||||||
+11
-1
@@ -7,7 +7,7 @@ from typing import Any
|
|||||||
|
|
||||||
from aiogram import Bot, Dispatcher
|
from aiogram import Bot, Dispatcher
|
||||||
from aiogram.client.session.base import BaseSession
|
from aiogram.client.session.base import BaseSession
|
||||||
from aiogram.methods import EditMessageText, SendMessage, TelegramMethod
|
from aiogram.methods import EditMessageText, GetMe, SendMessage, SendPhoto, TelegramMethod
|
||||||
from aiogram.methods.base import TelegramType
|
from aiogram.methods.base import TelegramType
|
||||||
from aiogram.types import Chat, Message, Update, User
|
from aiogram.types import Chat, Message, Update, User
|
||||||
|
|
||||||
@@ -27,6 +27,16 @@ class FakeSession(BaseSession):
|
|||||||
timeout: int | None = None, # noqa: ASYNC109 - signature fixed by aiogram BaseSession
|
timeout: int | None = None, # noqa: ASYNC109 - signature fixed by aiogram BaseSession
|
||||||
) -> TelegramType:
|
) -> TelegramType:
|
||||||
self.requests.append(method)
|
self.requests.append(method)
|
||||||
|
if isinstance(method, GetMe):
|
||||||
|
return User(id=42, is_bot=True, first_name="tgit", username="tgit_test_bot") # type: ignore[return-value]
|
||||||
|
if isinstance(method, SendPhoto):
|
||||||
|
chat = 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, type="private"),
|
||||||
|
caption=method.caption,
|
||||||
|
)
|
||||||
if isinstance(method, SendMessage | EditMessageText):
|
if isinstance(method, SendMessage | EditMessageText):
|
||||||
chat_id = method.chat_id if isinstance(method.chat_id, int) else 0
|
chat_id = method.chat_id if isinstance(method.chat_id, int) else 0
|
||||||
return Message( # type: ignore[return-value]
|
return Message( # type: ignore[return-value]
|
||||||
|
|||||||
Reference in New Issue
Block a user