Единый CHAT_ID и доступ только участникам чата
CI / lint-test (push) Successful in 8s Details
CI / build-push (push) Successful in 11s Details

This commit is contained in:
MpakobecEx 2026-10-16 02:05:37 +05:00
parent a96793e755
commit 9f33831971
8 changed files with 48 additions and 45 deletions

View File

@ -18,11 +18,10 @@ DB_POOL_MAX=10
DB_STATEMENT_TIMEOUT_MS=15000 DB_STATEMENT_TIMEOUT_MS=15000
# Чаты # Чаты
# Основной чат клана (prod) и тестовый чат # Основной чат клана (доступ в бота — только его участникам)
PROD_CHAT_ID= CHAT_ID=
TEST_CHAT_ID= # Чат для служебных уведомлений/логов (опционально)
# Личный чат для алертов об ошибках ALERT_CHAT_ID=0
ALERT_CHAT_ID=
# Telegram ID администраторов (через запятую) # Telegram ID администраторов (через запятую)
ADMIN_IDS= ADMIN_IDS=

View File

@ -15,7 +15,7 @@ cd DispelEvilBot
```bash ```bash
cp env/test.env.example env/test.env cp env/test.env.example env/test.env
# заполнить: BOT_TOKEN, DB_PASSWORD, тестовые чаты (TEST_CHAT_ID/PROD_CHAT_ID), ADMIN_IDS # заполнить: BOT_TOKEN, DB_PASSWORD, CHAT_ID (основной чат), ALERT_CHAT_ID (опц.), ADMIN_IDS (опц.)
``` ```
## 3. Логин в registry (если пакет приватный) ## 3. Логин в registry (если пакет приватный)

View File

@ -22,7 +22,7 @@ router = Router(name="member_events")
async def bot_added_handler(event: ChatMemberUpdated) -> None: async def bot_added_handler(event: ChatMemberUpdated) -> None:
"""Бот добавлен в чат.""" """Бот добавлен в чат."""
settings = get_settings() settings = get_settings()
if event.chat.id not in {settings.prod_chat_id, settings.test_chat_id}: if event.chat.id not in {settings.chat_id, settings.alert_chat_id}:
await event.bot.send_message(chat_id=event.chat.id, text=system.MSG_BOT_FOREIGN_CHAT) await event.bot.send_message(chat_id=event.chat.id, text=system.MSG_BOT_FOREIGN_CHAT)
await event.bot.leave_chat(event.chat.id) await event.bot.leave_chat(event.chat.id)
else: else:
@ -35,6 +35,8 @@ async def welcome_handler(event: ChatMemberUpdated) -> None:
user = event.new_chat_member.user user = event.new_chat_member.user
if user.is_bot: if user.is_bot:
return return
if event.chat.id != get_settings().chat_id:
return
await users_service.register_from_event(event) await users_service.register_from_event(event)
@ -61,21 +63,16 @@ async def goodbye_handler(event: ChatMemberUpdated) -> None:
user = event.from_user user = event.from_user
if user is None or user.is_bot: if user is None or user.is_bot:
return return
if event.chat.id == settings.test_chat_id:
return
await users_service.delete_user(user.id) await users_service.delete_user(user.id)
logger.info("Удалён пользователь %s после выхода из чата", user.id) logger.info("Удалён пользователь %s после выхода из чата", user.id)
if not settings.alert_chat_id:
return
try: try:
await event.bot.send_message( await event.bot.send_message(
chat_id=settings.test_chat_id, chat_id=settings.alert_chat_id,
text=f"[goodbye_handler]\nУдалил {user.username or user.id}!", text=f"[goodbye] {user.username or user.id} покинул чат {event.chat.title}",
) )
if settings.alert_chat_id:
await event.bot.send_message(
chat_id=settings.alert_chat_id,
text=f"Пользователь {user.username or user.id} покинул чат {event.chat.title}",
)
except Exception: except Exception:
logger.exception("Не удалось отправить уведомление о выходе пользователя") logger.exception("Не удалось отправить уведомление о выходе пользователя")

View File

@ -39,15 +39,9 @@ async def _is_member(bot: Bot, user_id: int) -> bool:
async def check_user(message: Message) -> bool: async def check_user(message: Message) -> bool:
"""Пропустить пользователя: проверить членство и обновить его запись. """Пропустить пользователя: доступ только участникам основного чата (или админам)."""
В тестовом чате доступ разрешён всегда.
"""
settings = get_settings() settings = get_settings()
if message.chat.id == settings.test_chat_id:
return True
user = message.from_user user = message.from_user
if user is None: if user is None:
return False return False
@ -63,18 +57,21 @@ async def check_user(message: Message) -> bool:
async def _notify_denied(message: Message) -> None: async def _notify_denied(message: Message) -> None:
"""Сообщить в чат логов (если задан) о попытке доступа извне."""
settings = get_settings() settings = get_settings()
if not settings.alert_chat_id:
return
username = message.from_user.username if message.from_user else None username = message.from_user.username if message.from_user else None
try: try:
await message.bot.send_message( await message.bot.send_message(
chat_id=settings.test_chat_id, chat_id=settings.alert_chat_id,
text=f"[check_user]\nСтучится в чат {username}!", text=f"[check_user]\nСтучится в чат {username}!",
) )
if settings.alert_chat_id: await message.bot.forward_message(
await message.bot.forward_message( chat_id=settings.alert_chat_id,
chat_id=settings.alert_chat_id, from_chat_id=message.chat.id,
from_chat_id=message.chat.id, message_id=message.message_id,
message_id=message.message_id, )
)
except Exception: # уведомление не должно ломать обработку except Exception: # уведомление не должно ломать обработку
logger.exception("Не удалось отправить уведомление о запрете доступа") logger.exception("Не удалось отправить уведомление о запрете доступа")

View File

@ -64,8 +64,9 @@ async def _send_broadcast(bot: Bot, template: str, subscribers: list[dict]) -> N
async def _tick(bot: Bot, now: datetime) -> None: async def _tick(bot: Bot, now: datetime) -> None:
settings = get_settings() settings = get_settings()
if now.minute == 0: if now.minute == 0 and settings.alert_chat_id:
await bot.send_message(chat_id=settings.test_chat_id, text="Я ЖЫФ!") # Пинг «жив» в чат логов (если задан).
await bot.send_message(chat_id=settings.alert_chat_id, text="Я ЖЫФ!")
for schedule in SCHEDULES: for schedule in SCHEDULES:
if schedule.matches(now): if schedule.matches(now):

View File

@ -37,10 +37,12 @@ class Settings(BaseSettings):
db_pool_max: int = 10 db_pool_max: int = 10
db_statement_timeout_ms: int = 15000 db_statement_timeout_ms: int = 15000
prod_chat_id: int = 0 #: Основной чат клана (единственный; контуры разделяются отдельными .env).
test_chat_id: int = 0 chat_id: int = 0
#: Доп. чат для служебных уведомлений/логов (опционально).
alert_chat_id: int = 0 alert_chat_id: int = 0
#: Telegram-ID, которым доступ разрешён в обход проверки членства.
admin_ids: list[int] = Field(default_factory=list) admin_ids: list[int] = Field(default_factory=list)
timezone: str = "Europe/Moscow" timezone: str = "Europe/Moscow"
@ -48,6 +50,14 @@ class Settings(BaseSettings):
#: Файл-heartbeat для Docker healthcheck (обновляется ботом периодически). #: Файл-heartbeat для Docker healthcheck (обновляется ботом периодически).
heartbeat_path: str = "/tmp/dispelevilbot.heartbeat" heartbeat_path: str = "/tmp/dispelevilbot.heartbeat"
@field_validator("chat_id", "alert_chat_id", mode="before")
@classmethod
def _empty_int_to_zero(cls, value: object) -> object:
"""Пустое значение в .env трактуем как 0 (а не ошибку валидации)."""
if value is None or value == "":
return 0
return value
@field_validator("admin_ids", mode="before") @field_validator("admin_ids", mode="before")
@classmethod @classmethod
def _parse_admin_ids(cls, value: object) -> object: def _parse_admin_ids(cls, value: object) -> object:
@ -65,11 +75,6 @@ class Settings(BaseSettings):
def is_prod(self) -> bool: def is_prod(self) -> bool:
return self.app_env.strip().lower() in {"prod", "production"} return self.app_env.strip().lower() in {"prod", "production"}
@property
def chat_id(self) -> int:
"""Основной чат клана для текущего окружения."""
return self.prod_chat_id if self.is_prod else self.test_chat_id
@property @property
def tzinfo(self) -> ZoneInfo: def tzinfo(self) -> ZoneInfo:
return ZoneInfo(self.timezone) return ZoneInfo(self.timezone)

View File

@ -23,8 +23,10 @@ DB_POOL_MIN=1
DB_POOL_MAX=10 DB_POOL_MAX=10
DB_STATEMENT_TIMEOUT_MS=15000 DB_STATEMENT_TIMEOUT_MS=15000
PROD_CHAT_ID= # Основной чат этого контура (доступ в бота — только его участникам)
TEST_CHAT_ID= CHAT_ID=
ALERT_CHAT_ID= # Чат для служебных уведомлений/логов (опционально; пусто или 0 — выключено)
ALERT_CHAT_ID=0
# Telegram-ID админов (доступ в обход проверки членства), через запятую; можно пусто
ADMIN_IDS= ADMIN_IDS=
TIMEZONE=Europe/Moscow TIMEZONE=Europe/Moscow

View File

@ -21,8 +21,10 @@ DB_POOL_MIN=1
DB_POOL_MAX=10 DB_POOL_MAX=10
DB_STATEMENT_TIMEOUT_MS=15000 DB_STATEMENT_TIMEOUT_MS=15000
PROD_CHAT_ID= # Основной чат этого контура (доступ в бота — только его участникам)
TEST_CHAT_ID= CHAT_ID=
ALERT_CHAT_ID= # Чат для служебных уведомлений/логов (опционально; пусто или 0 — выключено)
ALERT_CHAT_ID=0
# Telegram-ID админов (доступ в обход проверки членства), через запятую; можно пусто
ADMIN_IDS= ADMIN_IDS=
TIMEZONE=Europe/Moscow TIMEZONE=Europe/Moscow