Files
remnawave-bedolaga-telegram…/app/services/pinned_message_service.py
T
c0mrade 9a2aea038a chore: add uv package manager and ruff linter configuration
- Add pyproject.toml with uv and ruff configuration
- Pin Python version to 3.13 via .python-version
- Add Makefile commands: lint, format, fix
- Apply ruff formatting to entire codebase
- Remove unused imports (base64 in yookassa/simple_subscription)
- Update .gitignore for new config files
2026-01-24 17:45:27 +03:00

357 lines
11 KiB
Python

import asyncio
import logging
from datetime import datetime
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) -> PinnedMessage | None:
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: int | None = None,
media_type: str | None = None,
media_file_id: str | None = None,
send_before_menu: bool | None = None,
send_on_every_start: bool | None = 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) -> PinnedMessage | None:
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: PinnedMessage | None = 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
# Skip email-only users (no telegram_id)
if not user.telegram_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
# Skip email-only users (no telegram_id)
if not user.telegram_id:
failed_count += 1
return
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:
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
# Skip email-only users (no telegram_id)
if not user.telegram_id:
failed_count += 1
return
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:
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: int | None,
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:
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:
logger.error(
'Не удалось открепить сообщение у пользователя %s: %s',
chat_id,
error,
)
return False