mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 11:50:27 +00:00
347 lines
11 KiB
Python
347 lines
11 KiB
Python
import asyncio
|
||
import logging
|
||
from datetime import datetime
|
||
from typing import Optional
|
||
|
||
from aiogram import Bot
|
||
from aiogram.exceptions import (
|
||
TelegramBadRequest,
|
||
TelegramForbiddenError,
|
||
TelegramRetryAfter,
|
||
)
|
||
from sqlalchemy import select, update
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.database.crud.user import get_users_list
|
||
from app.database.database import AsyncSessionLocal
|
||
from app.database.models import PinnedMessage, User, UserStatus
|
||
from app.utils.validators import sanitize_html, validate_html_tags
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
async def get_active_pinned_message(db: AsyncSession) -> Optional[PinnedMessage]:
|
||
result = await db.execute(
|
||
select(PinnedMessage)
|
||
.where(PinnedMessage.is_active.is_(True))
|
||
.order_by(PinnedMessage.created_at.desc())
|
||
.limit(1)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
|
||
async def set_active_pinned_message(
|
||
db: AsyncSession,
|
||
content: str,
|
||
created_by: Optional[int] = None,
|
||
media_type: Optional[str] = None,
|
||
media_file_id: Optional[str] = None,
|
||
send_before_menu: Optional[bool] = None,
|
||
send_on_every_start: Optional[bool] = None,
|
||
) -> PinnedMessage:
|
||
sanitized_content = sanitize_html(content or "")
|
||
is_valid, error_message = validate_html_tags(sanitized_content)
|
||
if not is_valid:
|
||
raise ValueError(error_message)
|
||
|
||
if media_type not in {None, "photo", "video"}:
|
||
raise ValueError("Поддерживаются только фото или видео в закрепленном сообщении")
|
||
|
||
if created_by is not None:
|
||
creator_id = await db.scalar(select(User.id).where(User.id == created_by))
|
||
else:
|
||
creator_id = None
|
||
|
||
previous_active = await get_active_pinned_message(db)
|
||
|
||
await db.execute(
|
||
update(PinnedMessage)
|
||
.where(PinnedMessage.is_active.is_(True))
|
||
.values(is_active=False)
|
||
)
|
||
|
||
pinned_message = PinnedMessage(
|
||
content=sanitized_content,
|
||
media_type=media_type,
|
||
media_file_id=media_file_id,
|
||
is_active=True,
|
||
created_by=creator_id,
|
||
send_before_menu=(
|
||
send_before_menu
|
||
if send_before_menu is not None
|
||
else getattr(previous_active, "send_before_menu", True)
|
||
),
|
||
send_on_every_start=(
|
||
send_on_every_start
|
||
if send_on_every_start is not None
|
||
else getattr(previous_active, "send_on_every_start", True)
|
||
),
|
||
)
|
||
|
||
db.add(pinned_message)
|
||
await db.commit()
|
||
await db.refresh(pinned_message)
|
||
|
||
logger.info("Создано новое закрепленное сообщение #%s", pinned_message.id)
|
||
return pinned_message
|
||
|
||
|
||
async def deactivate_active_pinned_message(db: AsyncSession) -> Optional[PinnedMessage]:
|
||
pinned_message = await get_active_pinned_message(db)
|
||
if not pinned_message:
|
||
return None
|
||
|
||
pinned_message.is_active = False
|
||
pinned_message.updated_at = datetime.utcnow()
|
||
await db.commit()
|
||
await db.refresh(pinned_message)
|
||
logger.info("Деактивировано закрепленное сообщение #%s", pinned_message.id)
|
||
return pinned_message
|
||
|
||
|
||
async def deliver_pinned_message_to_user(
|
||
bot: Bot,
|
||
db: AsyncSession,
|
||
user: User,
|
||
pinned_message: Optional[PinnedMessage] = None,
|
||
) -> bool:
|
||
pinned_message = pinned_message or await get_active_pinned_message(db)
|
||
if not pinned_message:
|
||
return False
|
||
|
||
if not pinned_message.send_on_every_start:
|
||
last_pinned_id = getattr(user, "last_pinned_message_id", None)
|
||
if last_pinned_id == pinned_message.id:
|
||
return False
|
||
|
||
success = await _send_and_pin_message(bot, user.telegram_id, pinned_message)
|
||
if success:
|
||
await _mark_pinned_delivery(user_id=getattr(user, "id", None), pinned_message_id=pinned_message.id)
|
||
return success
|
||
|
||
|
||
async def broadcast_pinned_message(
|
||
bot: Bot,
|
||
db: AsyncSession,
|
||
pinned_message: PinnedMessage,
|
||
) -> tuple[int, int]:
|
||
users: list[User] = []
|
||
offset = 0
|
||
batch_size = 5000
|
||
|
||
while True:
|
||
batch = await get_users_list(
|
||
db,
|
||
offset=offset,
|
||
limit=batch_size,
|
||
status=UserStatus.ACTIVE,
|
||
)
|
||
|
||
if not batch:
|
||
break
|
||
|
||
users.extend(batch)
|
||
offset += batch_size
|
||
|
||
sent_count = 0
|
||
failed_count = 0
|
||
semaphore = asyncio.Semaphore(3)
|
||
|
||
async def send_to_user(user: User) -> None:
|
||
nonlocal sent_count, failed_count
|
||
async with semaphore:
|
||
for attempt in range(3):
|
||
try:
|
||
success = await _send_and_pin_message(
|
||
bot,
|
||
user.telegram_id,
|
||
pinned_message,
|
||
)
|
||
if success:
|
||
sent_count += 1
|
||
else:
|
||
failed_count += 1
|
||
break
|
||
except TelegramRetryAfter as retry_error:
|
||
delay = min(retry_error.retry_after + 1, 30)
|
||
logger.warning(
|
||
"RetryAfter for user %s, waiting %s seconds",
|
||
user.telegram_id,
|
||
delay,
|
||
)
|
||
await asyncio.sleep(delay)
|
||
except Exception as send_error: # noqa: BLE001
|
||
logger.error(
|
||
"Ошибка отправки закрепленного сообщения пользователю %s: %s",
|
||
user.telegram_id,
|
||
send_error,
|
||
)
|
||
failed_count += 1
|
||
break
|
||
|
||
for i in range(0, len(users), 30):
|
||
batch = users[i : i + 30]
|
||
tasks = [send_to_user(user) for user in batch]
|
||
await asyncio.gather(*tasks)
|
||
await asyncio.sleep(0.05)
|
||
|
||
return sent_count, failed_count
|
||
|
||
|
||
async def unpin_active_pinned_message(
|
||
bot: Bot,
|
||
db: AsyncSession,
|
||
) -> tuple[int, int, bool]:
|
||
pinned_message = await deactivate_active_pinned_message(db)
|
||
if not pinned_message:
|
||
return 0, 0, False
|
||
|
||
users: list[User] = []
|
||
offset = 0
|
||
batch_size = 5000
|
||
|
||
while True:
|
||
batch = await get_users_list(
|
||
db,
|
||
offset=offset,
|
||
limit=batch_size,
|
||
status=UserStatus.ACTIVE,
|
||
)
|
||
|
||
if not batch:
|
||
break
|
||
|
||
users.extend(batch)
|
||
offset += batch_size
|
||
|
||
unpinned_count = 0
|
||
failed_count = 0
|
||
semaphore = asyncio.Semaphore(5)
|
||
|
||
async def unpin_for_user(user: User) -> None:
|
||
nonlocal unpinned_count, failed_count
|
||
async with semaphore:
|
||
try:
|
||
success = await _unpin_message_for_user(bot, user.telegram_id)
|
||
if success:
|
||
unpinned_count += 1
|
||
else:
|
||
failed_count += 1
|
||
except TelegramRetryAfter as retry_error:
|
||
delay = min(retry_error.retry_after + 1, 30)
|
||
logger.warning(
|
||
"RetryAfter while unpinning for user %s, waiting %s seconds",
|
||
user.telegram_id,
|
||
delay,
|
||
)
|
||
await asyncio.sleep(delay)
|
||
await unpin_for_user(user)
|
||
except Exception as error: # noqa: BLE001
|
||
logger.error(
|
||
"Ошибка открепления сообщения у пользователя %s: %s",
|
||
user.telegram_id,
|
||
error,
|
||
)
|
||
failed_count += 1
|
||
|
||
for i in range(0, len(users), 40):
|
||
batch = users[i : i + 40]
|
||
tasks = [unpin_for_user(user) for user in batch]
|
||
await asyncio.gather(*tasks)
|
||
await asyncio.sleep(0.05)
|
||
|
||
return unpinned_count, failed_count, True
|
||
|
||
|
||
async def _mark_pinned_delivery(
|
||
user_id: Optional[int],
|
||
pinned_message_id: int,
|
||
) -> None:
|
||
if not user_id:
|
||
return
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
await session.execute(
|
||
update(User)
|
||
.where(User.id == user_id)
|
||
.values(
|
||
last_pinned_message_id=pinned_message_id,
|
||
updated_at=datetime.utcnow(),
|
||
)
|
||
)
|
||
await session.commit()
|
||
|
||
|
||
async def _send_and_pin_message(bot: Bot, chat_id: int, pinned_message: PinnedMessage) -> bool:
|
||
try:
|
||
await bot.unpin_all_chat_messages(chat_id=chat_id)
|
||
except TelegramBadRequest:
|
||
pass
|
||
except TelegramForbiddenError:
|
||
return False
|
||
|
||
try:
|
||
if pinned_message.media_type == "photo" and pinned_message.media_file_id:
|
||
sent_message = await bot.send_photo(
|
||
chat_id=chat_id,
|
||
photo=pinned_message.media_file_id,
|
||
caption=pinned_message.content or None,
|
||
parse_mode="HTML" if pinned_message.content else None,
|
||
disable_notification=True,
|
||
)
|
||
elif pinned_message.media_type == "video" and pinned_message.media_file_id:
|
||
sent_message = await bot.send_video(
|
||
chat_id=chat_id,
|
||
video=pinned_message.media_file_id,
|
||
caption=pinned_message.content or None,
|
||
parse_mode="HTML" if pinned_message.content else None,
|
||
disable_notification=True,
|
||
)
|
||
else:
|
||
sent_message = await bot.send_message(
|
||
chat_id=chat_id,
|
||
text=pinned_message.content,
|
||
parse_mode="HTML",
|
||
disable_web_page_preview=True,
|
||
disable_notification=True,
|
||
)
|
||
await bot.pin_chat_message(
|
||
chat_id=chat_id,
|
||
message_id=sent_message.message_id,
|
||
disable_notification=True,
|
||
)
|
||
return True
|
||
except TelegramForbiddenError:
|
||
return False
|
||
except TelegramBadRequest as error:
|
||
logger.warning(
|
||
"Некорректный запрос при отправке закрепленного сообщения в чат %s: %s",
|
||
chat_id,
|
||
error,
|
||
)
|
||
except Exception as error: # noqa: BLE001
|
||
logger.error(
|
||
"Не удалось отправить закрепленное сообщение пользователю %s: %s",
|
||
chat_id,
|
||
error,
|
||
)
|
||
|
||
return False
|
||
|
||
|
||
async def _unpin_message_for_user(bot: Bot, chat_id: int) -> bool:
|
||
try:
|
||
await bot.unpin_all_chat_messages(chat_id=chat_id)
|
||
return True
|
||
except TelegramForbiddenError:
|
||
return False
|
||
except TelegramBadRequest:
|
||
return False
|
||
except Exception as error: # noqa: BLE001
|
||
logger.error(
|
||
"Не удалось открепить сообщение у пользователя %s: %s",
|
||
chat_id,
|
||
error,
|
||
)
|
||
return False
|