feat: /new_creative tg_bot
This commit is contained in:
@@ -3,6 +3,9 @@ import typing
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
|
||||
from src import domain
|
||||
|
||||
from .analytics.get_creatives_analytics import get_creatives_analytics
|
||||
@@ -13,6 +16,9 @@ from .creative.create_creative import create_creative
|
||||
from .creative.delete_creative import delete_creative
|
||||
from .creative.get_creative import get_creative
|
||||
from .creative.get_creatives import get_creatives
|
||||
from .creative.handle_creative_callback import handle_creative_callback
|
||||
from .creative.handle_creative_text_input import handle_creative_text_input
|
||||
from .creative.start_creative_creation import start_creative_creation
|
||||
from .creative.update_creative import update_creative
|
||||
from .external_channel.create_external_channel import create_external_channel
|
||||
from .external_channel.delete_external_channel import delete_external_channel
|
||||
@@ -33,6 +39,7 @@ from .target_channel.disconnect_target_chan_by_tg_id import disconnect_target_ch
|
||||
from .target_channel.get_user_target_chans import get_user_target_chans
|
||||
from .target_channel.update_target_chan_permissions import update_target_chan_permissions
|
||||
from .telegram_login import telegram_login
|
||||
from .telegram_start import telegram_start
|
||||
from .validate_login_token import validate_login_token
|
||||
from .views.fetch_views_manually import fetch_views_manually
|
||||
from .views.get_views_history import get_views_history
|
||||
@@ -43,7 +50,7 @@ from .views.update_views_manually import update_views_manually
|
||||
class Database(typing.Protocol):
|
||||
def transaction(self) -> typing.AsyncContextManager[None]: ...
|
||||
|
||||
async def get_user(self, *, user_id: UUID | None = None, telegram_id: int | None = None) -> domain.User | None: ...
|
||||
async def get_user(self, user_id: UUID | None = None, telegram_id: int | None = None) -> domain.User | None: ...
|
||||
|
||||
async def create_user(self, user: domain.User) -> domain.User: ...
|
||||
|
||||
@@ -156,12 +163,26 @@ class Database(typing.Protocol):
|
||||
|
||||
async def get_active_placements_with_urls(self) -> list[domain.Placement]: ... # Для worker
|
||||
|
||||
async def get_telegram_state(self, telegram_id: int) -> domain.TelegramState | None: ...
|
||||
|
||||
async def set_telegram_state(
|
||||
self, telegram_id: int, state: domain.TelegramStateEnum, context: dict[str, typing.Any]
|
||||
) -> domain.TelegramState: ...
|
||||
|
||||
async def clear_telegram_state(self, telegram_id: int) -> None: ...
|
||||
|
||||
|
||||
class TelegramBotWriter(typing.Protocol):
|
||||
async def send_message(self, text: str, chat_id: int) -> None: ...
|
||||
|
||||
async def send_message_with_button(self, text: str, chat_id: int, button_text: str, button_url: str) -> None: ...
|
||||
|
||||
async def send_message_with_inline_keyboard(
|
||||
self, text: str, chat_id: int, buttons: list[list['InlineKeyboardButton']]
|
||||
) -> None: ...
|
||||
|
||||
async def edit_message_reply_markup(self, chat_id: int, message_id: int) -> None: ...
|
||||
|
||||
async def get_chat_member(self, chat_id: int, user_id: int) -> dict[str, typing.Any]: ...
|
||||
|
||||
async def create_chat_invite_link(self, chat_id: int, requires_approval: bool = False) -> str: ...
|
||||
@@ -184,6 +205,7 @@ class Usecase:
|
||||
|
||||
validate_login_token = validate_login_token
|
||||
telegram_login = telegram_login
|
||||
telegram_start = telegram_start
|
||||
get_me = get_me
|
||||
connect_target_chan = connect_target_chan
|
||||
get_user_target_chans = get_user_target_chans
|
||||
@@ -200,6 +222,9 @@ class Usecase:
|
||||
create_creative = create_creative
|
||||
update_creative = update_creative
|
||||
delete_creative = delete_creative
|
||||
start_creative_creation = start_creative_creation
|
||||
handle_creative_callback = handle_creative_callback
|
||||
handle_creative_text_input = handle_creative_text_input
|
||||
get_placements = get_placements
|
||||
get_placement = get_placement
|
||||
create_placement = create_placement
|
||||
|
||||
55
src/usecase/creative/handle_creative_callback.py
Normal file
55
src/usecase/creative/handle_creative_callback.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_creative_callback(
|
||||
self: 'Usecase', telegram_id: int, chat_id: int, callback_data: str, message_id: int
|
||||
) -> None:
|
||||
async with self.database.transaction():
|
||||
state = await self.database.get_telegram_state(telegram_id)
|
||||
if not state or state.state != domain.TelegramStateEnum.CREATIVE_WAITING_CHANNEL:
|
||||
log.debug('User %s has no active creative creation flow or wrong state', telegram_id)
|
||||
return
|
||||
|
||||
if not callback_data.startswith('cc_ch:'):
|
||||
log.warning('Invalid callback data format: %s', callback_data)
|
||||
return
|
||||
|
||||
channel_id_str = callback_data.split(':', 1)[1]
|
||||
try:
|
||||
channel_id = uuid.UUID(channel_id_str)
|
||||
except ValueError:
|
||||
log.error('Invalid channel UUID in callback data: %s', channel_id_str)
|
||||
return
|
||||
|
||||
user = await self.database.get_user(telegram_id=telegram_id)
|
||||
if not user:
|
||||
await self.telegram_bot.send_message('❌ Ошибка: пользователь не найден.', chat_id=chat_id)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
channel = await self.database.get_target_channel(user.id, channel_id=channel_id)
|
||||
if not channel:
|
||||
await self.telegram_bot.send_message('❌ Канал не найден или не принадлежит вам.', chat_id=chat_id)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
await self.database.set_telegram_state(
|
||||
telegram_id=telegram_id,
|
||||
state=domain.TelegramStateEnum.CREATIVE_WAITING_NAME,
|
||||
context={'target_channel_id': str(channel_id), 'channel_title': channel.title},
|
||||
)
|
||||
|
||||
await self.telegram_bot.edit_message_reply_markup(chat_id, message_id)
|
||||
|
||||
await self.telegram_bot.send_message(
|
||||
f'✅ Канал выбран: {channel.title}\n\n📝 Введите название креатива:', chat_id=chat_id
|
||||
)
|
||||
98
src/usecase/creative/handle_creative_text_input.py
Normal file
98
src/usecase/creative/handle_creative_text_input.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain
|
||||
|
||||
from .text_formatting import prepare_creative_text_html
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_creative_text_input(
|
||||
self: 'Usecase',
|
||||
telegram_id: int,
|
||||
chat_id: int,
|
||||
text: str,
|
||||
text_html: str | None = None,
|
||||
) -> None:
|
||||
async with self.database.transaction():
|
||||
state = await self.database.get_telegram_state(telegram_id)
|
||||
if not state:
|
||||
return
|
||||
|
||||
user = await self.database.get_user(telegram_id=telegram_id)
|
||||
if not user:
|
||||
await self.telegram_bot.send_message('❌ Ошибка: пользователь не найден.', chat_id=chat_id)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
if state.state == domain.TelegramStateEnum.CREATIVE_WAITING_NAME:
|
||||
await self.database.set_telegram_state(
|
||||
telegram_id=telegram_id,
|
||||
state=domain.TelegramStateEnum.CREATIVE_WAITING_TEXT,
|
||||
context={**state.context, 'creative_name': text},
|
||||
)
|
||||
await self.telegram_bot.send_message(
|
||||
'✅ Название сохранено!\n\n'
|
||||
'💬 Теперь отправьте или перешлите текст креатива.\n'
|
||||
'Используйте {link} для обозначения места вставки ссылки на ваш канал.\n'
|
||||
'Вы также можете указать текст ссылки в формате [текст]{link}.',
|
||||
chat_id=chat_id,
|
||||
)
|
||||
|
||||
elif state.state == domain.TelegramStateEnum.CREATIVE_WAITING_TEXT:
|
||||
target_channel_id_str = state.context.get('target_channel_id')
|
||||
creative_name = state.context.get('creative_name')
|
||||
|
||||
if not target_channel_id_str or not creative_name:
|
||||
log.error('Missing context data for creative creation: %s', state.context)
|
||||
await self.telegram_bot.send_message('❌ Ошибка: данные не найдены. Начните заново.', chat_id=chat_id)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
try:
|
||||
target_channel_id = uuid.UUID(target_channel_id_str)
|
||||
except ValueError:
|
||||
log.error('Invalid target_channel_id in context: %s', target_channel_id_str)
|
||||
await self.telegram_bot.send_message('❌ Ошибка: неверный формат данных.', chat_id=chat_id)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
channel = await self.database.get_target_channel(user.id, channel_id=target_channel_id)
|
||||
if not channel:
|
||||
await self.telegram_bot.send_message('❌ Канал не найден.', chat_id=chat_id)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
message_html = text_html or text
|
||||
creative_text = prepare_creative_text_html(message_html)
|
||||
|
||||
creative = domain.Creative(
|
||||
name=creative_name,
|
||||
text=creative_text,
|
||||
status=domain.CreativeStatus.ACTIVE,
|
||||
user_id=user.id,
|
||||
target_channel_id=target_channel_id,
|
||||
)
|
||||
|
||||
created = await self.database.create_creative(creative)
|
||||
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
|
||||
text_preview_source = text
|
||||
text_preview = text_preview_source[:100] + '...' if len(text_preview_source) > 100 else text_preview_source
|
||||
await self.telegram_bot.send_message(
|
||||
f'✅ Креатив успешно создан!\n\n'
|
||||
f'📝 Название: {created.name}\n'
|
||||
f'📢 Канал: {channel.title}\n\n'
|
||||
f'💬 Текст:\n{text_preview}',
|
||||
chat_id=chat_id,
|
||||
)
|
||||
|
||||
else:
|
||||
log.warning('Unknown state: %s', state.state)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
43
src/usecase/creative/start_creative_creation.py
Normal file
43
src/usecase/creative/start_creative_creation.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
|
||||
from src import domain
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def start_creative_creation(self: 'Usecase', telegram_id: int, chat_id: int) -> None:
|
||||
async with self.database.transaction():
|
||||
user = await self.database.get_user(telegram_id=telegram_id)
|
||||
if not user:
|
||||
await self.telegram_bot.send_message(
|
||||
'❌ Вы не авторизованы. Используйте /start для входа.', chat_id=chat_id
|
||||
)
|
||||
return
|
||||
|
||||
channels = await self.database.get_user_target_channels(user.id)
|
||||
|
||||
if not channels:
|
||||
await self.telegram_bot.send_message(
|
||||
'❌ У вас нет подключенных каналов. Добавьте бота в свой канал для начала работы.', chat_id=chat_id
|
||||
)
|
||||
return
|
||||
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text=channel.title, callback_data=f'cc_ch:{channel.id}')] for channel in channels
|
||||
]
|
||||
|
||||
await self.telegram_bot.send_message_with_inline_keyboard(
|
||||
'✨ Создание нового креатива\n\nВыберите канал, для которого создается креатив:',
|
||||
chat_id=chat_id,
|
||||
buttons=buttons,
|
||||
)
|
||||
|
||||
await self.database.set_telegram_state(
|
||||
telegram_id=telegram_id, state=domain.TelegramStateEnum.CREATIVE_WAITING_CHANNEL, context={}
|
||||
)
|
||||
22
src/usecase/creative/text_formatting.py
Normal file
22
src/usecase/creative/text_formatting.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import re
|
||||
|
||||
_LINK_WITH_LABEL_PATTERN: re.Pattern[str] = re.compile(r'\[(?P<label>[^\[\]]+?)\]\s*\{link\}', re.IGNORECASE)
|
||||
_LINK_PLACEHOLDER_PATTERN: re.Pattern[str] = re.compile(r'\{link\}', re.IGNORECASE)
|
||||
_LINK_CLASS = 'tg-link'
|
||||
|
||||
|
||||
def _replace_label_placeholder(match: re.Match[str]) -> str:
|
||||
label = match.group('label')
|
||||
return f'<a class="{_LINK_CLASS}">{label}</a>'
|
||||
|
||||
|
||||
def prepare_creative_text_html(source: str) -> str:
|
||||
"""Normalize Telegram HTML and replace {link} placeholder with custom elements.
|
||||
|
||||
Supported patterns:
|
||||
- ``{link}`` → ``<a class="tg-link"></a>``
|
||||
- ``[Label]{link}`` → ``<a class="tg-link">Label</a>``
|
||||
"""
|
||||
|
||||
with_labels = _LINK_WITH_LABEL_PATTERN.sub(_replace_label_placeholder, source)
|
||||
return _LINK_PLACEHOLDER_PATTERN.sub(f'<a class="{_LINK_CLASS}"></a>', with_labels)
|
||||
20
src/usecase/telegram_start.py
Normal file
20
src/usecase/telegram_start.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def telegram_start(self: 'Usecase', telegram_id: int, chat_id: int) -> None:
|
||||
async with self.database.transaction():
|
||||
user = await self.database.get_user(telegram_id=telegram_id)
|
||||
|
||||
if user:
|
||||
username_display = f'@{user.username}' if user.username else 'пользователь'
|
||||
message = f'Привет, {username_display}! 👋\n\nДоступные команды:\n/new_creative - Создать новый креатив'
|
||||
else:
|
||||
message = 'Привет! 👋\n\nДля работы с ботом необходимо авторизоваться через веб-интерфейс.'
|
||||
|
||||
await self.telegram_bot.send_message(message, chat_id=chat_id)
|
||||
Reference in New Issue
Block a user