feat: /new_creative tg_bot

This commit is contained in:
Artem Tsyrulnikov
2025-12-01 13:20:08 +03:00
parent 3508a522ac
commit f7754b707d
21 changed files with 688 additions and 113 deletions

View File

@@ -1,4 +1,5 @@
import datetime
import typing
import uuid
from sqlalchemy import delete, select
@@ -441,3 +442,31 @@ class Postgres(DatabaseBase):
result = await self.session.execute(q)
return list(result.scalars().all())
async def get_telegram_state(self, telegram_id: int) -> domain.TelegramState | None:
q = select(domain.TelegramState).where(domain.TelegramState.telegram_id == telegram_id)
result = await self.session.execute(q)
return result.scalar_one_or_none()
async def set_telegram_state(
self, telegram_id: int, state: domain.TelegramStateEnum, context: dict[str, typing.Any]
) -> domain.TelegramState:
existing = await self.get_telegram_state(telegram_id)
if existing:
existing.state = state
existing.context = context
existing.updated_at = datetime.datetime.now(datetime.UTC)
await self.session.flush()
await self.session.refresh(existing)
return existing
new_state = domain.TelegramState(telegram_id=telegram_id, state=state, context=context)
self.session.add(new_state)
await self.session.flush()
await self.session.refresh(new_state)
return new_state
async def clear_telegram_state(self, telegram_id: int) -> None:
q = delete(domain.TelegramState).where(domain.TelegramState.telegram_id == telegram_id)
await self.session.execute(q)

View File

@@ -1,7 +1,7 @@
import logging
from typing import Any
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.types import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup
from shared.telegram_base import TelegramBase
@@ -23,3 +23,20 @@ class TelegramBot(TelegramBase):
async def create_chat_invite_link(self, chat_id: int, requires_approval: bool = False) -> str:
invite_link = await self.bot.create_chat_invite_link(chat_id=chat_id, creates_join_request=requires_approval)
return invite_link.invite_link
async def send_message_with_inline_keyboard(
self, text: str, chat_id: int, buttons: list[list[InlineKeyboardButton]]
) -> None:
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
await self.bot.send_message(chat_id=chat_id, text=text, reply_markup=keyboard)
async def edit_message_reply_markup(self, chat_id: int, message_id: int) -> None:
await self.bot.edit_message_reply_markup(chat_id=chat_id, message_id=message_id, reply_markup=None)
async def set_commands(self) -> None:
commands = [
BotCommand(command='start', description='Начать работу с ботом'),
BotCommand(command='new_creative', description='Создать новый креатив'),
]
await self.bot.set_my_commands(commands)

View File

@@ -3,4 +3,10 @@ from aiogram import Router
telegram_callback_router = Router()
# Import handlers to register them
from . import chat_join_request, chat_member_updated, my_chat_member, start_with_login # noqa: E402, F401
from . import ( # noqa: E402, F401
chat_join_request,
chat_member_updated,
creative_commands,
my_chat_member,
start_with_login,
)

View File

@@ -0,0 +1,51 @@
import logging
from aiogram import F
from aiogram.filters import Command
from aiogram.types import CallbackQuery, Message
from src import dependencies
from src.controller.telegram_callback import telegram_callback_router
log = logging.getLogger(__name__)
@telegram_callback_router.message(Command('new_creative'))
async def cmd_new_creative(message: Message) -> None:
if not message.from_user:
log.error('Failed to get user data from message')
return
usecase = dependencies.get_usecase()
await usecase.start_creative_creation(telegram_id=message.from_user.id, chat_id=message.chat.id)
@telegram_callback_router.callback_query(F.data.startswith('cc_ch:'))
async def callback_creative_channel(callback: CallbackQuery) -> None:
if not callback.from_user or not callback.message or not callback.data:
log.error('Failed to get required data from callback query')
return
usecase = dependencies.get_usecase()
await usecase.handle_creative_callback(
telegram_id=callback.from_user.id,
chat_id=callback.message.chat.id,
callback_data=callback.data,
message_id=callback.message.message_id,
)
await callback.answer()
@telegram_callback_router.message(F.text & ~F.text.startswith('/'))
async def handle_text_message(message: Message) -> None:
if not message.from_user or not message.text:
return
usecase = dependencies.get_usecase()
await usecase.handle_creative_text_input(
telegram_id=message.from_user.id,
chat_id=message.chat.id,
text=message.text,
text_html=message.html_text,
)

View File

@@ -28,3 +28,14 @@ async def cmd_start_with_deeplink(message: Message) -> None:
)
await dependencies.get_usecase().telegram_login(input=input)
@telegram_callback_router.message(CommandStart())
async def cmd_start(message: Message) -> None:
"""Handle /start command without deep link."""
if not message.from_user:
log.error('Failed to get user data from message')
return
usecase = dependencies.get_usecase()
await usecase.telegram_start(telegram_id=message.from_user.id, chat_id=message.chat.id)

View File

@@ -8,6 +8,8 @@ __all__ = (
'Subscription',
'Subscriber',
'PlacementViewsHistory',
'TelegramState',
'TelegramStateEnum',
'TargetChannelStatus',
'CreativeStatus',
'PlacementStatus',
@@ -52,4 +54,5 @@ from .placement_views_history import PlacementViewsHistory
from .subscriber import Subscriber
from .subscription import Subscription, SubscriptionStatus
from .target_channel import TargetChannel, TargetChannelStatus
from .telegram_state import TelegramState, TelegramStateEnum
from .user import User

View File

@@ -0,0 +1,24 @@
import enum
from typing import Any
from sqlalchemy import BigInteger, Enum
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from . import Base
class TelegramStateEnum(str, enum.Enum):
"""Telegram bot conversation states."""
CREATIVE_WAITING_CHANNEL = 'creative_waiting_channel'
CREATIVE_WAITING_NAME = 'creative_waiting_name'
CREATIVE_WAITING_TEXT = 'creative_waiting_text'
class TelegramState(Base):
"""Хранит текущее состояние разговора пользователя с ботом для multi-step flows."""
telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, nullable=False, index=True)
state: Mapped[TelegramStateEnum] = mapped_column(Enum(TelegramStateEnum), nullable=False)
context: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)

View File

@@ -12,5 +12,4 @@ class User(domain.Base):
telegram_id: Mapped[int] = mapped_column(unique=True, index=True)
username: Mapped[str | None]
# Relationship with channels
target_channels: Mapped[list['TargetChannel']] = relationship(back_populates='user')

View File

@@ -0,0 +1,19 @@
import enum
from typing import Any
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from src import domain
class TelegramStateEnum(enum.StrEnum):
CREATIVE_WAITING_CHANNEL = 'creative_waiting_channel'
CREATIVE_WAITING_NAME = 'creative_waiting_name'
CREATIVE_WAITING_TEXT = 'creative_waiting_text'
class TelegramState(domain.Base):
telegram_id: Mapped[int] = mapped_column(unique=True, index=True)
state: Mapped[TelegramStateEnum]
context: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)

View File

@@ -21,6 +21,7 @@ from src.usecase import Usecase
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
await postgres.connect()
await telegram.start_polling()
await telegram.set_commands()
await views_worker.start()
yield

View File

@@ -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

View 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
)

View 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)

View 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={}
)

View 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)

View 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)