9de34900a2
Bot uses default HTML parse mode — all messages are HTML-parsed by Telegram. Added html.escape() to all user-controlled and admin-controlled strings before interpolation into HTML messages to prevent injection and parse errors. 49 files, ~250+ injection points fixed: - user.full_name, first_name across all handlers and services - tariff.name/description in purchase flow, admin panel, auto-purchase service - campaign.name, start_parameter in admin and user-facing handlers - group.name, promo_group.name across promo management - contest.title, prize_text, leaderboard names (including public channels) - transaction.description (contains raw user.full_name from referral service) - restriction_reason across all balance and subscription handlers - ticket.title, message_text, poll.title, poll.description - welcome text template placeholders (first_name, username) - maintenance reason, admin_name, selected_prize.display_name New helpers in app/utils/formatting.py: - safe_html_name() for escaping display names - user_html_link() replacing 15+ duplicated inline link patterns
270 lines
9.1 KiB
Python
270 lines
9.1 KiB
Python
import html
|
|
from datetime import UTC, datetime
|
|
|
|
import structlog
|
|
from sqlalchemy import func, select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database.models import User, WelcomeText
|
|
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
WELCOME_TEXT_KEY = 'welcome_text'
|
|
|
|
|
|
async def get_active_welcome_text(db: AsyncSession) -> str | None:
|
|
result = await db.execute(
|
|
select(WelcomeText)
|
|
.where(WelcomeText.is_active == True)
|
|
.where(WelcomeText.is_enabled == True)
|
|
.order_by(WelcomeText.updated_at.desc())
|
|
)
|
|
welcome_text = result.scalar_one_or_none()
|
|
|
|
if welcome_text:
|
|
return welcome_text.text_content
|
|
|
|
return None
|
|
|
|
|
|
async def get_current_welcome_text_settings(db: AsyncSession) -> dict:
|
|
result = await db.execute(
|
|
select(WelcomeText).where(WelcomeText.is_active == True).order_by(WelcomeText.updated_at.desc())
|
|
)
|
|
welcome_text = result.scalar_one_or_none()
|
|
|
|
if welcome_text:
|
|
return {'text': welcome_text.text_content, 'is_enabled': welcome_text.is_enabled, 'id': welcome_text.id}
|
|
|
|
return {'text': await get_current_welcome_text_or_default(), 'is_enabled': True, 'id': None}
|
|
|
|
|
|
async def get_welcome_text_by_id(db: AsyncSession, welcome_text_id: int) -> WelcomeText | None:
|
|
result = await db.execute(select(WelcomeText).where(WelcomeText.id == welcome_text_id))
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def list_welcome_texts(
|
|
db: AsyncSession,
|
|
*,
|
|
include_inactive: bool = True,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
):
|
|
query = select(WelcomeText).order_by(WelcomeText.updated_at.desc())
|
|
if not include_inactive:
|
|
query = query.where(WelcomeText.is_active == True)
|
|
|
|
result = await db.execute(query.limit(limit).offset(offset))
|
|
return result.scalars().all()
|
|
|
|
|
|
async def count_welcome_texts(db: AsyncSession, *, include_inactive: bool = True) -> int:
|
|
query = select(func.count(WelcomeText.id))
|
|
if not include_inactive:
|
|
query = query.where(WelcomeText.is_active == True)
|
|
|
|
result = await db.execute(query)
|
|
return result.scalar()
|
|
|
|
|
|
async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool:
|
|
try:
|
|
result = await db.execute(
|
|
select(WelcomeText).where(WelcomeText.is_active == True).order_by(WelcomeText.updated_at.desc())
|
|
)
|
|
welcome_text = result.scalar_one_or_none()
|
|
|
|
if welcome_text:
|
|
welcome_text.is_enabled = not welcome_text.is_enabled
|
|
welcome_text.updated_at = datetime.now(UTC)
|
|
|
|
await db.commit()
|
|
await db.refresh(welcome_text)
|
|
|
|
status = 'включен' if welcome_text.is_enabled else 'отключен'
|
|
logger.info('Приветственный текст администратором', status=status, admin_id=admin_id)
|
|
return welcome_text.is_enabled
|
|
default_text = await get_current_welcome_text_or_default()
|
|
new_welcome_text = WelcomeText(text_content=default_text, is_active=True, is_enabled=True, created_by=admin_id)
|
|
|
|
db.add(new_welcome_text)
|
|
await db.commit()
|
|
await db.refresh(new_welcome_text)
|
|
|
|
logger.info('Создан и включен дефолтный приветственный текст администратором', admin_id=admin_id)
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error('Ошибка при переключении статуса приветственного текста', error=e)
|
|
await db.rollback()
|
|
return False
|
|
|
|
|
|
async def set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) -> bool:
|
|
try:
|
|
current_settings = await get_current_welcome_text_settings(db)
|
|
current_enabled_status = current_settings.get('is_enabled', True)
|
|
|
|
await db.execute(update(WelcomeText).values(is_active=False))
|
|
|
|
new_welcome_text = WelcomeText(
|
|
text_content=text_content, is_active=True, is_enabled=current_enabled_status, created_by=admin_id
|
|
)
|
|
|
|
db.add(new_welcome_text)
|
|
await db.commit()
|
|
await db.refresh(new_welcome_text)
|
|
|
|
logger.info('Установлен новый приветственный текст администратором', admin_id=admin_id)
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error('Ошибка при установке приветственного текста', error=e)
|
|
await db.rollback()
|
|
return False
|
|
|
|
|
|
async def create_welcome_text(
|
|
db: AsyncSession,
|
|
*,
|
|
text_content: str,
|
|
created_by: int | None = None,
|
|
is_enabled: bool = True,
|
|
is_active: bool = True,
|
|
) -> WelcomeText:
|
|
resolved_creator = created_by
|
|
|
|
if created_by is not None:
|
|
result = await db.execute(select(User.id).where(User.id == created_by))
|
|
resolved_creator = result.scalar_one_or_none()
|
|
|
|
if is_active:
|
|
await db.execute(update(WelcomeText).values(is_active=False))
|
|
|
|
welcome_text = WelcomeText(
|
|
text_content=text_content,
|
|
is_active=is_active,
|
|
is_enabled=is_enabled,
|
|
created_by=resolved_creator,
|
|
)
|
|
|
|
db.add(welcome_text)
|
|
await db.commit()
|
|
await db.refresh(welcome_text)
|
|
|
|
logger.info(
|
|
'✅ Создан приветственный текст ID (активный включен=)',
|
|
welcome_text_id=welcome_text.id,
|
|
is_active=welcome_text.is_active,
|
|
is_enabled=welcome_text.is_enabled,
|
|
)
|
|
return welcome_text
|
|
|
|
|
|
async def update_welcome_text(
|
|
db: AsyncSession,
|
|
welcome_text: WelcomeText,
|
|
*,
|
|
text_content: str | None = None,
|
|
is_enabled: bool | None = None,
|
|
is_active: bool | None = None,
|
|
) -> WelcomeText:
|
|
if is_active:
|
|
await db.execute(update(WelcomeText).where(WelcomeText.id != welcome_text.id).values(is_active=False))
|
|
|
|
if text_content is not None:
|
|
welcome_text.text_content = text_content
|
|
|
|
if is_enabled is not None:
|
|
welcome_text.is_enabled = is_enabled
|
|
|
|
if is_active is not None:
|
|
welcome_text.is_active = is_active
|
|
|
|
welcome_text.updated_at = datetime.now(UTC)
|
|
|
|
await db.commit()
|
|
await db.refresh(welcome_text)
|
|
|
|
logger.info(
|
|
'📝 Обновлен приветственный текст ID (активный включен=)',
|
|
welcome_text_id=welcome_text.id,
|
|
is_active=welcome_text.is_active,
|
|
is_enabled=welcome_text.is_enabled,
|
|
)
|
|
return welcome_text
|
|
|
|
|
|
async def delete_welcome_text(db: AsyncSession, welcome_text: WelcomeText) -> None:
|
|
await db.delete(welcome_text)
|
|
await db.commit()
|
|
logger.info('🗑️ Удален приветственный текст ID', welcome_text_id=welcome_text.id)
|
|
|
|
|
|
async def get_current_welcome_text_or_default() -> str:
|
|
return (
|
|
'Привет, {user_name}! 🎁 3 дней VPN бесплатно! '
|
|
'Подключайтесь за минуту и забудьте о блокировках. '
|
|
'✅ До 1 Гбит/с скорость '
|
|
'✅ Умный VPN — можно не отключать для большинства российских сервисов '
|
|
'✅ Современные протоколы — максимум защиты и анонимности '
|
|
'💉 Всего 99₽/мес за 1 устройство '
|
|
'👇 Жмите кнопку и подключайтесь!'
|
|
)
|
|
|
|
|
|
def replace_placeholders(text: str, user) -> str:
|
|
first_name = getattr(user, 'first_name', None)
|
|
username = getattr(user, 'username', None)
|
|
|
|
first_name = first_name.strip() if first_name else None
|
|
username = username.strip() if username else None
|
|
|
|
user_name = html.escape(first_name or username or 'друг')
|
|
display_first_name = html.escape(first_name or 'друг')
|
|
display_username = f'@{html.escape(username)}' if username else html.escape(first_name or 'друг')
|
|
clean_username = html.escape(username or first_name or 'друг')
|
|
|
|
replacements = {
|
|
'{user_name}': user_name,
|
|
'{first_name}': display_first_name,
|
|
'{username}': display_username,
|
|
'{username_clean}': clean_username,
|
|
'Egor': user_name,
|
|
}
|
|
|
|
result = text
|
|
for placeholder, value in replacements.items():
|
|
result = result.replace(placeholder, value)
|
|
|
|
return result
|
|
|
|
|
|
async def get_welcome_text_for_user(db: AsyncSession, user) -> str:
|
|
welcome_text = await get_active_welcome_text(db)
|
|
|
|
if not welcome_text:
|
|
return None
|
|
|
|
if isinstance(user, str):
|
|
|
|
class SimpleUser:
|
|
def __init__(self, name):
|
|
self.first_name = name
|
|
self.username = None
|
|
|
|
user = SimpleUser(user)
|
|
|
|
return replace_placeholders(welcome_text, user)
|
|
|
|
|
|
def get_available_placeholders() -> dict:
|
|
return {
|
|
'{user_name}': 'Имя или username пользователя (приоритет: имя → username → "друг")',
|
|
'{first_name}': 'Только имя пользователя (или "друг" если не указано)',
|
|
'{username}': 'Username с символом @ (или имя если username не указан)',
|
|
'{username_clean}': 'Username без символа @ (или имя если username не указан)',
|
|
}
|