new telegram bot
This commit is contained in:
@@ -8,7 +8,7 @@ from tortoise.transactions import in_transaction
|
||||
|
||||
from shared.datebase_base import DatabaseBase
|
||||
from src import domain
|
||||
from usecase import Database
|
||||
from src.usecase import Database
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import logging
|
||||
|
||||
from aiogram.types import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
from shared.telegram_base import TelegramBase
|
||||
from usecase import TelegramBotWriter
|
||||
from src.usecase import TelegramBotWriter
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,10 +29,4 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
|
||||
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)
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from src.controller.http.analytics import analytics_router
|
||||
from src.controller.http.auth import auth_router
|
||||
from src.controller.http.channels import channels_router
|
||||
from src.controller.http.creatives import creatives_router
|
||||
from src.controller.http.placements import placements_router
|
||||
from src.controller.http.projects import projects_router
|
||||
from src.controller.http.purchase_plans import purchase_plan_channels_router
|
||||
from src.controller.http.views import views_router
|
||||
from src.controller.http.workspace_invites import workspace_invites_router
|
||||
from src.controller.http.workspace_members import workspace_members_router
|
||||
from src.controller.http.workspaces import workspaces_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# API v1 endpoints
|
||||
api_v1_router = APIRouter(prefix='/api/v1')
|
||||
api_v1_router.include_router(auth_router)
|
||||
api_v1_router.include_router(channels_router)
|
||||
api_v1_router.include_router(projects_router)
|
||||
api_v1_router.include_router(purchase_plan_channels_router)
|
||||
api_v1_router.include_router(creatives_router)
|
||||
api_v1_router.include_router(placements_router)
|
||||
api_v1_router.include_router(views_router)
|
||||
api_v1_router.include_router(analytics_router)
|
||||
api_v1_router.include_router(workspaces_router)
|
||||
api_v1_router.include_router(workspace_members_router)
|
||||
api_v1_router.include_router(workspace_invites_router)
|
||||
|
||||
api_router.include_router(api_v1_router)
|
||||
@@ -1,25 +0,0 @@
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.routing import APIRouter
|
||||
from fastapi_pagination import Page, paginate
|
||||
|
||||
from src import deps, dto
|
||||
from src.adapter.jwt import JWTPayload
|
||||
|
||||
projects_router = APIRouter(prefix='/workspaces/{workspace_id}/projects', tags=['projects'])
|
||||
|
||||
|
||||
@projects_router.get('')
|
||||
async def get_workspace_projects(
|
||||
workspace_id: uuid.UUID,
|
||||
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||
) -> Page[dto.ProjectOutput]:
|
||||
input = dto.GetWorkspaceProjectsInput(
|
||||
user_id=current_user.user_id,
|
||||
workspace_id=workspace_id,
|
||||
)
|
||||
|
||||
result = await deps.get_usecase().get_workspace_projects(input=input)
|
||||
return paginate(result) # type: ignore[no-any-return]
|
||||
34
src/controller/http_v1/__init__.py
Normal file
34
src/controller/http_v1/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from src.controller.http_v1.analytics import analytics_router
|
||||
from src.controller.http_v1.auth import auth_router
|
||||
from src.controller.http_v1.channels import channels_router
|
||||
from src.controller.http_v1.creatives import creatives_router
|
||||
from src.controller.http_v1.internal import internal_router
|
||||
from src.controller.http_v1.placements import placements_router
|
||||
from src.controller.http_v1.projects import projects_router
|
||||
from src.controller.http_v1.purchase_plans import purchase_plan_channels_router
|
||||
from src.controller.http_v1.views import views_router
|
||||
from src.controller.http_v1.workspace_invites import workspace_invites_router, workspace_invites_global_router
|
||||
from src.controller.http_v1.workspace_members import workspace_members_router
|
||||
from src.controller.http_v1.workspaces import workspaces_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# API v1 endpoints
|
||||
api_v1_router = APIRouter(prefix='/api/v1')
|
||||
api_v1_router.include_router(auth_router)
|
||||
api_v1_router.include_router(internal_router)
|
||||
api_v1_router.include_router(channels_router)
|
||||
api_v1_router.include_router(projects_router)
|
||||
api_v1_router.include_router(purchase_plan_channels_router)
|
||||
api_v1_router.include_router(creatives_router)
|
||||
api_v1_router.include_router(placements_router)
|
||||
api_v1_router.include_router(views_router)
|
||||
api_v1_router.include_router(analytics_router)
|
||||
api_v1_router.include_router(workspaces_router)
|
||||
api_v1_router.include_router(workspace_members_router)
|
||||
api_v1_router.include_router(workspace_invites_router)
|
||||
api_v1_router.include_router(workspace_invites_global_router)
|
||||
|
||||
api_router.include_router(api_v1_router)
|
||||
@@ -18,3 +18,14 @@ async def get_channels(
|
||||
input = dto.GetChannelsInput(username=username)
|
||||
result = await deps.get_usecase().get_channels(input=input)
|
||||
return paginate(result) # type: ignore[no-any-return]
|
||||
|
||||
|
||||
@channels_router.get('/{channel_id}')
|
||||
async def get_channel(
|
||||
channel_id: str,
|
||||
_: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||
) -> dto.ChannelOutput:
|
||||
import uuid
|
||||
|
||||
input_data = dto.GetChannelInput(channel_id=uuid.UUID(channel_id))
|
||||
return await deps.get_usecase().get_channel(input=input_data)
|
||||
270
src/controller/http_v1/internal.py
Normal file
270
src/controller/http_v1/internal.py
Normal file
@@ -0,0 +1,270 @@
|
||||
from fastapi.routing import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src import deps, dto
|
||||
|
||||
internal_router = APIRouter(prefix='/internal', tags=['internal'])
|
||||
|
||||
|
||||
|
||||
|
||||
class CreateLoginTokenResponse(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
@internal_router.post('/auth/login-token')
|
||||
async def create_login_token(input: dto.CreateLoginTokenRequest) -> str:
|
||||
token = await deps.get_usecase().create_telegram_login_token(
|
||||
telegram_id=input.telegram_id,
|
||||
username=input.username,
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
class ValidateTokenRequest(BaseModel):
|
||||
token: str
|
||||
telegram_id: int
|
||||
username: str | None = None
|
||||
|
||||
|
||||
|
||||
@internal_router.get('/auth/jwt')
|
||||
async def get_jwt_by_telegram_id(telegram_id: int) -> dto.ValidateLoginTokenOutput:
|
||||
return await deps.get_usecase().get_jwt_by_telegram_id(telegram_id)
|
||||
|
||||
|
||||
@internal_router.get('/projects/{workspace_id}/{project_id}')
|
||||
async def get_project(workspace_id: str, project_id: str) -> dto.ProjectOutput:
|
||||
import uuid
|
||||
|
||||
input_data = dto.GetProjectInput(
|
||||
workspace_id=uuid.UUID(workspace_id),
|
||||
project_id=uuid.UUID(project_id),
|
||||
)
|
||||
return await deps.get_usecase().get_project(input_data)
|
||||
|
||||
|
||||
class AttachChannelToWorkspaceRequest(BaseModel):
|
||||
channel_id: str
|
||||
workspace_id: str
|
||||
user_telegram_id: int
|
||||
|
||||
|
||||
@internal_router.post('/projects')
|
||||
async def attach_channel_to_workspace(input: AttachChannelToWorkspaceRequest) -> dto.ProjectOutput:
|
||||
"""Привязать канал к workspace (вызывается из Golang бота после выбора workspace)"""
|
||||
import uuid
|
||||
|
||||
input_data = dto.AttachChannelToWorkspaceInput(
|
||||
channel_id=uuid.UUID(input.channel_id),
|
||||
workspace_id=uuid.UUID(input.workspace_id),
|
||||
user_telegram_id=input.user_telegram_id,
|
||||
)
|
||||
|
||||
return await deps.get_usecase().attach_channel_to_workspace(input=input_data)
|
||||
|
||||
|
||||
@internal_router.post('/telegram/updates')
|
||||
async def handle_telegram_update(update: dict) -> None:
|
||||
"""Обрабатывает системные события от Golang бота (echotron формат)"""
|
||||
import json
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Логируем что пришло для отладки
|
||||
log.info('Received update from Golang bot, keys: %s', list(update.keys()))
|
||||
|
||||
# Обрабатываем ChatJoinRequest (запрос на вступление в канал)
|
||||
if 'chat_join_request' in update:
|
||||
log.info('ChatJoinRequest event: %s', json.dumps(update['chat_join_request'], ensure_ascii=False))
|
||||
await _handle_chat_join_request(update['chat_join_request'])
|
||||
return
|
||||
|
||||
# Обрабатываем ChatMember (пользователь вступил/вышел из канала)
|
||||
if 'chat_member' in update:
|
||||
event = update['chat_member']
|
||||
old_status = event.get('old_chat_member', {}).get('status')
|
||||
new_status = event.get('new_chat_member', {}).get('status')
|
||||
log.info('ChatMember event: status change %s -> %s', old_status, new_status)
|
||||
await _handle_chat_member_updated(event)
|
||||
return
|
||||
|
||||
# Обрабатываем MyChatMember (бот добавлен/удален из канала)
|
||||
if 'my_chat_member' in update:
|
||||
event = update['my_chat_member']
|
||||
old_status = event.get('old_chat_member', {}).get('status')
|
||||
new_status = event.get('new_chat_member', {}).get('status')
|
||||
log.info('MyChatMember event: bot status changed %s -> %s in chat', old_status, new_status)
|
||||
await _handle_my_chat_member(event)
|
||||
return
|
||||
|
||||
log.warning('Unknown update type from Golang bot: %s', list(update.keys()))
|
||||
|
||||
|
||||
async def _handle_chat_join_request(event: dict) -> None:
|
||||
"""Обработка запроса на вступление в канал через invite link"""
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
chat = event.get('chat', {})
|
||||
if chat.get('type') not in ['channel', 'supergroup']:
|
||||
return
|
||||
|
||||
from_user = event.get('from')
|
||||
if not from_user:
|
||||
log.error('No user in chat_join_request event')
|
||||
return
|
||||
|
||||
invite_link_obj = event.get('invite_link', {})
|
||||
invite_link = invite_link_obj.get('invite_link')
|
||||
if not invite_link:
|
||||
log.debug('No invite_link in chat_join_request event')
|
||||
return
|
||||
|
||||
await deps.get_usecase().handle_subscription(
|
||||
user_telegram_id=from_user['id'],
|
||||
username=from_user.get('username'),
|
||||
invite_link=invite_link,
|
||||
first_name=from_user.get('first_name'),
|
||||
last_name=from_user.get('last_name'),
|
||||
)
|
||||
|
||||
|
||||
async def _handle_chat_member_updated(event: dict) -> None:
|
||||
"""Обработка изменения статуса участника канала (вступление/выход)"""
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
chat = event.get('chat', {})
|
||||
if chat.get('type') not in ['channel', 'supergroup']:
|
||||
return
|
||||
|
||||
from_user = event.get('from')
|
||||
if not from_user:
|
||||
log.error('No user in chat_member_updated event')
|
||||
return
|
||||
|
||||
old_member = event.get('old_chat_member', {})
|
||||
new_member = event.get('new_chat_member', {})
|
||||
|
||||
old_status = old_member.get('status')
|
||||
new_status = new_member.get('status')
|
||||
|
||||
# Проверяем что пользователь присоединился к каналу
|
||||
was_not_member = old_status in ['left', 'kicked']
|
||||
is_now_member = new_status in ['member', 'administrator', 'creator']
|
||||
user_joined = was_not_member and is_now_member
|
||||
|
||||
# Проверяем что пользователь покинул канал
|
||||
was_member = old_status in ['member', 'administrator', 'creator', 'restricted']
|
||||
is_now_not_member = new_status in ['left', 'kicked']
|
||||
user_left = was_member and is_now_not_member
|
||||
|
||||
if user_joined:
|
||||
invite_link_obj = event.get('invite_link', {})
|
||||
invite_link = invite_link_obj.get('invite_link') if invite_link_obj else None
|
||||
if not invite_link:
|
||||
log.debug('No invite_link in chat_member_updated event (user joined)')
|
||||
return
|
||||
|
||||
await deps.get_usecase().handle_subscription(
|
||||
user_telegram_id=from_user['id'],
|
||||
username=from_user.get('username'),
|
||||
invite_link=invite_link,
|
||||
first_name=from_user.get('first_name'),
|
||||
last_name=from_user.get('last_name'),
|
||||
)
|
||||
|
||||
elif user_left:
|
||||
await deps.get_usecase().handle_unsubscription(
|
||||
user_telegram_id=from_user['id'],
|
||||
channel_telegram_id=chat['id'],
|
||||
)
|
||||
|
||||
|
||||
async def _handle_my_chat_member(event: dict) -> None:
|
||||
"""Обработка добавления/удаления бота из канала"""
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
chat = event.get('chat', {})
|
||||
if chat.get('type') not in ['channel', 'supergroup']:
|
||||
return
|
||||
|
||||
from_user = event.get('from')
|
||||
if not from_user:
|
||||
log.error('No user in my_chat_member event')
|
||||
return
|
||||
|
||||
old_member = event.get('old_chat_member', {})
|
||||
new_member = event.get('new_chat_member', {})
|
||||
|
||||
old_status = old_member.get('status')
|
||||
new_status = new_member.get('status')
|
||||
|
||||
# Проверяем что бот был удален из канала
|
||||
was_member = old_status in ['administrator', 'member']
|
||||
is_now_not_member = new_status in ['left', 'kicked']
|
||||
bot_removed = was_member and is_now_not_member
|
||||
|
||||
if bot_removed:
|
||||
# Отключаем проект
|
||||
disconnect_input = dto.DisconnectProjectByTgIdInput(
|
||||
telegram_id=chat['id'],
|
||||
user_telegram_id=from_user['id'],
|
||||
)
|
||||
try:
|
||||
await deps.get_usecase().disconnect_project_by_tg_id(input=disconnect_input)
|
||||
log.info('Bot removed from channel %s by user %s', chat['id'], from_user['id'])
|
||||
except Exception as e:
|
||||
log.error('Failed to disconnect project: %s', e)
|
||||
return
|
||||
|
||||
# Проверяем изменение прав администратора
|
||||
permissions_changed = old_status == 'administrator' and new_status == 'administrator'
|
||||
|
||||
# Извлекаем права бота
|
||||
bot_permissions = dto.ChannelBotPermissions(
|
||||
is_admin=(new_status == 'administrator'),
|
||||
can_invite_users=new_member.get('can_invite_users', False),
|
||||
can_restrict_members=new_member.get('can_restrict_members', False),
|
||||
)
|
||||
|
||||
if permissions_changed:
|
||||
# Обновляем права бота
|
||||
permissions_input = dto.UpdateProjectPermissionsInput(
|
||||
telegram_id=chat['id'],
|
||||
permissions=bot_permissions,
|
||||
chat_title=chat.get('title') or f'Channel {chat["id"]}',
|
||||
user_telegram_id=from_user['id'],
|
||||
)
|
||||
try:
|
||||
await deps.get_usecase().update_project_permissions(input=permissions_input)
|
||||
log.info('Bot permissions updated in channel %s', chat['id'])
|
||||
except Exception as e:
|
||||
log.error('Failed to update permissions: %s', e)
|
||||
return
|
||||
|
||||
# Проверяем что бот был добавлен в канал
|
||||
was_not_member = old_status in ['left', 'kicked']
|
||||
is_now_member = new_status in ['administrator', 'member']
|
||||
bot_added = was_not_member and is_now_member
|
||||
|
||||
if bot_added:
|
||||
# Добавляем канал как проект
|
||||
connect_input = dto.ConnectProjectInput(
|
||||
telegram_id=chat['id'],
|
||||
title=chat.get('title') or f'Channel {chat["id"]}',
|
||||
username=chat.get('username'),
|
||||
user_telegram_id=from_user['id'],
|
||||
bot_permissions=bot_permissions,
|
||||
)
|
||||
try:
|
||||
await deps.get_usecase().tg_add_project(input=connect_input)
|
||||
log.info('Bot added to channel %s by user %s', chat['id'], from_user['id'])
|
||||
except Exception as e:
|
||||
log.error('Failed to add project: %s', e)
|
||||
49
src/controller/http_v1/projects.py
Normal file
49
src/controller/http_v1/projects.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.routing import APIRouter
|
||||
from fastapi_pagination import Page, paginate
|
||||
|
||||
from src import deps, dto
|
||||
from src.adapter.jwt import JWTPayload
|
||||
|
||||
projects_router = APIRouter(prefix='/workspaces/{workspace_id}/projects', tags=['projects'])
|
||||
|
||||
|
||||
@projects_router.get('')
|
||||
async def get_workspace_projects(
|
||||
workspace_id: uuid.UUID,
|
||||
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||
) -> Page[dto.ProjectOutput]:
|
||||
input = dto.GetWorkspaceProjectsInput(
|
||||
user_id=current_user.user_id,
|
||||
workspace_id=workspace_id,
|
||||
)
|
||||
|
||||
result = await deps.get_usecase().get_workspace_projects(input=input)
|
||||
return paginate(result) # type: ignore[no-any-return]
|
||||
|
||||
|
||||
@projects_router.patch('/{project_id}/invite-link-type')
|
||||
async def update_project_invite_link_type(
|
||||
workspace_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
input: dto.UpdateProjectInviteLinkTypeInput,
|
||||
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||
) -> dto.ProjectOutput:
|
||||
project = await deps.get_usecase().update_project_invite_link_type(
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
invite_link_type=input.invite_link_type,
|
||||
user_id=current_user.user_id,
|
||||
)
|
||||
|
||||
return dto.ProjectOutput(
|
||||
id=project.id,
|
||||
telegram_id=project.channel.telegram_id,
|
||||
title=project.channel.title,
|
||||
username=project.channel.username,
|
||||
status=project.status,
|
||||
invite_link_type=project.invite_link_type,
|
||||
)
|
||||
@@ -34,3 +34,18 @@ async def create_workspace_invite(
|
||||
user_id=current_user.user_id,
|
||||
input=request,
|
||||
)
|
||||
|
||||
|
||||
# Глобальный роутер для invite endpoints (без workspace_id в пути)
|
||||
workspace_invites_global_router = APIRouter(prefix='/invites', tags=['workspace invites'])
|
||||
|
||||
|
||||
@workspace_invites_global_router.post('/{invite_id}/accept')
|
||||
async def accept_workspace_invite(
|
||||
invite_id: uuid.UUID,
|
||||
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||
) -> dto.WorkspaceInviteOutput:
|
||||
return await deps.get_usecase().accept_workspace_invite(
|
||||
invite_id=invite_id,
|
||||
user_id=current_user.user_id,
|
||||
)
|
||||
@@ -1,14 +0,0 @@
|
||||
from aiogram import Router
|
||||
|
||||
telegram_callback_router = Router()
|
||||
|
||||
# Import handlers to register them
|
||||
from . import ( # noqa: E402, F401
|
||||
chat_join_request,
|
||||
chat_member_updated,
|
||||
creative_commands,
|
||||
my_chat_member,
|
||||
project_commands,
|
||||
start_with_login,
|
||||
workspace_invites,
|
||||
)
|
||||
@@ -1,29 +0,0 @@
|
||||
import logging
|
||||
|
||||
from aiogram.types import ChatJoinRequest
|
||||
|
||||
from src import deps
|
||||
from src.controller.telegram_callback import telegram_callback_router
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@telegram_callback_router.chat_join_request()
|
||||
async def on_chat_join_request(event: ChatJoinRequest) -> None:
|
||||
if event.chat.type not in {'channel', 'supergroup'}:
|
||||
return
|
||||
|
||||
if not event.from_user:
|
||||
log.error('Failed to get user data from chat join request')
|
||||
return
|
||||
|
||||
invite_link = event.invite_link
|
||||
if not invite_link or not invite_link.invite_link:
|
||||
log.debug('No invite_link in chat join request event')
|
||||
return
|
||||
|
||||
await deps.get_usecase().handle_subscription(
|
||||
user_telegram_id=event.from_user.id,
|
||||
username=event.from_user.username,
|
||||
invite_link=invite_link.invite_link,
|
||||
)
|
||||
@@ -1,46 +0,0 @@
|
||||
import logging
|
||||
|
||||
from aiogram.types import ChatMemberUpdated
|
||||
|
||||
from src import deps
|
||||
from src.controller.telegram_callback import telegram_callback_router
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@telegram_callback_router.chat_member()
|
||||
async def on_chat_member_updated(event: ChatMemberUpdated) -> None:
|
||||
if event.chat.type not in {'channel', 'supergroup'}:
|
||||
return
|
||||
|
||||
if not event.from_user:
|
||||
log.error('Failed to get user data from chat member update')
|
||||
return
|
||||
|
||||
old_status = event.old_chat_member.status
|
||||
new_status = event.new_chat_member.status
|
||||
|
||||
# Проверяем что пользователь присоединился к каналу
|
||||
was_not_member = old_status in ('left', 'kicked')
|
||||
is_now_member = new_status in ('member', 'administrator', 'creator')
|
||||
user_joined = was_not_member and is_now_member
|
||||
|
||||
# Проверяем что пользователь покинул канал
|
||||
was_member = old_status in ('member', 'administrator', 'creator', 'restricted')
|
||||
is_now_not_member = new_status in ('left', 'kicked')
|
||||
user_left = was_member and is_now_not_member
|
||||
|
||||
if user_joined:
|
||||
invite_link = event.invite_link.invite_link if event.invite_link else None
|
||||
if not invite_link:
|
||||
log.debug('No invite_link in chat member update event')
|
||||
return
|
||||
|
||||
await deps.get_usecase().handle_subscription(
|
||||
user_telegram_id=event.from_user.id, username=event.from_user.username, invite_link=invite_link
|
||||
)
|
||||
|
||||
elif user_left:
|
||||
await deps.get_usecase().handle_unsubscription(
|
||||
user_telegram_id=event.from_user.id, channel_telegram_id=event.chat.id
|
||||
)
|
||||
@@ -1,68 +0,0 @@
|
||||
import logging
|
||||
|
||||
from aiogram import F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
|
||||
from src import deps
|
||||
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 = deps.get_usecase()
|
||||
await usecase.tg_create_creative_1(telegram_id=message.from_user.id, chat_id=message.chat.id)
|
||||
|
||||
|
||||
@telegram_callback_router.callback_query(F.data.startswith('tg_create_creative_2:'))
|
||||
async def callback_creative_workspace(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 = deps.get_usecase()
|
||||
await usecase.tg_create_creative_2(
|
||||
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.callback_query(F.data.startswith('tg_create_creative_3:'))
|
||||
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 = deps.get_usecase()
|
||||
await usecase.tg_create_creative_3(
|
||||
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 = deps.get_usecase()
|
||||
await usecase.tg_create_creative_4(
|
||||
telegram_id=message.from_user.id,
|
||||
chat_id=message.chat.id,
|
||||
text=message.text,
|
||||
text_html=message.html_text,
|
||||
)
|
||||
@@ -1,84 +0,0 @@
|
||||
import logging
|
||||
|
||||
from aiogram.types import ChatMemberAdministrator, ChatMemberUpdated
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src import deps, dto
|
||||
from src.controller.telegram_callback import telegram_callback_router
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@telegram_callback_router.my_chat_member()
|
||||
async def on_bot_added_to_chat(event: ChatMemberUpdated) -> None:
|
||||
if event.chat.type not in {'channel', 'supergroup'}:
|
||||
return
|
||||
|
||||
if not event.from_user:
|
||||
log.error('Failed to get user data from chat member update')
|
||||
return
|
||||
|
||||
usecase = deps.get_usecase()
|
||||
|
||||
old_status = event.old_chat_member.status
|
||||
new_status = event.new_chat_member.status
|
||||
|
||||
# Проверяем что бот был удален
|
||||
was_member = old_status in ('administrator', 'member')
|
||||
is_now_not_member = new_status in ('left', 'kicked')
|
||||
bot_removed = was_member and is_now_not_member
|
||||
|
||||
if bot_removed:
|
||||
disconnect_input = dto.DisconnectProjectByTgIdInput(
|
||||
telegram_id=event.chat.id,
|
||||
user_telegram_id=event.from_user.id,
|
||||
)
|
||||
try:
|
||||
await usecase.disconnect_project_by_tg_id(input=disconnect_input)
|
||||
except HTTPException as e:
|
||||
log.error(e)
|
||||
|
||||
return
|
||||
|
||||
# Проверяем изменение прав администратора
|
||||
permissions_changed = old_status == 'administrator' and new_status == 'administrator'
|
||||
|
||||
# Извлекаем права бота из события
|
||||
new_member = event.new_chat_member
|
||||
|
||||
bot_permissions = dto.ChannelBotPermissions(
|
||||
is_admin=isinstance(new_member, ChatMemberAdministrator),
|
||||
can_invite_users=getattr(new_member, 'can_invite_users', False),
|
||||
can_restrict_members=getattr(new_member, 'can_restrict_members', False),
|
||||
)
|
||||
|
||||
if permissions_changed:
|
||||
permissions_input = dto.UpdateProjectPermissionsInput(
|
||||
telegram_id=event.chat.id,
|
||||
permissions=bot_permissions,
|
||||
chat_title=event.chat.title or f'Channel {event.chat.id}',
|
||||
user_telegram_id=event.from_user.id,
|
||||
)
|
||||
try:
|
||||
await usecase.update_project_permissions(input=permissions_input)
|
||||
except HTTPException as e:
|
||||
log.error(e)
|
||||
return
|
||||
|
||||
# Проверяем что бот был добавлен
|
||||
was_not_member = old_status in ('left', 'kicked')
|
||||
is_now_member = new_status in ('administrator', 'member')
|
||||
bot_added = was_not_member and is_now_member
|
||||
|
||||
if bot_added:
|
||||
connect_input = dto.ConnectProjectInput(
|
||||
telegram_id=event.chat.id,
|
||||
title=event.chat.title or f'Channel {event.chat.id}',
|
||||
username=event.chat.username,
|
||||
user_telegram_id=event.from_user.id,
|
||||
bot_permissions=bot_permissions,
|
||||
)
|
||||
try:
|
||||
await usecase.tg_add_project_1(input=connect_input)
|
||||
except HTTPException as e:
|
||||
log.error(e)
|
||||
@@ -1,26 +0,0 @@
|
||||
import logging
|
||||
|
||||
from aiogram import F
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
from src import deps
|
||||
from src.controller.telegram_callback import telegram_callback_router
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@telegram_callback_router.callback_query(F.data.startswith('tg_add_project_2:'))
|
||||
async def callback_project_workspace(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 = deps.get_usecase()
|
||||
await usecase.tg_add_project_2(
|
||||
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()
|
||||
@@ -1,41 +0,0 @@
|
||||
import logging
|
||||
|
||||
from aiogram.filters import CommandStart
|
||||
from aiogram.types import Message
|
||||
|
||||
from src import deps, dto
|
||||
from src.controller.telegram_callback import telegram_callback_router
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@telegram_callback_router.message(CommandStart(deep_link=True))
|
||||
async def cmd_start_with_deeplink(message: Message) -> None:
|
||||
if not message.from_user:
|
||||
log.error('Failed to get user data from tg_message')
|
||||
return
|
||||
|
||||
args = message.text.split() if message.text else []
|
||||
deeplink_param = args[1].split('_')[0] if len(args) > 1 else None
|
||||
|
||||
if deeplink_param != 'login':
|
||||
return
|
||||
|
||||
input = dto.TelegramLoginInput(
|
||||
telegram_id=message.from_user.id,
|
||||
username=message.from_user.username,
|
||||
chat_id=message.chat.id,
|
||||
)
|
||||
|
||||
await deps.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 = deps.get_usecase()
|
||||
await usecase.telegram_start(telegram_id=message.from_user.id, chat_id=message.chat.id)
|
||||
@@ -1,27 +0,0 @@
|
||||
import logging
|
||||
|
||||
from aiogram import F
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
from src import deps
|
||||
from src.controller.telegram_callback import telegram_callback_router
|
||||
from src.usecase.workspace.create_workspace_invite import INVITE_ACCEPT_CALLBACK_PREFIX
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@telegram_callback_router.callback_query(F.data.startswith(f'{INVITE_ACCEPT_CALLBACK_PREFIX}:'))
|
||||
async def callback_workspace_invite_accept(callback: CallbackQuery) -> None:
|
||||
if not callback.from_user or not callback.message or not callback.data:
|
||||
log.error('Incomplete callback data for workspace invite acceptance')
|
||||
return
|
||||
|
||||
usecase = deps.get_usecase()
|
||||
await usecase.tg_accept_workspace_invite(
|
||||
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()
|
||||
@@ -86,10 +86,10 @@ from .error import (
|
||||
WorkspaceNotFound,
|
||||
)
|
||||
from .login_token import LoginToken
|
||||
from .placement import InviteLinkType, Placement, PlacementStatus, PostViewsAvailability
|
||||
from .placement import Placement, PlacementStatus, PostViewsAvailability
|
||||
from .post import Post
|
||||
from .post_views_history import PostViewsHistory
|
||||
from .project import Project, ProjectStatus
|
||||
from .project import InviteLinkType, Project, ProjectStatus
|
||||
from .purchase_plan import (
|
||||
PurchasePlan,
|
||||
PurchasePlanChannel,
|
||||
|
||||
@@ -114,5 +114,9 @@ def WorkspaceInviteAlreadyExists() -> HTTPException:
|
||||
return HTTPException(status.HTTP_409_CONFLICT, 'Workspace invite already exists for this user')
|
||||
|
||||
|
||||
def WorkspaceInviteAlreadyProcessed() -> HTTPException:
|
||||
return HTTPException(status.HTTP_400_BAD_REQUEST, 'Workspace invite has already been processed')
|
||||
|
||||
|
||||
def WorkspaceMemberAlreadyExists() -> HTTPException:
|
||||
return HTTPException(status.HTTP_409_CONFLICT, 'User is already a workspace member')
|
||||
|
||||
@@ -19,11 +19,6 @@ class PlacementStatus(str, enum.Enum):
|
||||
ARCHIVED = 'archived' # Вручную архивировано
|
||||
|
||||
|
||||
class InviteLinkType(str, enum.Enum):
|
||||
PUBLIC = 'public' # открытая ссылка
|
||||
APPROVAL = 'approval' # с одобрением ботом
|
||||
|
||||
|
||||
class PostViewsAvailability(str, enum.Enum):
|
||||
UNKNOWN = 'unknown' # Ещё не проверялось
|
||||
AVAILABLE = 'available' # Просмотры доступны
|
||||
@@ -35,7 +30,6 @@ class Placement(TimestampedModel):
|
||||
placement_date = fields.DatetimeField()
|
||||
cost = fields.FloatField(null=True)
|
||||
comment = fields.TextField(null=True)
|
||||
invite_link_type = fields.CharEnumField(InviteLinkType)
|
||||
invite_link = fields.CharField(max_length=512)
|
||||
|
||||
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE)
|
||||
|
||||
@@ -17,8 +17,14 @@ class ProjectStatus(str, enum.Enum):
|
||||
ARCHIVED = 'archived'
|
||||
|
||||
|
||||
class InviteLinkType(str, enum.Enum):
|
||||
PUBLIC = 'public' # открытая ссылка
|
||||
APPROVAL = 'approval' # с одобрением ботом
|
||||
|
||||
|
||||
class Project(TimestampedModel):
|
||||
status = fields.CharEnumField(ProjectStatus, default=ProjectStatus.ACTIVE)
|
||||
invite_link_type = fields.CharEnumField(InviteLinkType, default=InviteLinkType.APPROVAL, max_length=10)
|
||||
|
||||
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
|
||||
'models.Workspace', related_name='projects', on_delete=fields.CASCADE, index=True
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
__all__ = (
|
||||
'UpdateProjectInviteLinkTypeInput',
|
||||
'UpdateProjectPermissionsInput',
|
||||
'GetWorkspaceProjectsInput',
|
||||
'GetWorkspaceProjectsOutput',
|
||||
'GetProjectInput',
|
||||
'DisconnectProjectByTgIdInput',
|
||||
'ConnectProjectInput',
|
||||
'ProjectOutput',
|
||||
@@ -10,8 +12,10 @@ __all__ = (
|
||||
'TelegramLoginInput',
|
||||
'ChannelBotPermissions',
|
||||
'ChannelOutput',
|
||||
'GetChannelInput',
|
||||
'GetChannelsInput',
|
||||
'GetChannelsOutput',
|
||||
'AttachChannelToWorkspaceInput',
|
||||
'PurchasePlanChannelOutput',
|
||||
'GetPurchasePlanChannelsInput',
|
||||
'GetPurchasePlanChannelsOutput',
|
||||
@@ -67,6 +71,8 @@ __all__ = (
|
||||
'GetWorkspaceInvitesOutput',
|
||||
)
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .analytics import (
|
||||
ChannelAnalyticsOutput,
|
||||
CreativeAnalyticsOutput,
|
||||
@@ -82,7 +88,13 @@ from .analytics import (
|
||||
PlacementAnalyticsOutput,
|
||||
SpendingDataPoint,
|
||||
)
|
||||
from .channel import ChannelOutput, GetChannelsInput, GetChannelsOutput
|
||||
from .channel import (
|
||||
AttachChannelToWorkspaceInput,
|
||||
ChannelOutput,
|
||||
GetChannelInput,
|
||||
GetChannelsInput,
|
||||
GetChannelsOutput,
|
||||
)
|
||||
from .creative import (
|
||||
CreateCreativeInput,
|
||||
CreativeOutput,
|
||||
@@ -105,9 +117,11 @@ from .project import (
|
||||
ChannelBotPermissions,
|
||||
ConnectProjectInput,
|
||||
DisconnectProjectByTgIdInput,
|
||||
GetProjectInput,
|
||||
GetWorkspaceProjectsInput,
|
||||
GetWorkspaceProjectsOutput,
|
||||
ProjectOutput,
|
||||
UpdateProjectInviteLinkTypeInput,
|
||||
UpdateProjectPermissionsInput,
|
||||
)
|
||||
from .purchase_plan import (
|
||||
@@ -144,3 +158,8 @@ from .workspace import (
|
||||
WorkspacePermissionScopeInput,
|
||||
WorkspacePermissionScopeOutput,
|
||||
)
|
||||
|
||||
|
||||
class CreateLoginTokenRequest(BaseModel):
|
||||
telegram_id: int
|
||||
username: str | None
|
||||
@@ -16,3 +16,13 @@ class GetChannelsInput(pydantic.BaseModel):
|
||||
|
||||
class GetChannelsOutput(pydantic.BaseModel):
|
||||
channels: list[ChannelOutput]
|
||||
|
||||
|
||||
class GetChannelInput(pydantic.BaseModel):
|
||||
channel_id: uuid.UUID
|
||||
|
||||
|
||||
class AttachChannelToWorkspaceInput(pydantic.BaseModel):
|
||||
channel_id: uuid.UUID
|
||||
workspace_id: uuid.UUID
|
||||
user_telegram_id: int
|
||||
|
||||
6
src/dto/internal.py
Normal file
6
src/dto/internal.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CreateLoginTokenRequest(BaseModel):
|
||||
telegram_id: int
|
||||
username: str | None = None
|
||||
@@ -51,7 +51,6 @@ class CreatePlacementInput(pydantic.BaseModel):
|
||||
placement_date: datetime.datetime
|
||||
cost: float | None = None
|
||||
comment: str | None = None
|
||||
invite_link_type: domain.InviteLinkType = domain.InviteLinkType.PUBLIC
|
||||
|
||||
|
||||
class UpdatePlacementInput(pydantic.BaseModel):
|
||||
|
||||
@@ -2,6 +2,7 @@ import uuid
|
||||
|
||||
import pydantic
|
||||
|
||||
from src import domain
|
||||
from src.domain.project import ProjectStatus
|
||||
|
||||
|
||||
@@ -31,6 +32,11 @@ class ProjectOutput(pydantic.BaseModel):
|
||||
title: str
|
||||
username: str | None
|
||||
status: ProjectStatus
|
||||
invite_link_type: domain.InviteLinkType
|
||||
|
||||
|
||||
class UpdateProjectInviteLinkTypeInput(pydantic.BaseModel):
|
||||
invite_link_type: domain.InviteLinkType
|
||||
|
||||
|
||||
class GetWorkspaceProjectsInput(pydantic.BaseModel):
|
||||
@@ -52,3 +58,8 @@ class UpdateProjectPermissionsInput(pydantic.BaseModel):
|
||||
permissions: ChannelBotPermissions
|
||||
chat_title: str
|
||||
user_telegram_id: int
|
||||
|
||||
|
||||
class GetProjectInput(pydantic.BaseModel):
|
||||
workspace_id: uuid.UUID
|
||||
project_id: uuid.UUID
|
||||
|
||||
12
src/main.py
12
src/main.py
@@ -4,6 +4,7 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi_pagination import add_pagination
|
||||
from fastapi_pagination.utils import disable_installed_extensions_check
|
||||
|
||||
from shared import logger
|
||||
from shared.worker_base import WorkerConfig
|
||||
@@ -13,30 +14,27 @@ from src.adapter.parser import ParserClient
|
||||
from src.adapter.postgres import Postgres
|
||||
from src.adapter.telegram_bot import TelegramBot
|
||||
from src.config import settings
|
||||
from src.controller.http import api_router
|
||||
from src.controller.telegram_callback import telegram_callback_router
|
||||
from src.controller.http_v1 import api_router
|
||||
from src.controller.worker.fetch_placement_post import FetchPlacementPostWorker
|
||||
from src.usecase import Usecase
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
|
||||
async def lifespan(_: FastAPI) -> AsyncGenerator[None]:
|
||||
await postgres.connect()
|
||||
await fetch_placement_post_worker.start()
|
||||
await telegram.start_polling()
|
||||
await telegram.set_commands()
|
||||
|
||||
yield
|
||||
|
||||
await telegram.stop_polling()
|
||||
await fetch_placement_post_worker.stop()
|
||||
await postgres.close()
|
||||
|
||||
|
||||
logger.init(settings.logger)
|
||||
disable_installed_extensions_check()
|
||||
|
||||
postgres = Postgres(settings.db)
|
||||
telegram = TelegramBot(settings.telegram, telegram_callback_router)
|
||||
telegram = TelegramBot(settings.telegram)
|
||||
jwt_encoder = JWT(settings.jwt)
|
||||
parser_client = ParserClient(base_url=settings.parser.URL)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import typing
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
|
||||
@@ -12,19 +13,17 @@ from .analytics.get_channel_analytics import get_channel_analytics
|
||||
from .analytics.get_creatives_analytics import get_creatives_analytics
|
||||
from .analytics.get_placements_analytics import get_placements_analytics
|
||||
from .analytics.get_spending_analytics import get_spending_analytics
|
||||
from .auth.get_jwt_by_telegram_id import get_jwt_by_telegram_id
|
||||
from .auth.create_telegram_login_token import create_telegram_login_token
|
||||
from .auth.get_me import get_me
|
||||
from .auth.telegram_login import telegram_login
|
||||
from .auth.telegram_start import telegram_start
|
||||
from .auth.validate_login_token import validate_login_token
|
||||
from .channel.attach_channel_to_workspace import attach_channel_to_workspace
|
||||
from .channel.get_channel import get_channel
|
||||
from .channel.get_channels import get_channels
|
||||
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.tg_create_creative_1 import tg_create_creative_1
|
||||
from .creative.tg_create_creative_2 import tg_create_creative_2
|
||||
from .creative.tg_create_creative_3 import tg_create_creative_3
|
||||
from .creative.tg_create_creative_4 import tg_create_creative_4
|
||||
from .creative.update_creative import update_creative
|
||||
from .placement.create_placement import create_placement
|
||||
from .placement.delete_placement import delete_placement
|
||||
@@ -33,9 +32,10 @@ from .placement.get_placement import get_placement
|
||||
from .placement.get_placements import get_placements
|
||||
from .placement.update_placement import update_placement
|
||||
from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id
|
||||
from .project.get_project import get_project
|
||||
from .project.get_workspace_projects import get_workspace_projects
|
||||
from .project.tg_add_project_1 import tg_add_project_1
|
||||
from .project.tg_add_project_2 import tg_add_project_2
|
||||
from .project.tg_add_project import tg_add_project
|
||||
from .project.update_project_invite_link_type import update_project_invite_link_type
|
||||
from .project.update_project_permissions import update_project_permissions
|
||||
from .purchase_plan.attach_channel_to_purchase_plan import attach_channel_to_purchase_plan
|
||||
from .purchase_plan.get_purchase_plan_channels import get_purchase_plan_channels
|
||||
@@ -44,6 +44,7 @@ from .purchase_plan.update_purchase_plan_channel import update_purchase_plan_cha
|
||||
from .subscription.handle_subscription import handle_subscription
|
||||
from .subscription.handle_unsubscription import handle_unsubscription
|
||||
from .views.get_views_history import get_views_history
|
||||
from .workspace.accept_workspace_invite import accept_workspace_invite
|
||||
from .workspace.create_workspace import create_workspace
|
||||
from .workspace.create_workspace_invite import create_workspace_invite
|
||||
from .workspace.delete_workspace import delete_workspace
|
||||
@@ -371,15 +372,18 @@ class Usecase:
|
||||
return plan
|
||||
|
||||
validate_login_token = validate_login_token
|
||||
telegram_login = telegram_login
|
||||
telegram_start = telegram_start
|
||||
create_telegram_login_token = create_telegram_login_token
|
||||
get_jwt_by_telegram_id = get_jwt_by_telegram_id
|
||||
get_me = get_me
|
||||
tg_add_project_1 = tg_add_project_1
|
||||
tg_add_project_2 = tg_add_project_2
|
||||
tg_add_project = tg_add_project
|
||||
get_workspace_projects = get_workspace_projects
|
||||
get_project = get_project
|
||||
disconnect_project_by_tg_id = disconnect_project_by_tg_id
|
||||
update_project_permissions = update_project_permissions
|
||||
update_project_invite_link_type = update_project_invite_link_type
|
||||
get_channels = get_channels
|
||||
get_channel = get_channel
|
||||
attach_channel_to_workspace = attach_channel_to_workspace
|
||||
attach_channel_to_purchase_plan = attach_channel_to_purchase_plan
|
||||
get_purchase_plan_channels = get_purchase_plan_channels
|
||||
update_purchase_plan_channel = update_purchase_plan_channel
|
||||
@@ -389,10 +393,6 @@ class Usecase:
|
||||
create_creative = create_creative
|
||||
update_creative = update_creative
|
||||
delete_creative = delete_creative
|
||||
tg_create_creative_1 = tg_create_creative_1
|
||||
tg_create_creative_2 = tg_create_creative_2
|
||||
tg_create_creative_3 = tg_create_creative_3
|
||||
tg_create_creative_4 = tg_create_creative_4
|
||||
get_placements = get_placements
|
||||
get_placement = get_placement
|
||||
create_placement = create_placement
|
||||
@@ -414,4 +414,5 @@ class Usecase:
|
||||
update_workspace_member_permissions = update_workspace_member_permissions
|
||||
create_workspace_invite = create_workspace_invite
|
||||
get_workspace_invites = get_workspace_invites
|
||||
accept_workspace_invite = accept_workspace_invite
|
||||
tg_accept_workspace_invite = tg_accept_workspace_invite
|
||||
|
||||
32
src/usecase/auth/create_telegram_login_token.py
Normal file
32
src/usecase/auth/create_telegram_login_token.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import datetime
|
||||
import secrets
|
||||
import typing
|
||||
|
||||
from tortoise import timezone
|
||||
|
||||
from src import domain
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
|
||||
async def create_telegram_login_token(self: 'Usecase', telegram_id: int, username: str | None) -> str:
|
||||
user = await self.database.get_user(telegram_id=telegram_id)
|
||||
if not user:
|
||||
user = domain.User(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
)
|
||||
await self.database.create_user(user)
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires_at = timezone.now() + datetime.timedelta(minutes=10)
|
||||
|
||||
login_token = domain.LoginToken(
|
||||
token=token,
|
||||
user=user,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
await self.database.create_login_token(login_token)
|
||||
|
||||
return token
|
||||
23
src/usecase/auth/get_jwt_by_telegram_id.py
Normal file
23
src/usecase/auth/get_jwt_by_telegram_id.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import typing
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
|
||||
async def get_jwt_by_telegram_id(self: 'Usecase', telegram_id: int) -> dto.ValidateLoginTokenOutput:
|
||||
user = await self.database.get_user(telegram_id=telegram_id)
|
||||
|
||||
if not user:
|
||||
raise domain.UserNotFound()
|
||||
|
||||
access_token = self.jwt_encoder.encode_access_token(
|
||||
user_id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
)
|
||||
|
||||
return dto.ValidateLoginTokenOutput(
|
||||
access_token=access_token,
|
||||
)
|
||||
@@ -1,40 +0,0 @@
|
||||
import datetime
|
||||
import secrets
|
||||
import typing
|
||||
|
||||
from tortoise import timezone
|
||||
|
||||
from src import domain, dto
|
||||
from src.config import settings
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
|
||||
async def telegram_login(self: 'Usecase', input: dto.TelegramLoginInput) -> None:
|
||||
user = await self.database.get_user(telegram_id=input.telegram_id)
|
||||
if not user:
|
||||
user = domain.User(
|
||||
telegram_id=input.telegram_id,
|
||||
username=input.username,
|
||||
)
|
||||
await self.database.create_user(user)
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires_at = timezone.now() + datetime.timedelta(minutes=10)
|
||||
|
||||
login_token = domain.LoginToken(
|
||||
token=token,
|
||||
user=user,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
await self.database.create_login_token(login_token)
|
||||
|
||||
login_url = f'{settings.app.URL}{login_token.token}'
|
||||
|
||||
await self.telegram_bot.send_message_with_button(
|
||||
f'Привет, {user.username or "аноним"}!\n\nНажми кнопку ниже для входа на сайт.',
|
||||
input.chat_id,
|
||||
button_text='Войти на сайт',
|
||||
button_url=login_url,
|
||||
)
|
||||
@@ -1,19 +0,0 @@
|
||||
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:
|
||||
user = await self.database.get_user(telegram_id=telegram_id)
|
||||
|
||||
if not user:
|
||||
message = 'Привет! 👋\n\nДля работы с ботом необходимо авторизоваться через веб-интерфейс.'
|
||||
await self.telegram_bot.send_message(message, chat_id)
|
||||
return
|
||||
|
||||
message = f'Привет, {user.username or "аноним"}! 👋\n\nДоступные команды:\n/new_creative - Создать новый креатив'
|
||||
await self.telegram_bot.send_message(message, chat_id)
|
||||
52
src/usecase/channel/attach_channel_to_workspace.py
Normal file
52
src/usecase/channel/attach_channel_to_workspace.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def attach_channel_to_workspace(
|
||||
self: 'Usecase', input: dto.AttachChannelToWorkspaceInput
|
||||
) -> dto.ProjectOutput:
|
||||
"""Привязать канал к workspace (вызывается из Golang бота после выбора workspace пользователем)"""
|
||||
|
||||
channel = await self.database.get_channel(channel_id=input.channel_id)
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail='Channel not found')
|
||||
|
||||
workspace = await self.database.get_workspace(input.workspace_id)
|
||||
if not workspace:
|
||||
raise HTTPException(status_code=404, detail='Workspace not found')
|
||||
|
||||
# Проверяем что проект еще не существует
|
||||
project = await self.database.get_project(workspace.id, channel_id=channel.id)
|
||||
if project:
|
||||
# Проект уже существует - просто активируем
|
||||
project.status = domain.ProjectStatus.ACTIVE
|
||||
await self.database.update_project(project)
|
||||
log.info('Project %s reactivated in workspace %s', project.id, workspace.id)
|
||||
else:
|
||||
# Создаем новый проект
|
||||
project = domain.Project(
|
||||
workspace_id=workspace.id,
|
||||
channel_id=channel.id,
|
||||
status=domain.ProjectStatus.ACTIVE,
|
||||
)
|
||||
await self.database.create_project(project)
|
||||
log.info('Project created for channel %s in workspace %s', channel.id, workspace.id)
|
||||
|
||||
await self.ensure_active_purchase_plan(project)
|
||||
|
||||
return dto.ProjectOutput(
|
||||
id=project.id,
|
||||
telegram_id=channel.telegram_id,
|
||||
title=channel.title,
|
||||
username=channel.username,
|
||||
status=project.status,
|
||||
)
|
||||
22
src/usecase/channel/get_channel.py
Normal file
22
src/usecase/channel/get_channel.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src import dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
|
||||
async def get_channel(self: 'Usecase', input: dto.GetChannelInput) -> dto.ChannelOutput:
|
||||
channel = await self.database.get_channel(channel_id=input.channel_id)
|
||||
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail='Channel not found')
|
||||
|
||||
return dto.ChannelOutput(
|
||||
id=channel.id,
|
||||
telegram_id=channel.telegram_id,
|
||||
title=channel.title,
|
||||
username=channel.username,
|
||||
)
|
||||
@@ -1,87 +0,0 @@
|
||||
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 tg_create_creative_1(self: 'Usecase', telegram_id: int, chat_id: int) -> None:
|
||||
user = await self.database.get_user(telegram_id=telegram_id)
|
||||
if not user:
|
||||
await self.telegram_bot.send_message('❌ Вы не авторизованы. Используйте /start для входа.', chat_id)
|
||||
return
|
||||
|
||||
memberships = await self.database.get_user_workspaces(user.id)
|
||||
if not memberships:
|
||||
await self.telegram_bot.send_message(
|
||||
'❌ У вас нет рабочих пространств. Создайте workspace в веб-панели, чтобы продолжить.', chat_id
|
||||
)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
if len(memberships) == 1:
|
||||
workspace: domain.Workspace | None = memberships[0].workspace
|
||||
if not workspace:
|
||||
workspace = await self.database.get_workspace(memberships[0].workspace_id)
|
||||
|
||||
if not workspace:
|
||||
log.error('Workspace %s not found for user %s', memberships[0].workspace_id, user.id)
|
||||
await self.telegram_bot.send_message('❌ Рабочее пространство не найдено. Попробуйте позже.', chat_id)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
projects = await self.database.get_workspace_projects(workspace.id)
|
||||
if not projects:
|
||||
await self.telegram_bot.send_message(
|
||||
'❌ В выбранном рабочем пространстве нет подключенных проектов. Добавьте канал и попробуйте снова.',
|
||||
chat_id,
|
||||
)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
buttons = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=project.channel.title or project.channel.username or 'Unnamed Channel',
|
||||
callback_data=f'tg_create_creative_3:{project.id}',
|
||||
)
|
||||
]
|
||||
for project in projects
|
||||
]
|
||||
|
||||
await self.telegram_bot.send_message_with_inline_keyboard(
|
||||
'✨ Создание нового креатива\n\nВыберите канал, для которого создается креатив:', chat_id, buttons
|
||||
)
|
||||
|
||||
await self.database.set_telegram_state(
|
||||
telegram_id,
|
||||
domain.TelegramStateEnum.CREATIVE_WAITING_CHANNEL,
|
||||
context={'workspace_id': str(workspace.id)},
|
||||
)
|
||||
return
|
||||
|
||||
buttons = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=membership.workspace.name if membership.workspace else 'Workspace',
|
||||
callback_data=f'tg_create_creative_2:{membership.workspace_id}',
|
||||
)
|
||||
]
|
||||
for membership in memberships
|
||||
]
|
||||
|
||||
await self.telegram_bot.send_message_with_inline_keyboard(
|
||||
'✨ Создание нового креатива\n\nВыберите рабочее пространство:', chat_id, buttons
|
||||
)
|
||||
|
||||
await self.database.set_telegram_state(
|
||||
telegram_id,
|
||||
domain.TelegramStateEnum.CREATIVE_WAITING_WORKSPACE,
|
||||
context={},
|
||||
)
|
||||
@@ -1,77 +0,0 @@
|
||||
import logging
|
||||
import uuid
|
||||
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 tg_create_creative_2(
|
||||
self: 'Usecase', telegram_id: int, chat_id: int, callback_data: str, message_id: int
|
||||
) -> None:
|
||||
state = await self.database.get_telegram_state(telegram_id)
|
||||
if not state or state.state != domain.TelegramStateEnum.CREATIVE_WAITING_WORKSPACE:
|
||||
log.debug('User %s has no active creative workspace selection flow', telegram_id)
|
||||
return
|
||||
|
||||
if not callback_data.startswith('tg_create_creative_2:'):
|
||||
log.warning('Invalid workspace callback data format: %s', callback_data)
|
||||
return
|
||||
|
||||
workspace_id_str = callback_data.split(':', 1)[1]
|
||||
try:
|
||||
workspace_id = uuid.UUID(workspace_id_str)
|
||||
except ValueError:
|
||||
log.error('Invalid workspace UUID in callback data: %s', workspace_id_str)
|
||||
await self.telegram_bot.send_message('❌ Некорректное рабочее пространство.', chat_id)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
user = await self.database.get_user(telegram_id=telegram_id)
|
||||
if not user:
|
||||
await self.telegram_bot.send_message('Вы не зарегистрированы на платформе.', chat_id)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
workspace = await self.database.get_workspace_for_user(workspace_id, user.id)
|
||||
if not workspace:
|
||||
await self.telegram_bot.send_message('❌ Рабочее пространство недоступно.', chat_id)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
await self.telegram_bot.edit_message_reply_markup(chat_id, message_id)
|
||||
|
||||
projects = await self.database.get_workspace_projects(workspace.id)
|
||||
if not projects:
|
||||
await self.telegram_bot.send_message(
|
||||
'❌ В выбранном рабочем пространстве нет подключенных проектов. Добавьте канал и попробуйте снова.',
|
||||
chat_id,
|
||||
)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
buttons = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=project.channel.title or project.channel.username or 'Unnamed Channel',
|
||||
callback_data=f'tg_create_creative_3:{project.id}',
|
||||
)
|
||||
]
|
||||
for project in projects
|
||||
]
|
||||
|
||||
await self.telegram_bot.send_message_with_inline_keyboard(
|
||||
'✨ Создание нового креатива\n\nВыберите канал, для которого создается креатив:', chat_id, buttons
|
||||
)
|
||||
|
||||
await self.database.set_telegram_state(
|
||||
telegram_id,
|
||||
domain.TelegramStateEnum.CREATIVE_WAITING_CHANNEL,
|
||||
context={'workspace_id': str(workspace.id)},
|
||||
)
|
||||
@@ -1,81 +0,0 @@
|
||||
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 tg_create_creative_3(
|
||||
self: 'Usecase', telegram_id: int, chat_id: int, callback_data: str, message_id: int
|
||||
) -> None:
|
||||
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('tg_create_creative_3:'):
|
||||
log.warning('Invalid callback data format: %s', callback_data)
|
||||
return
|
||||
|
||||
project_id_str = callback_data.split(':', 1)[1]
|
||||
try:
|
||||
project_id = uuid.UUID(project_id_str)
|
||||
except ValueError:
|
||||
log.error('Invalid project UUID in callback data: %s', project_id_str)
|
||||
return
|
||||
|
||||
user = await self.database.get_user(telegram_id=telegram_id)
|
||||
if not user:
|
||||
await self.telegram_bot.send_message('Вы не зарегистрированы на платформе.', chat_id)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
workspace = None
|
||||
workspace_id = None
|
||||
|
||||
workspace_id_str = state.context.get('workspace_id') if state.context else None
|
||||
if workspace_id_str:
|
||||
try:
|
||||
workspace_id = uuid.UUID(workspace_id_str)
|
||||
except ValueError:
|
||||
workspace_id = None
|
||||
|
||||
if workspace_id:
|
||||
workspace = await self.database.get_workspace_for_user(workspace_id, user.id)
|
||||
|
||||
if not workspace:
|
||||
workspace = await self.get_or_create_personal_workspace(user)
|
||||
workspace_id = workspace.id
|
||||
|
||||
if not workspace_id:
|
||||
log.error('Workspace ID missing for user %s when selecting project %s', telegram_id, project_id)
|
||||
await self.telegram_bot.send_message('❌ Ошибка: рабочее пространство не найдено. Начните заново.', chat_id)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
project = await self.database.get_project(workspace_id, project_id=project_id)
|
||||
if not project:
|
||||
await self.telegram_bot.send_message('❌ Канал не найден или не принадлежит вам.', chat_id)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
await self.database.set_telegram_state(
|
||||
telegram_id,
|
||||
domain.TelegramStateEnum.CREATIVE_WAITING_NAME,
|
||||
{
|
||||
'project_id': str(project_id),
|
||||
'channel_title': project.channel.title,
|
||||
'workspace_id': str(workspace_id),
|
||||
},
|
||||
)
|
||||
|
||||
await self.telegram_bot.edit_message_reply_markup(chat_id, message_id)
|
||||
|
||||
await self.telegram_bot.send_message(
|
||||
f'✅ Канал выбран: {project.channel.title}\n\n📝 Введите название креатива:', chat_id
|
||||
)
|
||||
@@ -1,112 +0,0 @@
|
||||
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 tg_create_creative_4(
|
||||
self: 'Usecase', telegram_id: int, chat_id: int, text: str, text_html: str | None = None
|
||||
) -> None:
|
||||
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)
|
||||
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, domain.TelegramStateEnum.CREATIVE_WAITING_TEXT, {**state.context, 'creative_name': text}
|
||||
)
|
||||
await self.telegram_bot.send_message(
|
||||
'✅ Название сохранено!\n\n'
|
||||
'💬 Теперь отправьте или перешлите текст креатива.\n'
|
||||
'Пригласительная ссылка на ваш канал будет автоматически '
|
||||
'заменена на уникальную ссылку для отслеживания.',
|
||||
chat_id,
|
||||
)
|
||||
|
||||
elif state.state == domain.TelegramStateEnum.CREATIVE_WAITING_TEXT:
|
||||
project_id_str = state.context.get('project_id')
|
||||
creative_name = state.context.get('creative_name')
|
||||
workspace_id_str = state.context.get('workspace_id')
|
||||
|
||||
if not project_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)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
try:
|
||||
project_id = uuid.UUID(project_id_str)
|
||||
except ValueError:
|
||||
log.error('Invalid project_id in context: %s', project_id_str)
|
||||
await self.telegram_bot.send_message('❌ Ошибка: неверный формат данных.', chat_id)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
workspace = None
|
||||
workspace_id = None
|
||||
|
||||
if workspace_id_str:
|
||||
try:
|
||||
workspace_id = uuid.UUID(workspace_id_str)
|
||||
except ValueError:
|
||||
workspace_id = None
|
||||
|
||||
if workspace_id:
|
||||
workspace = await self.database.get_workspace_for_user(workspace_id, user.id)
|
||||
|
||||
if not workspace:
|
||||
workspace = await self.get_or_create_personal_workspace(user)
|
||||
workspace_id = workspace.id
|
||||
|
||||
if not workspace_id:
|
||||
log.error('Workspace ID missing while finalizing creative for user %s', telegram_id)
|
||||
await self.telegram_bot.send_message('❌ Ошибка: рабочее пространство не найдено. Начните заново.', chat_id)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
project = await self.database.get_project(workspace_id, project_id=project_id)
|
||||
if not project:
|
||||
await self.telegram_bot.send_message('❌ Канал не найден.', chat_id)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
message_html = text_html or text
|
||||
creative_text = domain.replace_invite_link_with_tag(message_html)
|
||||
|
||||
creative = domain.Creative(
|
||||
name=creative_name,
|
||||
text=creative_text,
|
||||
status=domain.CreativeStatus.ACTIVE,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
async with self.database.transaction():
|
||||
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'📝 Название: {creative.name}\n'
|
||||
f'📢 Канал: {project.channel.title}\n\n'
|
||||
f'💬 Текст:\n{text_preview}',
|
||||
chat_id,
|
||||
)
|
||||
|
||||
else:
|
||||
log.warning('Unknown state: %s', state.state)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
@@ -34,7 +34,8 @@ async def create_placement(
|
||||
if creative.project_id != project.id:
|
||||
raise domain.CreativeNotFound(input.creative_id)
|
||||
|
||||
requires_approval = input.invite_link_type == domain.InviteLinkType.APPROVAL
|
||||
# Берем тип ссылки из настроек проекта
|
||||
requires_approval = project.invite_link_type == domain.InviteLinkType.APPROVAL
|
||||
|
||||
if project.channel.telegram_id is None:
|
||||
raise domain.ChannelNotFound(project.channel.id)
|
||||
@@ -49,7 +50,6 @@ async def create_placement(
|
||||
placement_date=input.placement_date,
|
||||
cost=input.cost,
|
||||
comment=input.comment,
|
||||
invite_link_type=input.invite_link_type,
|
||||
invite_link=invite_link,
|
||||
status=domain.PlacementStatus.ACTIVE,
|
||||
)
|
||||
@@ -68,7 +68,7 @@ async def create_placement(
|
||||
cost=placement.cost,
|
||||
comment=placement.comment,
|
||||
ad_post_url=None,
|
||||
invite_link_type=placement.invite_link_type,
|
||||
invite_link_type=project.invite_link_type,
|
||||
invite_link=placement.invite_link,
|
||||
status=placement.status,
|
||||
subscriptions_count=0,
|
||||
|
||||
@@ -37,7 +37,7 @@ async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.Pl
|
||||
cost=placement.cost,
|
||||
comment=placement.comment,
|
||||
ad_post_url=ad_post_url,
|
||||
invite_link_type=placement.invite_link_type,
|
||||
invite_link_type=placement.project.invite_link_type,
|
||||
invite_link=placement.invite_link,
|
||||
status=placement.status,
|
||||
subscriptions_count=subscriptions_count,
|
||||
|
||||
@@ -46,7 +46,7 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> list
|
||||
cost=placement.cost,
|
||||
comment=placement.comment,
|
||||
ad_post_url=ad_post_url,
|
||||
invite_link_type=placement.invite_link_type,
|
||||
invite_link_type=placement.project.invite_link_type,
|
||||
invite_link=placement.invite_link,
|
||||
status=placement.status,
|
||||
subscriptions_count=subscriptions_counts.get(placement.id, 0),
|
||||
|
||||
@@ -53,7 +53,7 @@ async def update_placement(
|
||||
cost=placement.cost,
|
||||
comment=placement.comment,
|
||||
ad_post_url=ad_post_url,
|
||||
invite_link_type=placement.invite_link_type,
|
||||
invite_link_type=placement.project.invite_link_type,
|
||||
invite_link=placement.invite_link,
|
||||
status=placement.status,
|
||||
subscriptions_count=subscriptions_count,
|
||||
|
||||
24
src/usecase/project/get_project.py
Normal file
24
src/usecase/project/get_project.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
|
||||
async def get_project(self: 'Usecase', input: dto.GetProjectInput) -> dto.ProjectOutput:
|
||||
project = await self.database.get_project(input.workspace_id, project_id=input.project_id)
|
||||
|
||||
if not project:
|
||||
raise domain.ProjectNotFound()
|
||||
|
||||
await project.fetch_related('channel')
|
||||
|
||||
return dto.ProjectOutput(
|
||||
id=project.id,
|
||||
telegram_id=project.channel.telegram_id,
|
||||
title=project.channel.title,
|
||||
username=project.channel.username,
|
||||
status=project.status,
|
||||
invite_link_type=project.invite_link_type,
|
||||
)
|
||||
@@ -24,6 +24,7 @@ async def get_workspace_projects(
|
||||
title=project.channel.title,
|
||||
username=project.channel.username,
|
||||
status=project.status,
|
||||
invite_link_type=project.invite_link_type,
|
||||
)
|
||||
for project in projects
|
||||
]
|
||||
|
||||
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def tg_add_project_1(self: 'Usecase', input: dto.ConnectProjectInput) -> dto.ProjectOutput | None:
|
||||
async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto.ProjectOutput | None:
|
||||
permissions = input.bot_permissions
|
||||
|
||||
if not permissions.is_admin:
|
||||
@@ -125,41 +125,40 @@ async def tg_add_project_1(self: 'Usecase', input: dto.ConnectProjectInput) -> d
|
||||
status=project.status,
|
||||
)
|
||||
|
||||
current_state = await self.database.get_telegram_state(user.telegram_id)
|
||||
if current_state and current_state.state == domain.TelegramStateEnum.PROJECT_WAITING_WORKSPACE:
|
||||
await self.telegram_bot.send_message(
|
||||
'⚠️ Сначала завершите выбор рабочего пространства для предыдущего канала.', user.telegram_id
|
||||
# Если >1 workspace - создаем/обновляем канал и отправляем уведомление
|
||||
channel = await self.database.get_channel(telegram_id=input.telegram_id)
|
||||
if channel:
|
||||
channel.title = input.title
|
||||
if input.username is not None:
|
||||
channel.username = input.username
|
||||
await self.database.update_channel(channel)
|
||||
else:
|
||||
if input.username is None:
|
||||
log.warning('Cannot create channel %s without username', input.telegram_id)
|
||||
await self.telegram_bot.send_message(
|
||||
f'⚠️ Канал "{input.title}" не может быть подключен.\n\nУ канала отсутствует публичный username.',
|
||||
input.user_telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
channel = domain.Channel(
|
||||
telegram_id=input.telegram_id,
|
||||
title=input.title,
|
||||
username=input.username,
|
||||
)
|
||||
return None
|
||||
await self.database.create_channel(channel)
|
||||
|
||||
context: dict[str, object] = {
|
||||
'channel_data': {
|
||||
'telegram_id': input.telegram_id,
|
||||
'title': input.title,
|
||||
'username': input.username,
|
||||
'permissions': input.bot_permissions.model_dump(),
|
||||
}
|
||||
}
|
||||
|
||||
if current_state:
|
||||
context['previous_state'] = current_state.state.value
|
||||
context['previous_context'] = dict(current_state.context or {})
|
||||
|
||||
await self.database.set_telegram_state(
|
||||
user.telegram_id,
|
||||
domain.TelegramStateEnum.PROJECT_WAITING_WORKSPACE,
|
||||
context,
|
||||
)
|
||||
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text=workspace.name, callback_data=f'tg_add_project_2:{workspace.id}')]
|
||||
for workspace in workspaces
|
||||
]
|
||||
# Callback будет обработан Golang ботом который покажет экран выбора workspace
|
||||
buttons = [[InlineKeyboardButton(text='Выбрать workspace', callback_data=f'pending_channel:{channel.id}')]]
|
||||
|
||||
await self.telegram_bot.send_message_with_inline_keyboard(
|
||||
(f'✨ Подключение канала\n\nВыберите рабочее пространство, в которое добавить канал "{input.title}":'),
|
||||
f'Бот добавлен в канал "{input.title}".\n\n'
|
||||
f'У вас {len(workspaces)} рабочих пространств. '
|
||||
'Нажмите кнопку ниже чтобы выбрать, где создать проект.',
|
||||
user.telegram_id,
|
||||
buttons,
|
||||
)
|
||||
|
||||
log.info('Pending channel notification sent to user %s for channel %s', user.telegram_id, channel.id)
|
||||
|
||||
return None
|
||||
@@ -1,108 +0,0 @@
|
||||
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 tg_add_project_2(
|
||||
self: 'Usecase', telegram_id: int, chat_id: int, callback_data: str, message_id: int
|
||||
) -> None:
|
||||
state = await self.database.get_telegram_state(telegram_id)
|
||||
if not state or state.state != domain.TelegramStateEnum.PROJECT_WAITING_WORKSPACE:
|
||||
log.debug('User %s has no active project workspace selection flow', telegram_id)
|
||||
return
|
||||
|
||||
context = state.context or {}
|
||||
previous_state_value = context.get('previous_state')
|
||||
previous_context = context.get('previous_context') or {}
|
||||
previous_state = None
|
||||
if previous_state_value:
|
||||
try:
|
||||
previous_state = domain.TelegramStateEnum(previous_state_value)
|
||||
except ValueError:
|
||||
previous_state = None
|
||||
|
||||
if not callback_data.startswith('tg_add_project_2:'):
|
||||
log.warning('Invalid project workspace callback data: %s', callback_data)
|
||||
return
|
||||
|
||||
workspace_id_str = callback_data.split(':', 1)[1]
|
||||
try:
|
||||
workspace_id = uuid.UUID(workspace_id_str)
|
||||
except ValueError:
|
||||
log.error('Invalid workspace UUID in callback data: %s', workspace_id_str)
|
||||
await self.telegram_bot.send_message('❌ Некорректное рабочее пространство.', chat_id)
|
||||
if previous_state:
|
||||
await self.database.set_telegram_state(telegram_id, previous_state, previous_context)
|
||||
else:
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
channel_data = context.get('channel_data')
|
||||
if not channel_data:
|
||||
log.error('Missing channel data for user %s in project flow', telegram_id)
|
||||
await self.telegram_bot.send_message('❌ Не удалось подключить канал. Попробуйте снова.', chat_id)
|
||||
if previous_state:
|
||||
await self.database.set_telegram_state(telegram_id, previous_state, previous_context)
|
||||
else:
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
user = await self.database.get_user(telegram_id=telegram_id)
|
||||
if not user:
|
||||
await self.telegram_bot.send_message('Вы не зарегистрированы на платформе.', chat_id)
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
return
|
||||
|
||||
workspace = await self.database.get_workspace_for_user(workspace_id, user.id)
|
||||
if not workspace:
|
||||
await self.telegram_bot.send_message('❌ У вас нет доступа к выбранному рабочему пространству.', chat_id)
|
||||
return
|
||||
|
||||
telegram_channel_id = int(channel_data['telegram_id'])
|
||||
channel = await self.database.get_channel(telegram_id=telegram_channel_id)
|
||||
if channel:
|
||||
channel.title = str(channel_data['title'])
|
||||
channel.username = channel_data.get('username')
|
||||
await self.database.update_channel(channel)
|
||||
else:
|
||||
channel = domain.Channel(
|
||||
telegram_id=telegram_channel_id,
|
||||
title=str(channel_data['title']),
|
||||
username=channel_data.get('username'),
|
||||
)
|
||||
await self.database.create_channel(channel)
|
||||
|
||||
project = await self.database.get_project(workspace.id, channel_id=channel.id)
|
||||
if project:
|
||||
project.status = domain.ProjectStatus.ACTIVE
|
||||
await self.database.update_project(project)
|
||||
else:
|
||||
project = domain.Project(workspace_id=workspace.id, channel_id=channel.id, status=domain.ProjectStatus.ACTIVE)
|
||||
await self.database.create_project(project)
|
||||
|
||||
await self.ensure_active_purchase_plan(project)
|
||||
|
||||
log.info(
|
||||
'Channel %s connected/updated successfully in workspace %s by user %s',
|
||||
channel.telegram_id,
|
||||
workspace.id,
|
||||
telegram_id,
|
||||
)
|
||||
|
||||
await self.telegram_bot.send_message(
|
||||
f'✅ Канал "{channel.title}" добавлен в рабочее пространство "{workspace.name}".', telegram_id
|
||||
)
|
||||
|
||||
await self.telegram_bot.edit_message_reply_markup(chat_id, message_id)
|
||||
|
||||
if previous_state:
|
||||
await self.database.set_telegram_state(telegram_id, previous_state, previous_context)
|
||||
else:
|
||||
await self.database.clear_telegram_state(telegram_id)
|
||||
42
src/usecase/project/update_project_invite_link_type.py
Normal file
42
src/usecase/project/update_project_invite_link_type.py
Normal file
@@ -0,0 +1,42 @@
|
||||
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 update_project_invite_link_type(
|
||||
self: 'Usecase',
|
||||
workspace_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
invite_link_type: domain.InviteLinkType,
|
||||
user_id: uuid.UUID,
|
||||
) -> domain.Project:
|
||||
await self.ensure_workspace_permission(
|
||||
workspace_id,
|
||||
user_id,
|
||||
domain.PermissionKey.PROJECTS_WRITE,
|
||||
for_project_id=project_id,
|
||||
)
|
||||
|
||||
async with self.database.transaction():
|
||||
project = await self.database.get_project(workspace_id, project_id=project_id)
|
||||
if not project:
|
||||
raise domain.ProjectNotFound(project_id)
|
||||
|
||||
project.invite_link_type = invite_link_type
|
||||
await project.save()
|
||||
|
||||
log.info(
|
||||
'Project %s invite_link_type updated to %s by user %s',
|
||||
project_id,
|
||||
invite_link_type,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return project
|
||||
36
src/usecase/workspace/accept_workspace_invite.py
Normal file
36
src/usecase/workspace/accept_workspace_invite.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
|
||||
async def accept_workspace_invite(
|
||||
self: Usecase,
|
||||
invite_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> dto.WorkspaceInviteOutput:
|
||||
invite = await self.database.get_workspace_invite(invite_id)
|
||||
if not invite or invite.user_id != user_id:
|
||||
raise domain.WorkspaceInviteNotFound()
|
||||
|
||||
if invite.status != domain.WorkspaceInviteStatus.PENDING:
|
||||
raise domain.WorkspaceInviteAlreadyProcessed()
|
||||
|
||||
async with self.database.transaction():
|
||||
invite.status = domain.WorkspaceInviteStatus.ACCEPTED
|
||||
await self.database.update_workspace_invite(invite)
|
||||
|
||||
membership = await self.database.get_workspace_membership(invite.workspace_id, user_id)
|
||||
if membership:
|
||||
if membership.status != domain.WorkspaceUserStatus.ACTIVE:
|
||||
membership.status = domain.WorkspaceUserStatus.ACTIVE
|
||||
await self.database.update_workspace_user(membership)
|
||||
else:
|
||||
await self.database.add_user_to_workspace(invite.workspace_id, user_id)
|
||||
|
||||
return dto.WorkspaceInviteOutput.from_domain(invite)
|
||||
Reference in New Issue
Block a user