Реферальные и глубокие ссылки start
This commit is contained in:
544
bot/middlewares/referal_mdw.py
Normal file
544
bot/middlewares/referal_mdw.py
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
"""
|
||||||
|
Middleware для обработки реферальных ссылок и deep links
|
||||||
|
"""
|
||||||
|
from typing import Callable, Awaitable, Any, Dict, Optional
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from collections import defaultdict
|
||||||
|
import re
|
||||||
|
|
||||||
|
from aiogram import BaseMiddleware
|
||||||
|
from aiogram.filters.command import CommandObject
|
||||||
|
from aiogram.types import TelegramObject, Message, User
|
||||||
|
|
||||||
|
from middleware.loggers import logger
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'ReferralMiddleware',
|
||||||
|
'DeepLinkData',
|
||||||
|
'referral_stats',
|
||||||
|
'ReferralType'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReferralType:
|
||||||
|
"""Типы реферальных ссылок"""
|
||||||
|
REFERRAL = 'ref' # Обычная реферальная ссылка
|
||||||
|
PROMO = 'promo' # Промокод
|
||||||
|
UTM = 'utm' # UTM метки
|
||||||
|
INVITE = 'invite' # Инвайт-ссылка
|
||||||
|
DEEPLINK = 'deeplink' # Произвольный deep link
|
||||||
|
CUSTOM = 'custom' # Кастомный тип
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeepLinkData:
|
||||||
|
"""
|
||||||
|
Данные deep link.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
raw: Исходная строка (все после /start)
|
||||||
|
type: Тип ссылки (ref, promo, utm, и т.д.)
|
||||||
|
params: Распарсенные параметры
|
||||||
|
user_id: ID пользователя, перешедшего по ссылке
|
||||||
|
username: Username пользователя
|
||||||
|
timestamp: Время перехода
|
||||||
|
is_valid: Валидна ли ссылка
|
||||||
|
"""
|
||||||
|
raw: str
|
||||||
|
type: str = ReferralType.DEEPLINK
|
||||||
|
params: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
username: Optional[str] = None
|
||||||
|
timestamp: datetime = field(default_factory=datetime.now)
|
||||||
|
is_valid: bool = True
|
||||||
|
|
||||||
|
def get(self, key: str, default: Any = None) -> Any:
|
||||||
|
"""Получает параметр по ключу"""
|
||||||
|
return self.params.get(key, default)
|
||||||
|
|
||||||
|
def __getitem__(self, key: str) -> Any:
|
||||||
|
"""Позволяет использовать data['key']"""
|
||||||
|
return self.params[key]
|
||||||
|
|
||||||
|
def __contains__(self, key: str) -> bool:
|
||||||
|
"""Позволяет использовать 'key' in data"""
|
||||||
|
return key in self.params
|
||||||
|
|
||||||
|
|
||||||
|
class ReferralStatistics:
|
||||||
|
"""
|
||||||
|
Статистика реферальных переходов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Счетчики переходов по типам: {type: count}
|
||||||
|
self.clicks_by_type: Dict[str, int] = defaultdict(int)
|
||||||
|
|
||||||
|
# Переходы по кодам: {ref_code: count}
|
||||||
|
self.clicks_by_code: Dict[str, int] = defaultdict(int)
|
||||||
|
|
||||||
|
# История переходов: [(timestamp, user_id, ref_code, type), ...]
|
||||||
|
self.history: list[tuple[datetime, int, str, str]] = []
|
||||||
|
|
||||||
|
# Уникальные пользователи: {ref_code: set(user_ids)}
|
||||||
|
self.unique_users: Dict[str, set[int]] = defaultdict(set)
|
||||||
|
|
||||||
|
def record(self, deep_link: DeepLinkData) -> None:
|
||||||
|
"""Записывает переход"""
|
||||||
|
# Счетчик по типу
|
||||||
|
self.clicks_by_type[deep_link.type] += 1
|
||||||
|
|
||||||
|
# Счетчик по коду (если есть реферальный код)
|
||||||
|
ref_code = deep_link.get('ref_code') or deep_link.get('code') or deep_link.raw
|
||||||
|
if ref_code:
|
||||||
|
self.clicks_by_code[ref_code] += 1
|
||||||
|
|
||||||
|
# Уникальные пользователи
|
||||||
|
if deep_link.user_id:
|
||||||
|
self.unique_users[ref_code].add(deep_link.user_id)
|
||||||
|
|
||||||
|
# История
|
||||||
|
if deep_link.user_id:
|
||||||
|
self.history.append((
|
||||||
|
deep_link.timestamp,
|
||||||
|
deep_link.user_id,
|
||||||
|
ref_code,
|
||||||
|
deep_link.type
|
||||||
|
))
|
||||||
|
|
||||||
|
def get_stats(self, ref_code: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Возвращает статистику.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ref_code: Код для фильтрации (если None, возвращает общую статистику)
|
||||||
|
"""
|
||||||
|
if ref_code:
|
||||||
|
return {
|
||||||
|
'ref_code': ref_code,
|
||||||
|
'total_clicks': self.clicks_by_code.get(ref_code, 0),
|
||||||
|
'unique_users': len(self.unique_users.get(ref_code, set()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_clicks': sum(self.clicks_by_type.values()),
|
||||||
|
'clicks_by_type': dict(self.clicks_by_type),
|
||||||
|
'top_codes': self.get_top_codes(10),
|
||||||
|
'total_unique_users': sum(len(users) for users in self.unique_users.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_top_codes(self, limit: int = 10) -> list[tuple[str, int]]:
|
||||||
|
"""Возвращает топ реферальных кодов"""
|
||||||
|
sorted_codes = sorted(
|
||||||
|
self.clicks_by_code.items(),
|
||||||
|
key=lambda x: x[1],
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
return sorted_codes[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
# Глобальная статистика
|
||||||
|
referral_stats = ReferralStatistics()
|
||||||
|
|
||||||
|
|
||||||
|
class ReferralMiddleware(BaseMiddleware):
|
||||||
|
"""
|
||||||
|
Middleware для обработки реферальных ссылок и deep links.
|
||||||
|
|
||||||
|
Возможности:
|
||||||
|
- Парсинг различных форматов deep links
|
||||||
|
- Автоматическое определение типа ссылки
|
||||||
|
- Валидация параметров
|
||||||
|
- Сбор статистики
|
||||||
|
- Интеграция с базой данных через callback
|
||||||
|
- Поддержка сложных параметров (ref_123_promo_abc)
|
||||||
|
|
||||||
|
Поддерживаемые форматы:
|
||||||
|
- /start ref123 → {'ref_code': 'ref123'}
|
||||||
|
- /start promo_SUMMER2024 → {'type': 'promo', 'code': 'SUMMER2024'}
|
||||||
|
- /start ref_123_bonus_50 → {'ref_code': '123', 'bonus': '50'}
|
||||||
|
- /start utm_source_telegram → {'utm_source': 'telegram'}
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
on_referral: Callback функция для сохранения в БД
|
||||||
|
validator: Функция валидации кодов
|
||||||
|
parse_complex: Парсить ли сложные параметры
|
||||||
|
collect_stats: Собирать ли статистику
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from middleware.referral import ReferralMiddleware, DeepLinkData
|
||||||
|
|
||||||
|
async def save_referral(deep_link: DeepLinkData):
|
||||||
|
# Сохранение в БД
|
||||||
|
await db.save_referral(
|
||||||
|
user_id=deep_link.user_id,
|
||||||
|
ref_code=deep_link.get('ref_code'),
|
||||||
|
timestamp=deep_link.timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
# Регистрация middleware
|
||||||
|
referral_mdw = ReferralMiddleware(
|
||||||
|
on_referral=save_referral,
|
||||||
|
parse_complex=True,
|
||||||
|
collect_stats=True
|
||||||
|
)
|
||||||
|
|
||||||
|
dp.message.middleware(referral_mdw)
|
||||||
|
|
||||||
|
# В хендлере
|
||||||
|
@router.message(CommandStart())
|
||||||
|
async def start(message: Message, deep_link: Optional[DeepLinkData] = None):
|
||||||
|
if deep_link:
|
||||||
|
ref_code = deep_link.get('ref_code')
|
||||||
|
await message.answer(f"Привет! Вы пришли по ссылке: {ref_code}")
|
||||||
|
else:
|
||||||
|
await message.answer("Привет!")
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Паттерны для парсинга
|
||||||
|
PATTERNS = {
|
||||||
|
# ref_123 или ref123
|
||||||
|
ReferralType.REFERRAL: re.compile(r'^ref[_-]?(\w+)$', re.IGNORECASE),
|
||||||
|
|
||||||
|
# promo_SUMMER2024
|
||||||
|
ReferralType.PROMO: re.compile(r'^promo[_-]?(\w+)$', re.IGNORECASE),
|
||||||
|
|
||||||
|
# invite_abc123
|
||||||
|
ReferralType.INVITE: re.compile(r'^invite[_-]?(\w+)$', re.IGNORECASE),
|
||||||
|
|
||||||
|
# utm_source_telegram_campaign_ads
|
||||||
|
ReferralType.UTM: re.compile(r'^utm[_-]', re.IGNORECASE),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
on_referral: Optional[Callable[[DeepLinkData], Awaitable[None]]] = None,
|
||||||
|
validator: Optional[Callable[[str], bool]] = None,
|
||||||
|
parse_complex: bool = True,
|
||||||
|
collect_stats: bool = True,
|
||||||
|
max_length: int = 64
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Инициализация middleware.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
on_referral: Callback для обработки реферала (сохранение в БД)
|
||||||
|
validator: Функция валидации кода (должна вернуть True если валиден)
|
||||||
|
parse_complex: Парсить ли сложные параметры (ref_123_bonus_50)
|
||||||
|
collect_stats: Собирать ли статистику
|
||||||
|
max_length: Максимальная длина deep link
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
|
self.on_referral = on_referral
|
||||||
|
self.validator = validator
|
||||||
|
self.parse_complex = parse_complex
|
||||||
|
self.collect_stats = collect_stats
|
||||||
|
self.max_length = max_length
|
||||||
|
|
||||||
|
def _parse_simple(self, args: str) -> tuple[str, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Парсит простые форматы deep links.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: Аргументы команды /start
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (тип, параметры)
|
||||||
|
"""
|
||||||
|
# Проверка по паттернам
|
||||||
|
for link_type, pattern in self.PATTERNS.items():
|
||||||
|
match = pattern.match(args)
|
||||||
|
if match:
|
||||||
|
if link_type == ReferralType.REFERRAL:
|
||||||
|
return link_type, {'ref_code': match.group(1)}
|
||||||
|
elif link_type == ReferralType.PROMO:
|
||||||
|
return link_type, {'code': match.group(1), 'promo_code': match.group(1)}
|
||||||
|
elif link_type == ReferralType.INVITE:
|
||||||
|
return link_type, {'invite_code': match.group(1)}
|
||||||
|
elif link_type == ReferralType.UTM:
|
||||||
|
# Парсим UTM параметры
|
||||||
|
return link_type, self._parse_utm(args)
|
||||||
|
|
||||||
|
# Если не совпало ни с одним паттерном - просто код
|
||||||
|
return ReferralType.DEEPLINK, {'code': args}
|
||||||
|
|
||||||
|
def _parse_utm(self, args: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Парсит UTM параметры: utm_source_telegram_campaign_ads
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: Строка с UTM параметрами
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict с UTM параметрами
|
||||||
|
"""
|
||||||
|
params = {}
|
||||||
|
|
||||||
|
# Удаляем префикс utm_
|
||||||
|
if args.lower().startswith('utm_'):
|
||||||
|
args = args[4:]
|
||||||
|
|
||||||
|
# Разбиваем по _ и парсим пары ключ-значение
|
||||||
|
parts = args.split('_')
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i < len(parts) - 1:
|
||||||
|
key = f"utm_{parts[i]}"
|
||||||
|
value = parts[i + 1]
|
||||||
|
params[key] = value
|
||||||
|
i += 2
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
def _parse_complex(self, args: str) -> tuple[str, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Парсит сложные форматы: ref_123_bonus_50_promo_SUMMER
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: Аргументы команды
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (тип, параметры)
|
||||||
|
"""
|
||||||
|
params = {}
|
||||||
|
parts = args.split('_')
|
||||||
|
|
||||||
|
# Определяем тип по первому элементу
|
||||||
|
link_type = ReferralType.DEEPLINK
|
||||||
|
|
||||||
|
if parts[0].lower() in ['ref', 'referral']:
|
||||||
|
link_type = ReferralType.REFERRAL
|
||||||
|
if len(parts) > 1:
|
||||||
|
params['ref_code'] = parts[1]
|
||||||
|
parts = parts[2:] # Пропускаем первые 2 элемента
|
||||||
|
elif parts[0].lower() == 'promo':
|
||||||
|
link_type = ReferralType.PROMO
|
||||||
|
if len(parts) > 1:
|
||||||
|
params['promo_code'] = parts[1]
|
||||||
|
parts = parts[2:]
|
||||||
|
elif parts[0].lower() == 'invite':
|
||||||
|
link_type = ReferralType.INVITE
|
||||||
|
if len(parts) > 1:
|
||||||
|
params['invite_code'] = parts[1]
|
||||||
|
parts = parts[2:]
|
||||||
|
|
||||||
|
# Парсим остальные параметры как пары ключ-значение
|
||||||
|
i = 0
|
||||||
|
while i < len(parts) - 1:
|
||||||
|
key = parts[i]
|
||||||
|
value = parts[i + 1]
|
||||||
|
|
||||||
|
# Пытаемся преобразовать в число
|
||||||
|
try:
|
||||||
|
value = int(value)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
value = float(value)
|
||||||
|
except ValueError:
|
||||||
|
pass # Оставляем строкой
|
||||||
|
|
||||||
|
params[key] = value
|
||||||
|
i += 2
|
||||||
|
|
||||||
|
return link_type, params
|
||||||
|
|
||||||
|
def _validate_deep_link(self, args: str) -> bool:
|
||||||
|
"""
|
||||||
|
Валидирует deep link.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: Строка для валидации
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True если валиден
|
||||||
|
"""
|
||||||
|
# Проверка длины
|
||||||
|
if len(args) > self.max_length:
|
||||||
|
logger.warning(
|
||||||
|
f"Deep link слишком длинный: {len(args)} > {self.max_length}",
|
||||||
|
log_type='REFERRAL'
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Проверка на запрещенные символы (только буквы, цифры, _ и -)
|
||||||
|
if not re.match(r'^[a-zA-Z0-9_-]+$', args):
|
||||||
|
logger.warning(
|
||||||
|
f"Deep link содержит недопустимые символы: {args}",
|
||||||
|
log_type='REFERRAL'
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Кастомная валидация
|
||||||
|
if self.validator:
|
||||||
|
return self.validator(args)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _parse_deep_link(self, args: str, user: User) -> DeepLinkData:
|
||||||
|
"""
|
||||||
|
Парсит deep link и создает объект DeepLinkData.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: Аргументы команды /start
|
||||||
|
user: Пользователь, перешедший по ссылке
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DeepLinkData: Распарсенные данные
|
||||||
|
"""
|
||||||
|
# Валидация
|
||||||
|
is_valid = self._validate_deep_link(args)
|
||||||
|
|
||||||
|
# Парсинг
|
||||||
|
if self.parse_complex and '_' in args:
|
||||||
|
link_type, params = self._parse_complex(args)
|
||||||
|
else:
|
||||||
|
link_type, params = self._parse_simple(args)
|
||||||
|
|
||||||
|
# Создаем объект
|
||||||
|
deep_link = DeepLinkData(
|
||||||
|
raw=args,
|
||||||
|
type=link_type,
|
||||||
|
params=params,
|
||||||
|
user_id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
is_valid=is_valid
|
||||||
|
)
|
||||||
|
|
||||||
|
return deep_link
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||||
|
event: TelegramObject,
|
||||||
|
data: Dict[str, Any]
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Перехватывает команды /start с аргументами.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
handler: Функция хендлера
|
||||||
|
event: Объект события
|
||||||
|
data: Дополнительные данные
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Результат хендлера
|
||||||
|
"""
|
||||||
|
# Обрабатываем только сообщения
|
||||||
|
if not isinstance(event, Message):
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
# Извлекаем команду
|
||||||
|
command: Optional[CommandObject] = data.get('command')
|
||||||
|
|
||||||
|
# Проверяем, что это /start с аргументами
|
||||||
|
if not command or command.command.lower() != 'start' or not command.args:
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
user = event.from_user
|
||||||
|
args = command.args
|
||||||
|
|
||||||
|
# Парсим deep link
|
||||||
|
deep_link = self._parse_deep_link(args, user)
|
||||||
|
|
||||||
|
# Логирование
|
||||||
|
if deep_link.is_valid:
|
||||||
|
logger.info(
|
||||||
|
f"Deep link: type={deep_link.type}, params={deep_link.params}",
|
||||||
|
log_type='REFERRAL',
|
||||||
|
user=f"@{user.username}" if user.username else f"id{user.id}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Невалидный deep link: {args}",
|
||||||
|
log_type='REFERRAL',
|
||||||
|
user=f"@{user.username}" if user.username else f"id{user.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Собираем статистику
|
||||||
|
if self.collect_stats and deep_link.is_valid:
|
||||||
|
referral_stats.record(deep_link)
|
||||||
|
|
||||||
|
# Вызываем callback для сохранения в БД
|
||||||
|
if self.on_referral and deep_link.is_valid:
|
||||||
|
try:
|
||||||
|
await self.on_referral(deep_link)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Ошибка в on_referral callback: {e}",
|
||||||
|
log_type='REFERRAL'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Добавляем deep_link в data для хендлера
|
||||||
|
data['deep_link'] = deep_link
|
||||||
|
data['ref_code'] = deep_link.get('ref_code') # Для обратной совместимости
|
||||||
|
|
||||||
|
# Выполняем хендлер
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
|
||||||
|
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
|
||||||
|
|
||||||
|
def create_deep_link(bot_username: str, **params) -> str:
|
||||||
|
"""
|
||||||
|
Создает deep link для бота.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot_username: Username бота (без @)
|
||||||
|
**params: Параметры для ссылки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Готовая ссылка
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> create_deep_link('mybot', ref_code='123', bonus='50')
|
||||||
|
'https://t.me/mybot?start=ref_123_bonus_50'
|
||||||
|
"""
|
||||||
|
# Формируем строку параметров
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
for key, value in params.items():
|
||||||
|
parts.append(str(key))
|
||||||
|
parts.append(str(value))
|
||||||
|
|
||||||
|
param_string = '_'.join(parts)
|
||||||
|
|
||||||
|
return f"https://t.me/{bot_username}?start={param_string}"
|
||||||
|
|
||||||
|
|
||||||
|
def create_referral_link(bot_username: str, ref_code: str) -> str:
|
||||||
|
"""
|
||||||
|
Создает простую реферальную ссылку.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot_username: Username бота
|
||||||
|
ref_code: Реферальный код
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Реферальная ссылка
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> create_referral_link('mybot', '123')
|
||||||
|
'https://t.me/mybot?start=ref_123'
|
||||||
|
"""
|
||||||
|
return f"https://t.me/{bot_username}?start=ref_{ref_code}"
|
||||||
|
|
||||||
|
|
||||||
|
def create_promo_link(bot_username: str, promo_code: str) -> str:
|
||||||
|
"""
|
||||||
|
Создает ссылку с промокодом.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot_username: Username бота
|
||||||
|
promo_code: Промокод
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Ссылка с промокодом
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> create_promo_link('mybot', 'SUMMER2024')
|
||||||
|
'https://t.me/mybot?start=promo_SUMMER2024'
|
||||||
|
"""
|
||||||
|
return f"https://t.me/{bot_username}?start=promo_{promo_code}"
|
||||||
Reference in New Issue
Block a user