Author SHA1 Message Date
ElebobaandClaude Opus 5.5 e8c3a321a3 feat(access): демо-QR — многоразовое приглашение для показа
CI / ruff · mypy · pytest (pull_request) Successful in 1h3m54s
- invites.max_uses/uses: обычное приглашение — 1 вход, демо-QR — до DEMO_INVITE_MAX_USES (50),
  живёт DEMO_INVITE_TTL_HOURS (3 ч); захват атомарный, UPDATE … WHERE uses < max_uses
- users.guest: вошедшие по демо-QR; «🧹 Отключить гостей демо» отзывает всех и гасит демо-QR
- «👥 Участники»: кнопка «🎤 Демо-QR», гости в списке — 🎟
- миграция 0003 (ADD COLUMN без batch — SQLite не пересоздаёт users); проверена на Postgres
- SPEC A-3a, ADR-017, DEMO.md, .env.example

Refs #55

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:11:53 +03:00
Eleboba 7a12b3525a Merge pull request 'feat(llm): OpenRouter через прокси — агент логов' (#53) from feat/52-openrouter into dev
CD / deploy (push) Successful in 55s
CI / ruff · mypy · pytest (push) Successful in 1m44s
CI / ruff · mypy · pytest (pull_request) Successful in 16m15s
Reviewed-on: #53
2026-10-22 17:42:30 +03:00
ElebobaandClaude Opus 5.5 38bb238651 feat(llm): прокси до LLM и ключ OPENROUTER_API_KEY — OpenRouter для агента логов
CI / ruff · mypy · pytest (pull_request) Successful in 2m44s
- LLM_PROXY_URL (SecretStr): запросы к LLM через HTTP/SOCKS-прокси — OpenRouter
  не принимает IP из РФ, как в meditron (OPENROUTER_PROXY_URL)
- ключ читается из LLM_API_KEY или OPENROUTER_API_KEY (первый важнее)
- .env.example, README, DEPLOY: настройка OpenRouter

Refs #52

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:09:19 +03:00
Eleboba a0d48d8cbc Merge pull request 'docs: презентация и сценарий защиты' (#51) from docs/20-presentation into dev
CD / deploy (push) Successful in 25s
CI / ruff · mypy · pytest (push) Successful in 1m50s
Reviewed-on: #51
2026-10-19 13:48:39 +03:00
18 changed files with 304 additions and 23 deletions
+8 -2
View File
@@ -5,6 +5,8 @@
TELEGRAM_BOT_TOKEN= # (secret) токен от @BotFather TELEGRAM_BOT_TOKEN= # (secret) токен от @BotFather
ADMIN_IDS= # Telegram ID первых администраторов через запятую ADMIN_IDS= # Telegram ID первых администраторов через запятую
LOG_LEVEL=INFO LOG_LEVEL=INFO
DEMO_INVITE_MAX_USES=50 # демо-QR для показа (#55): сколько человек войдут по одному QR
DEMO_INVITE_TTL_HOURS=3 # и сколько часов он живёт
TELEGRAM_PROXY_URL= # (secret) http://user:pass@host:port или socks5://… — на RF-сервере Telegram недоступен напрямую TELEGRAM_PROXY_URL= # (secret) http://user:pass@host:port или socks5://… — на RF-сервере Telegram недоступен напрямую
# --- База данных --- # --- База данных ---
@@ -42,11 +44,15 @@ CI_TOKEN= # (secret) токен Gitea с чтением р
# RUNNER_USER=tgit-deploy # RUNNER_USER=tgit-deploy
# RUNNER_SSH_KEY_B64= # (secret) base64 ключа, прибитого к tgit-runner в authorized_keys # RUNNER_SSH_KEY_B64= # (secret) base64 ключа, прибитого к tgit-runner в authorized_keys
# --- LLM (#17) --- # --- LLM (#17, #52): агент логов ---
LLM_PROVIDER=mock # mock | openai_compat; при mock агент логов выключен (#18) LLM_PROVIDER=mock # mock | openai_compat; при mock агент логов выключен (#18)
# OpenRouter (как в meditron): модель обязана поддерживать structured output (json_schema)
# LLM_BASE_URL=https://openrouter.ai/api/v1
# LLM_MODEL=google/gemini-2.5-flash-lite
LLM_BASE_URL= LLM_BASE_URL=
LLM_MODEL= LLM_MODEL=
LLM_API_KEY= # (secret) LLM_API_KEY= # (secret) или OPENROUTER_API_KEY — то же самое
LLM_PROXY_URL= # (secret) http://user:pass@host:port — OpenRouter не пускает IP из РФ; на сервере тот же прокси, что TELEGRAM_PROXY_URL
# --- Для Claude Code: трекер (не нужно боту) --- # --- Для Claude Code: трекер (не нужно боту) ---
GITEA_URL=https://gitea.24studbank.ru GITEA_URL=https://gitea.24studbank.ru
+2 -1
View File
@@ -114,7 +114,8 @@ CD из Gitea Actions разворачивает `dev` на сервер скр
| `RUNNER_HOST`, `RUNNER_SSH_KEY_B64` | нет | `tgit-runner` на хосте; без него сервисы не разворачиваются | | `RUNNER_HOST`, `RUNNER_SSH_KEY_B64` | нет | `tgit-runner` на хосте; без него сервисы не разворачиваются |
| `DATABASE_URL` | нет | по умолчанию SQLite; в Compose — PostgreSQL | | `DATABASE_URL` | нет | по умолчанию SQLite; в Compose — PostgreSQL |
| `DOCKER_URL` | нет | docker-socket-proxy; без него «Статус», «Логи» и алерты отвечают «Docker недоступен» | | `DOCKER_URL` | нет | docker-socket-proxy; без него «Статус», «Логи» и алерты отвечают «Docker недоступен» |
| `LLM_PROVIDER`, `LLM_BASE_URL`, `LLM_MODEL`, `LLM_API_KEY` | нет | LLM для агента; при `mock` агент выключен | | `LLM_PROVIDER`, `LLM_BASE_URL`, `LLM_MODEL`, `LLM_API_KEY` | нет | LLM для агента (OpenAI-совместимый, например OpenRouter); при `mock` агент выключен |
| `LLM_PROXY_URL` | нет | прокси до LLM — OpenRouter не принимает запросы с IP из РФ |
### Проверки ### Проверки
+36 -8
View File
@@ -36,6 +36,7 @@ class TgUser:
class CreatedInvite: class CreatedInvite:
token: str token: str
expires_at: datetime expires_at: datetime
max_uses: int = 1
def hash_token(token: str) -> str: def hash_token(token: str) -> str:
@@ -80,16 +81,25 @@ class AccessService:
.values(last_seen_at=now, full_name=seen.full_name, username=seen.username) .values(last_seen_at=now, full_name=seen.full_name, username=seen.username)
) )
async def create_invite(self, admin_id: int) -> CreatedInvite: async def create_invite(self, admin_id: int, demo: bool = False) -> CreatedInvite:
"""A personal one-time invite, or the demo QR (#55): up to `demo_invite_max_uses` entrants, short-lived."""
token = secrets.token_urlsafe(16) # 128 bits, 22 chars of [A-Za-z0-9_-]: fits the 64-char deep link payload 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) if demo:
max_uses, ttl = self._settings.demo_invite_max_uses, self._settings.demo_invite_ttl_hours
else:
max_uses, ttl = 1, self._settings.invite_ttl_hours
expires_at = self._clock() + timedelta(hours=ttl)
async with self._sessions.begin() as s: async with self._sessions.begin() as s:
s.add( s.add(
Invite( Invite(
token_hash=hash_token(token), created_by=admin_id, created_at=self._clock(), expires_at=expires_at token_hash=hash_token(token),
created_by=admin_id,
created_at=self._clock(),
expires_at=expires_at,
max_uses=max_uses,
) )
) )
return CreatedInvite(token=token, expires_at=expires_at) return CreatedInvite(token=token, expires_at=expires_at, max_uses=max_uses)
async def redeem(self, token: str, who: TgUser) -> RedeemStatus: async def redeem(self, token: str, who: TgUser) -> RedeemStatus:
now = self._clock() now = self._clock()
@@ -111,8 +121,9 @@ class AccessService:
return RedeemStatus.THROTTLED return RedeemStatus.THROTTLED
invite = await s.scalar(select(Invite).where(Invite.token_hash == hash_token(token))) 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: if invite is None or invite.uses >= invite.max_uses or _aware(invite.expires_at) <= now:
raise _Rejected raise _Rejected
guest = invite.max_uses > 1
if existing is None: if existing is None:
s.add( s.add(
@@ -122,21 +133,23 @@ class AccessService:
username=who.username, username=who.username,
role=Role.MEMBER, role=Role.MEMBER,
invited_by=invite.created_by, invited_by=invite.created_by,
guest=guest,
joined_at=now, joined_at=now,
last_seen_at=now, last_seen_at=now,
) )
) )
else: # revoked earlier, invited again else: # revoked earlier, invited again
existing.is_active, existing.role, existing.invited_by = True, Role.MEMBER, invite.created_by existing.is_active, existing.role, existing.invited_by = True, Role.MEMBER, invite.created_by
existing.guest = guest
existing.full_name, existing.username, existing.last_seen_at = who.full_name, who.username, now 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 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, # Claim atomically: when two people race for the last use only one UPDATE matches the row,
# the loser's transaction (with its freshly created user) is rolled back. # the loser's transaction (with its freshly created user) is rolled back.
result = await s.execute( result = await s.execute(
update(Invite) update(Invite)
.where(Invite.id == invite.id, Invite.used_by.is_(None)) .where(Invite.id == invite.id, Invite.uses < Invite.max_uses)
.values(used_by=who.tg_id, used_at=now) .values(uses=Invite.uses + 1, used_by=who.tg_id, used_at=now)
) )
if cast(CursorResult[Any], result).rowcount != 1: if cast(CursorResult[Any], result).rowcount != 1:
raise _Rejected raise _Rejected
@@ -164,6 +177,21 @@ class AccessService:
await s.execute(delete(FsmState).where(FsmState.key.like(f"%:{tg_id}:{tg_id}:%"))) await s.execute(delete(FsmState).where(FsmState.key.like(f"%:{tg_id}:{tg_id}:%")))
return True return True
async def revoke_guests(self) -> int:
"""After the talk: cut off everybody who came by the demo QR and expire it — a photo of it lets nobody in."""
now = self._clock()
async with self._sessions.begin() as s:
await s.execute(update(Invite).where(Invite.max_uses > 1, Invite.expires_at > now).values(expires_at=now))
ids = list(
await s.scalars(
select(User.tg_id).where(User.guest.is_(True), User.is_active.is_(True), User.role != Role.ADMIN)
)
)
revoked = 0
for tg_id in ids:
revoked += await self.revoke(tg_id)
return revoked
class _Rejected(Exception): class _Rejected(Exception):
pass pass
+23 -5
View File
@@ -1,4 +1,4 @@
"""«👥 Участники» for admins: list, invite by QR, revoke access (SPEC A-1, A-7).""" """«👥 Участники» for admins: list, invite by QR (personal or the demo one, #55), revoke access (SPEC A-1, A-7)."""
from datetime import datetime from datetime import datetime
from enum import StrEnum from enum import StrEnum
@@ -21,6 +21,9 @@ class MembersAction(StrEnum):
ASK_REVOKE = "ask" ASK_REVOKE = "ask"
REVOKE = "rev" REVOKE = "rev"
LIST = "list" LIST = "list"
DEMO_INVITE = "demo"
ASK_REVOKE_GUESTS = "askg"
REVOKE_GUESTS = "revg"
class MembersCb(CallbackData, prefix="mem"): class MembersCb(CallbackData, prefix="mem"):
@@ -38,7 +41,7 @@ def render_members(texts: Texts, members: list[User]) -> tuple[str, InlineKeyboa
for m in members: for m in members:
lines.append( lines.append(
t.line.format( t.line.format(
icon="👑" if m.role is Role.ADMIN else "👤", icon="👑" if m.role is Role.ADMIN else "🎟" if m.guest else "👤",
name=m.full_name, name=m.full_name,
username=f" @{m.username}" if m.username else "", username=f" @{m.username}" if m.username else "",
joined=_fmt(m.joined_at, t.never), joined=_fmt(m.joined_at, t.never),
@@ -47,6 +50,9 @@ def render_members(texts: Texts, members: list[User]) -> tuple[str, InlineKeyboa
) )
kb = InlineKeyboardBuilder() kb = InlineKeyboardBuilder()
kb.button(text=t.invite, callback_data=MembersCb(action=MembersAction.INVITE)) kb.button(text=t.invite, callback_data=MembersCb(action=MembersAction.INVITE))
kb.button(text=t.demo_invite, callback_data=MembersCb(action=MembersAction.DEMO_INVITE))
if any(m.guest and m.role is not Role.ADMIN for m in members):
kb.button(text=t.revoke_guests, callback_data=MembersCb(action=MembersAction.ASK_REVOKE_GUESTS))
for m in members: for m in members:
if m.role is not Role.ADMIN: if m.role is not Role.ADMIN:
kb.button( kb.button(
@@ -81,11 +87,14 @@ def build_router(texts: Texts, access: AccessService) -> Router:
return return
msg = query.message msg = query.message
match callback_data.action: match callback_data.action:
case MembersAction.INVITE: case MembersAction.INVITE | MembersAction.DEMO_INVITE:
invite = await access.create_invite(member.tg_id) demo = callback_data.action is MembersAction.DEMO_INVITE
invite = await access.create_invite(member.tg_id, demo=demo)
me = await bot.me() me = await bot.me()
link = invite_link(me.username or "", invite.token) link = invite_link(me.username or "", invite.token)
caption = t.invite_caption.format(link=link, expires=invite.expires_at.strftime("%d.%m %H:%M")) caption = (t.demo_invite_caption if demo else t.invite_caption).format(
link=link, expires=invite.expires_at.strftime("%d.%m %H:%M"), max_uses=invite.max_uses
)
await msg.answer_photo(BufferedInputFile(qr_png(link), "invite.png"), caption=caption) await msg.answer_photo(BufferedInputFile(qr_png(link), "invite.png"), caption=caption)
case MembersAction.ASK_REVOKE: case MembersAction.ASK_REVOKE:
target = await access.get_member(callback_data.tg_id) target = await access.get_member(callback_data.tg_id)
@@ -103,6 +112,15 @@ def build_router(texts: Texts, access: AccessService) -> Router:
text, kb_markup = render_members(texts, await access.list_members()) text, kb_markup = render_members(texts, await access.list_members())
note = t.revoked.format(name=name) if ok else t.not_revoked 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) await msg.edit_text(f"{note}\n\n{text}", reply_markup=kb_markup)
case MembersAction.ASK_REVOKE_GUESTS:
kb = InlineKeyboardBuilder()
kb.button(text=t.confirm_yes, callback_data=MembersCb(action=MembersAction.REVOKE_GUESTS))
kb.button(text=t.confirm_no, callback_data=MembersCb(action=MembersAction.LIST))
await msg.edit_text(t.confirm_guests, reply_markup=kb.as_markup())
case MembersAction.REVOKE_GUESTS:
count = await access.revoke_guests()
text, kb_markup = render_members(texts, await access.list_members())
await msg.edit_text(f"{t.guests_revoked.format(count=count)}\n\n{text}", reply_markup=kb_markup)
case MembersAction.LIST: case MembersAction.LIST:
text, kb_markup = render_members(texts, await access.list_members()) text, kb_markup = render_members(texts, await access.list_members())
await msg.edit_text(text, reply_markup=kb_markup) await msg.edit_text(text, reply_markup=kb_markup)
+13 -4
View File
@@ -3,7 +3,7 @@
from pathlib import Path from pathlib import Path
from typing import Annotated, Literal from typing import Annotated, Literal
from pydantic import Field, SecretStr, field_validator from pydantic import AliasChoices, Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
_ENV = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") _ENV = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@@ -23,6 +23,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) invite_ttl_hours: float = Field(default=24.0, gt=0)
# Demo QR for the audience at the talk (#55): many entrants, short life
demo_invite_max_uses: int = Field(default=50, ge=2)
demo_invite_ttl_hours: float = Field(default=3.0, gt=0)
# More failed /start <token> per hour than this — silence (SPEC A-6) # More failed /start <token> per hour than this — silence (SPEC A-6)
max_failed_attempts: int = Field(default=5, ge=1) max_failed_attempts: int = Field(default=5, ge=1)
@@ -35,13 +38,19 @@ class AccessSettings(BaseSettings):
class LLMSettings(BaseSettings): class LLMSettings(BaseSettings):
model_config = SettingsConfigDict(**_ENV, env_prefix="LLM_") model_config = SettingsConfigDict(**_ENV, env_prefix="LLM_", populate_by_name=True)
# mock: deterministic offline answers (tests, demo without network); openai_compat: any OpenAI-style API (ADR-008) # mock: deterministic offline answers (tests, demo without network); openai_compat: any OpenAI-style API (ADR-008),
# e.g. OpenRouter: LLM_BASE_URL=https://openrouter.ai/api/v1
provider: Literal["mock", "openai_compat"] = "mock" provider: Literal["mock", "openai_compat"] = "mock"
base_url: str = "" base_url: str = ""
model: str = "" model: str = ""
api_key: SecretStr = SecretStr("") # OPENROUTER_API_KEY is accepted too — the name meditron and Vobot use for the same key
api_key: SecretStr = Field(
default=SecretStr(""), validation_alias=AliasChoices("LLM_API_KEY", "OPENROUTER_API_KEY")
)
# OpenRouter refuses RF IPs: the server goes through an HTTP/SOCKS proxy (as meditron does)
proxy_url: SecretStr | None = None
timeout: float = Field(default=30.0, gt=0) timeout: float = Field(default=30.0, gt=0)
max_attempts: int = Field(default=3, ge=1) max_attempts: int = Field(default=3, ge=1)
temperature: float = Field(default=0.0, ge=0, le=2) temperature: float = Field(default=0.0, ge=0, le=2)
@@ -0,0 +1,33 @@
"""multi-use demo invite (#55): invites.max_uses/uses, users.guest
Revision ID: 0003
Revises: 0002
Create Date: 2026-10-22 12:00:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0003"
down_revision: str | None = "0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# Plain ADD COLUMN, not batch: on SQLite a batch rebuilds `users`, which invites reference, and the FKs fail
op.add_column("invites", sa.Column("max_uses", sa.Integer(), server_default="1", nullable=False))
op.add_column("invites", sa.Column("uses", sa.Integer(), server_default="0", nullable=False))
op.add_column("users", sa.Column("guest", sa.Boolean(), server_default=sa.false(), nullable=False))
# Invites redeemed before this migration are spent
op.execute("UPDATE invites SET uses = 1 WHERE used_by IS NOT NULL")
def downgrade() -> None:
with op.batch_alter_table("users", schema=None) as batch_op:
batch_op.drop_column("guest")
with op.batch_alter_table("invites", schema=None) as batch_op:
batch_op.drop_column("uses")
batch_op.drop_column("max_uses")
+6
View File
@@ -22,6 +22,7 @@ from sqlalchemy import (
String, String,
Text, Text,
UniqueConstraint, UniqueConstraint,
false,
) )
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -128,6 +129,8 @@ class User(Base):
full_name: Mapped[str] = mapped_column(String(256)) full_name: Mapped[str] = mapped_column(String(256))
role: Mapped[Role] = mapped_column(enum(Role, "role"), default=Role.MEMBER) role: Mapped[Role] = mapped_column(enum(Role, "role"), default=Role.MEMBER)
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# Came in by a multi-use demo invite: cut off all at once after the talk (#55)
guest: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
invited_by: Mapped[int | None] = mapped_column(TgId, ForeignKey("users.tg_id")) invited_by: Mapped[int | None] = mapped_column(TgId, ForeignKey("users.tg_id"))
joined_at: Mapped[datetime] = mapped_column(default=utcnow) joined_at: Mapped[datetime] = mapped_column(default=utcnow)
last_seen_at: Mapped[datetime | None] last_seen_at: Mapped[datetime | None]
@@ -142,6 +145,9 @@ class Invite(Base):
created_by: Mapped[int] = mapped_column(TgId, ForeignKey("users.tg_id")) created_by: Mapped[int] = mapped_column(TgId, ForeignKey("users.tg_id"))
created_at: Mapped[datetime] = mapped_column(default=utcnow) created_at: Mapped[datetime] = mapped_column(default=utcnow)
expires_at: Mapped[datetime] expires_at: Mapped[datetime]
# 1 — a personal invite; more — the demo QR shown to an audience (#55). used_by/used_at — the latest entrant.
max_uses: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
uses: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
used_by: Mapped[int | None] = mapped_column(TgId, ForeignKey("users.tg_id")) used_by: Mapped[int | None] = mapped_column(TgId, ForeignKey("users.tg_id"))
used_at: Mapped[datetime | None] used_at: Mapped[datetime | None]
+2 -1
View File
@@ -1,4 +1,4 @@
"""Any OpenAI-style `/chat/completions` endpoint: OpenAI, YandexGPT compatibility API, a local model server.""" """Any OpenAI-style `/chat/completions` endpoint: OpenRouter, OpenAI, YandexGPT compatibility API, a local server."""
import asyncio import asyncio
import logging import logging
@@ -34,6 +34,7 @@ class OpenAICompatProvider:
headers={"Authorization": f"Bearer {settings.api_key.get_secret_value()}"}, headers={"Authorization": f"Bearer {settings.api_key.get_secret_value()}"},
timeout=settings.timeout, timeout=settings.timeout,
transport=transport, transport=transport,
proxy=settings.proxy_url.get_secret_value() if settings.proxy_url else None,
) )
async def aclose(self) -> None: async def aclose(self) -> None:
+5
View File
@@ -49,6 +49,11 @@ class MembersTexts(_Strict):
revoked: str revoked: str
not_revoked: str not_revoked: str
invite_caption: str invite_caption: str
demo_invite: str
demo_invite_caption: str
revoke_guests: str
confirm_guests: str
guests_revoked: str
class CommitTexts(_Strict): class CommitTexts(_Strict):
+9
View File
@@ -47,6 +47,15 @@ members:
{link} {link}
Одноразовое, действует до {expires} UTC. Одноразовое, действует до {expires} UTC.
demo_invite: "🎤 Демо-QR"
demo_invite_caption: |-
🎤 Демо-доступ к tgit — отсканируйте QR или откройте ссылку:
{link}
До {max_uses} человек, действует до {expires} UTC.
revoke_guests: "🧹 Отключить гостей демо"
confirm_guests: "Отключить всех, кто вошёл по демо-QR, и погасить сам QR? Их черновики удалятся."
guests_revoked: "Гости демо отключены: {count}. Демо-QR больше не работает."
commit: commit:
title: "📝 Коммит · /{path}" title: "📝 Коммит · /{path}"
+14
View File
@@ -155,3 +155,17 @@
прежнее (деплой сразу). Деплой ждёт CI: на общих раннерах это от ~30 с до минут при очереди, таймаут — 30 мин. прежнее (деплой сразу). Деплой ждёт CI: на общих раннерах это от ~30 с до минут при очереди, таймаут — 30 мин.
Возврат к SourceCraft — вторая реализация `CIProvider` поверх готового `SourceCraftClient` (ADR-014), код наблюдателя Возврат к SourceCraft — вторая реализация `CIProvider` поверх готового `SourceCraftClient` (ADR-014), код наблюдателя
не меняется. не меняется.
## ADR-017: Демо-QR — многоразовое приглашение с лимитом для показа
**Дата:** 2026-10-22 · **Статус:** принято (дополняет ADR-011)
**Контекст:** на защите зал должен попробовать бота по одному QR с экрана; одноразовое приглашение (ADR-011) пускает
только первого (#55).
**Решение:** у приглашения — лимит входов `max_uses` (обычное — 1) и счётчик `uses`; захват атомарный
(`UPDATE … WHERE uses < max_uses`). Админ выпускает демо-QR отдельной кнопкой: `DEMO_INVITE_MAX_USES` входов (50),
`DEMO_INVITE_TTL_HOURS` (3 ч). Вошедшие помечаются `users.guest`; «Отключить гостей демо» отзывает их всех и гасит
действующие демо-QR, чтобы фото QR больше никого не пускало.
**Альтернативы:** выпустить 50 одноразовых QR — неудобно показывать; роль «гость» без коммита — больше работы, а
код гостей и так исполняется только в песочнице `tgit-runner` (ADR-005/006).
**Последствия:** риск ADR-011 «одна утечка открывает доступ всем» ограничен лимитом, коротким сроком и массовым
отзывом. Гости получают алерты и дневной лимит агента наравне с участниками.
+3
View File
@@ -40,6 +40,9 @@
| 15 | 11:30 | любой | «🚨 Алерты» → «📋 Список» | активные сверху, история, карточка алерта | сц. 10, AL-6 | | 15 | 11:30 | любой | «🚨 Алерты» → «📋 Список» | активные сверху, история, карточка алерта | сц. 10, AL-6 |
| 16 | 12:00 | А | «👥 Участники» → Б → «Отозвать» | Б нажимает любую кнопку — тишина | A-7 | | 16 | 12:00 | А | «👥 Участники» → Б → «Отозвать» | Б нажимает любую кнопку — тишина | A-7 |
**Зал пробует сам (по желанию, в конце):** А → «👥 Участники» → «🎤 Демо-QR» → QR на экран; до 50 человек входят
гостями 🎟 и пробуют разделы. После показа — «🧹 Отключить гостей демо»: все гости отключены, QR погашен.
Что сказать по ходу (по одной фразе на шаг): Что сказать по ходу (по одной фразе на шаг):
- **2–4:** токен 128 бит, одноразовый, 24 ч, в БД только хэш; проверка доступа — middleware на каждом апдейте. - **2–4:** токен 128 бит, одноразовый, 24 ч, в БД только хэш; проверка доступа — middleware на каждом апдейте.
- **7:** CI — Gitea Actions репозитория загрузок; бот находит прогон по sha и разворачивает только после зелёного CI. - **7:** CI — Gitea Actions репозитория загрузок; бот находит прогон по sha и разворачивает только после зелёного CI.
+1
View File
@@ -57,6 +57,7 @@
| A-3 | Приглашение **одноразовое**, живёт 24 ч (настраивается); в БД хранится только хэш токена | | A-3 | Приглашение **одноразовое**, живёт 24 ч (настраивается); в БД хранится только хэш токена |
| A-4 | Переход по QR → `/start <token>` → проверка → пользователь становится участником, видит главное меню. Повторное использование, истёкший или неизвестный токен → отказ | | A-4 | Переход по QR → `/start <token>` → проверка → пользователь становится участником, видит главное меню. Повторное использование, истёкший или неизвестный токен → отказ |
| A-5 | Без приглашения ни одна функция недоступна: проверка — в middleware на **каждом** апдейте (сообщения и нажатия кнопок) | | A-5 | Без приглашения ни одна функция недоступна: проверка — в middleware на **каждом** апдейте (сообщения и нажатия кнопок) |
| A-3a | Исключение для показа (#55): **демо-QR** — одно приглашение на ≤ 50 входов, живёт 3 ч (настраивается). Вошедшие по нему — гости 🎟 (права участника); «Отключить гостей демо» отзывает всех разом и гасит демо-QR |
| A-6 | Защита от перебора: не больше 5 неудачных `/start <token>` в час с одного Telegram ID, дальше — игнор | | A-6 | Защита от перебора: не больше 5 неудачных `/start <token>` в час с одного Telegram ID, дальше — игнор |
| A-7 | Администратор видит участников (имя, кто пригласил, когда, последняя активность) и может **отозвать** доступ — с этого момента апдейты пользователя игнорируются, его черновик удаляется | | A-7 | Администратор видит участников (имя, кто пригласил, когда, последняя активность) и может **отозвать** доступ — с этого момента апдейты пользователя игнорируются, его черновик удаляется |
| A-8 | Первые администраторы задаются в `.env` (`ADMIN_IDS`); остальные — только через приглашение | | A-8 | Первые администраторы задаются в `.env` (`ADMIN_IDS`); остальные — только через приглашение |
+1
View File
@@ -13,6 +13,7 @@ CD — `.gitea/workflows/deploy.yml` (Gitea Actions, ADR-015): push в `dev` →
| Telegram | с сервера напрямую недоступен → `TELEGRAM_PROXY_URL` (тот же прокси, что у meditron) | | Telegram | с сервера напрямую недоступен → `TELEGRAM_PROXY_URL` (тот же прокси, что у meditron) |
| Репозиторий загрузок | временно `ssh://git@gitea.24studbank.ru:2222/HSE/tgit-uploads.git`, deploy key бота с записью | | Репозиторий загрузок | временно `ssh://git@gitea.24studbank.ru:2222/HSE/tgit-uploads.git`, deploy key бота с записью |
| CI загрузок | Gitea Actions `HSE/tgit-uploads` (`check.yml`); боту в `.env` — `CI_PROVIDER=gitea`, `CI_TOKEN` (чтение репозитория загрузок). Без них деплой сервисов идёт сразу после push, без CI | | CI загрузок | Gitea Actions `HSE/tgit-uploads` (`check.yml`); боту в `.env` — `CI_PROVIDER=gitea`, `CI_TOKEN` (чтение репозитория загрузок). Без них деплой сервисов идёт сразу после push, без CI |
| Агент логов | `LLM_PROVIDER=openai_compat`, `LLM_BASE_URL=https://openrouter.ai/api/v1`, `LLM_MODEL`, `LLM_API_KEY`, `LLM_PROXY_URL` (тот же прокси, что `TELEGRAM_PROXY_URL`) — OpenRouter блокирует IP из РФ |
Секреты репозитория в Gitea (Settings → Actions → Secrets): `DEPLOY_SSH_KEY` (приватный ключ `tgit-cd@gitea`), `DEPLOY_HOST` (`tgit-deploy@72.56.247.166`). Секреты репозитория в Gitea (Settings → Actions → Secrets): `DEPLOY_SSH_KEY` (приватный ключ `tgit-cd@gitea`), `DEPLOY_HOST` (`tgit-deploy@72.56.247.166`).
+69
View File
@@ -136,3 +136,72 @@ async def test_touch_is_throttled_but_tracks_renames(svc: AccessService, clock:
await svc.touch(same, TgUser(ADMIN, "Новое имя", "boss")) await svc.touch(same, TgUser(ADMIN, "Новое имя", "boss"))
renamed = await svc.get_member(ADMIN) renamed = await svc.get_member(ADMIN)
assert renamed is not None and renamed.full_name == "Новое имя" assert renamed is not None and renamed.full_name == "Новое имя"
@pytest.fixture
async def demo_svc(sessions: SessionMaker, clock: Clock) -> AccessService:
settings = AccessSettings(admin_ids=frozenset({ADMIN}), demo_invite_max_uses=3, demo_invite_ttl_hours=2)
service = AccessService(sessions, settings, clock=clock)
await service.bootstrap_admins()
return service
def guest(n: int) -> TgUser:
return TgUser(tg_id=700 + n, full_name=f"Гость {n}")
async def test_demo_invite_lets_in_up_to_the_limit(
demo_svc: AccessService, sessions: SessionMaker, clock: Clock
) -> None:
invite = await demo_svc.create_invite(ADMIN, demo=True)
assert invite.max_uses == 3 and invite.expires_at == clock.now + timedelta(hours=2)
assert [await demo_svc.redeem(invite.token, guest(n)) for n in range(4)] == [RedeemStatus.OK] * 3 + [
RedeemStatus.REJECTED
]
for n in range(3):
member = await demo_svc.get_member(guest(n).tg_id)
assert member is not None and member.guest and member.role is Role.MEMBER
async with sessions() as s:
stored = await s.scalar(select(Invite))
assert stored is not None and stored.uses == 3
assert await s.get(User, guest(3).tg_id) is None
async def test_demo_invite_expires(demo_svc: AccessService, clock: Clock) -> None:
invite = await demo_svc.create_invite(ADMIN, demo=True)
clock.now += timedelta(hours=2, seconds=1)
assert await demo_svc.redeem(invite.token, guest(0)) is RedeemStatus.REJECTED
async def test_personal_invite_is_not_a_guest_one(demo_svc: AccessService) -> None:
invite = await demo_svc.create_invite(ADMIN)
assert invite.max_uses == 1
await demo_svc.redeem(invite.token, NEWBIE)
member = await demo_svc.get_member(NEWBIE.tg_id)
assert member is not None and not member.guest
async def test_revoke_guests_cuts_off_only_guests_and_kills_the_qr(demo_svc: AccessService) -> None:
await demo_svc.redeem((await demo_svc.create_invite(ADMIN)).token, NEWBIE)
demo = await demo_svc.create_invite(ADMIN, demo=True)
for n in range(2):
await demo_svc.redeem(demo.token, guest(n))
assert await demo_svc.revoke_guests() == 2
assert [m.tg_id for m in await demo_svc.list_members()] == [ADMIN, NEWBIE.tg_id]
assert await demo_svc.redeem(demo.token, guest(2)) is RedeemStatus.REJECTED
assert await demo_svc.revoke_guests() == 0
async def test_guest_invited_personally_later_is_no_longer_a_guest(demo_svc: AccessService) -> None:
await demo_svc.redeem((await demo_svc.create_invite(ADMIN, demo=True)).token, guest(0))
await demo_svc.revoke_guests()
await demo_svc.redeem((await demo_svc.create_invite(ADMIN)).token, guest(0))
member = await demo_svc.get_member(guest(0).tg_id)
assert member is not None and not member.guest
+22 -2
View File
@@ -21,8 +21,8 @@ def tg(texts: Texts, sessions: SessionMaker, access: AccessService, repo: Upload
return TgClient(build_dispatcher(texts, sessions, access, repo)) return TgClient(build_dispatcher(texts, sessions, access, repo))
async def invite_token(tg: TgClient) -> str: async def invite_token(tg: TgClient, action: MembersAction = MembersAction.INVITE) -> str:
await tg.press(ADMIN, MembersCb(action=MembersAction.INVITE).pack()) await tg.press(ADMIN, MembersCb(action=action).pack())
[photo] = tg.session.sent(SendPhoto) [photo] = tg.session.sent(SendPhoto)
match = re.search(r"https://t\.me/tgit_test_bot\?start=([A-Za-z0-9_-]+)", photo.caption or "") match = re.search(r"https://t\.me/tgit_test_bot\?start=([A-Za-z0-9_-]+)", photo.caption or "")
assert match, photo.caption assert match, photo.caption
@@ -93,3 +93,23 @@ async def test_cancel_revoke_returns_to_list(tg: TgClient, texts: Texts) -> None
[edit] = tg.session.sent(EditMessageText) [edit] = tg.session.sent(EditMessageText)
assert edit.text.startswith(texts.members.title.format(count=1)) assert edit.text.startswith(texts.members.title.format(count=1))
async def test_demo_qr_lets_many_in_and_guests_are_revoked_together(tg: TgClient, texts: Texts) -> None:
token = await invite_token(tg, MembersAction.DEMO_INVITE)
for n in range(3):
await tg.send(NEWBIE + n, f"/start {token}")
assert [r.text for r in tg.session.sent(SendMessage)] == [texts.start.welcome] * 3
tg.session.requests.clear()
await tg.press(ADMIN, MembersCb(action=MembersAction.ASK_REVOKE_GUESTS).pack())
await tg.press(ADMIN, MembersCb(action=MembersAction.REVOKE_GUESTS).pack())
confirm, done = tg.session.sent(EditMessageText)
assert confirm.text == texts.members.confirm_guests
assert done.text.startswith(texts.members.guests_revoked.format(count=3))
tg.session.requests.clear()
await tg.send(NEWBIE, texts.menu.commit)
await tg.send(NEWBIE + 10, f"/start {token}")
assert [r.text for r in tg.session.sent(SendMessage)] == [texts.start.no_access, texts.start.invite_rejected]
+21
View File
@@ -80,3 +80,24 @@ async def test_deleting_draft_cascades_to_changes(sessions: SessionMaker) -> Non
await s.execute(text("DELETE FROM drafts")) await s.execute(text("DELETE FROM drafts"))
async with sessions() as s: async with sessions() as s:
assert (await s.execute(text("SELECT count(*) FROM draft_changes"))).scalar_one() == 0 assert (await s.execute(text("SELECT count(*) FROM draft_changes"))).scalar_one() == 0
async def test_0003_marks_redeemed_invites_spent() -> None:
eng = make_engine("sqlite+aiosqlite://")
try:
await upgrade(eng, "0002")
async with eng.begin() as conn:
await conn.execute(INSERT_USER, {"id": 1, "role": "admin"})
await conn.execute(
text(
"INSERT INTO invites (token_hash, created_by, created_at, expires_at, used_by) VALUES "
"('a', 1, '2026-01-01', '2026-01-02', 1), ('b', 1, '2026-01-01', '2026-01-02', NULL)"
)
)
await upgrade(eng)
async with eng.connect() as conn:
rows = (await conn.execute(text("SELECT token_hash, max_uses, uses FROM invites ORDER BY 1"))).all()
assert [tuple(r) for r in rows] == [("a", 1, 1), ("b", 1, 0)]
assert (await conn.execute(text("SELECT guest FROM users"))).scalar_one() in (0, False)
finally:
await eng.dispose()
+36
View File
@@ -137,3 +137,39 @@ def test_factory_picks_provider() -> None:
) )
def test_parse_json_object(text: str, expected: dict[str, Any] | None) -> None: def test_parse_json_object(text: str, expected: dict[str, Any] | None) -> None:
assert parse_json_object(text) == expected assert parse_json_object(text) == expected
def test_openrouter_key_name_is_accepted(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("LLM_API_KEY", raising=False)
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
assert LLMSettings(_env_file=None).api_key.get_secret_value() == "or-key" # type: ignore[call-arg]
monkeypatch.setenv("LLM_API_KEY", "llm-key") # the project's own name wins
assert LLMSettings(_env_file=None).api_key.get_secret_value() == "llm-key" # type: ignore[call-arg]
async def test_requests_go_through_the_proxy() -> None:
seen: list[str] = []
class Proxy(httpx.AsyncBaseTransport):
"""Stands in for the HTTP proxy: httpx routes requests to the transport mounted for the proxy."""
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
seen.append(str(request.url))
return httpx.Response(200, json=completion("через прокси"))
provider = OpenAICompatProvider(settings(proxy_url="http://user:pass@proxy.example.test:3128"))
mounts = provider._http._mounts # httpx keeps the proxy transport here, no public accessor
assert mounts, "no proxy transport mounted"
provider._http._mounts = {pattern: Proxy() for pattern in mounts}
result = await provider.complete([ChatMessage("user", "x")])
await provider.aclose()
assert result.text == "через прокси" and seen == [f"{BASE}/chat/completions"]
def test_proxy_is_a_secret() -> None:
s = settings(proxy_url="http://user:pass@proxy.example.test:3128")
assert "pass" not in repr(s)