приватные каналы
This commit is contained in:
@@ -12,8 +12,8 @@ log = logging.getLogger(__name__)
|
||||
class FetchChannelResponse(BaseModel):
|
||||
id: str
|
||||
telegram_id: int
|
||||
username: str
|
||||
title: str
|
||||
username: str | None
|
||||
title: str | None
|
||||
access_hash: int
|
||||
pts: int
|
||||
|
||||
@@ -52,3 +52,33 @@ class ParserClient(Parser):
|
||||
except Exception as e:
|
||||
log.error('Parser unexpected error for @%s: %s', username, e)
|
||||
raise
|
||||
|
||||
async def resolve_telegram_channel_by_invite(self, invite_link: str) -> FetchChannelResponse | None:
|
||||
invite_link = invite_link.strip()
|
||||
|
||||
url = f'{self.base_url}/resolve-channel-by-invite'
|
||||
payload = {'invite_link': invite_link}
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
try:
|
||||
response = await client.post(url, json=payload)
|
||||
|
||||
if response.status_code == 404:
|
||||
log.info('Channel not found by invite link')
|
||||
return None
|
||||
|
||||
response.raise_for_status()
|
||||
data: dict[str, Any] = response.json()
|
||||
return FetchChannelResponse.model_validate(data)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return None
|
||||
log.error('Parser HTTP error for invite link: %s', e)
|
||||
raise
|
||||
except httpx.TimeoutException:
|
||||
log.error('Parser timeout for invite link')
|
||||
raise
|
||||
except Exception as e:
|
||||
log.error('Parser unexpected error for invite link: %s', e)
|
||||
raise
|
||||
|
||||
@@ -20,6 +20,13 @@ async def get_channels(
|
||||
return paginate(result) # type: ignore[no-any-return]
|
||||
|
||||
|
||||
@channels_router.post('')
|
||||
async def create_channels(
|
||||
request: dto.CreateChannelsInput, _: Annotated[JWTPayload, Depends(deps.get_current_user)]
|
||||
) -> dto.CreateChannelsOutput:
|
||||
return await deps.get_usecase().create_channels(input=request)
|
||||
|
||||
|
||||
@channels_router.get('/{channel_id}')
|
||||
async def get_channel(
|
||||
channel_id: uuid.UUID, _: Annotated[JWTPayload, Depends(deps.get_current_user)]
|
||||
|
||||
@@ -6,10 +6,11 @@ from .base import TimestampedModel
|
||||
class Channel(TimestampedModel):
|
||||
telegram_id = fields.BigIntField(unique=True, index=True)
|
||||
title = fields.CharField(max_length=255)
|
||||
username = fields.CharField(max_length=255, unique=True, index=True)
|
||||
username = fields.CharField(max_length=255, unique=True, index=True, null=True)
|
||||
|
||||
access_hash = fields.BigIntField(null=True)
|
||||
pts = fields.IntField(null=True)
|
||||
invite_link = fields.CharField(max_length=1024, null=True)
|
||||
|
||||
class Meta:
|
||||
table = 'channel'
|
||||
|
||||
@@ -15,6 +15,10 @@ __all__ = (
|
||||
'ChannelOutput',
|
||||
'GetChannelInput',
|
||||
'GetChannelsInput',
|
||||
'CreateChannelInput',
|
||||
'CreateChannelsInput',
|
||||
'CreateChannelResult',
|
||||
'CreateChannelsOutput',
|
||||
'GetChannelsOutput',
|
||||
'AttachChannelToWorkspaceInput',
|
||||
'PlacementOutput',
|
||||
@@ -121,6 +125,10 @@ from .channel import (
|
||||
ChannelOutput,
|
||||
GetChannelInput,
|
||||
GetChannelsInput,
|
||||
CreateChannelInput,
|
||||
CreateChannelsInput,
|
||||
CreateChannelResult,
|
||||
CreateChannelsOutput,
|
||||
GetChannelsOutput,
|
||||
)
|
||||
from .creative import (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
import pydantic
|
||||
|
||||
@@ -26,3 +27,54 @@ class AttachChannelToWorkspaceInput(pydantic.BaseModel):
|
||||
channel_id: uuid.UUID
|
||||
workspace_id: uuid.UUID
|
||||
user_telegram_id: int
|
||||
|
||||
|
||||
class CreateChannelInput(pydantic.BaseModel):
|
||||
username: str | None = None
|
||||
invite_link: str | None = None
|
||||
|
||||
@pydantic.field_validator('username')
|
||||
@classmethod
|
||||
def normalize_username(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
username = value.strip()
|
||||
if username.startswith('@'):
|
||||
username = username[1:]
|
||||
username = username.strip()
|
||||
if not username:
|
||||
return None
|
||||
return username
|
||||
|
||||
@pydantic.field_validator('invite_link')
|
||||
@classmethod
|
||||
def normalize_invite_link(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
link = value.strip()
|
||||
if not link:
|
||||
return None
|
||||
return link
|
||||
|
||||
@pydantic.model_validator(mode='after')
|
||||
def validate_input(self) -> 'CreateChannelInput':
|
||||
has_username = bool(self.username)
|
||||
has_invite = bool(self.invite_link)
|
||||
if has_username == has_invite:
|
||||
raise ValueError('Specify exactly one of username or invite_link')
|
||||
return self
|
||||
|
||||
|
||||
class CreateChannelsInput(pydantic.BaseModel):
|
||||
channels: list[CreateChannelInput]
|
||||
|
||||
|
||||
class CreateChannelResult(pydantic.BaseModel):
|
||||
index: int
|
||||
status: Literal['created', 'updated', 'failed']
|
||||
channel: ChannelOutput | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class CreateChannelsOutput(pydantic.BaseModel):
|
||||
results: list[CreateChannelResult]
|
||||
|
||||
@@ -59,7 +59,7 @@ class PlacementWithPostsOutput(PlacementOutput):
|
||||
class CreatePlacementChannelInput(pydantic.BaseModel):
|
||||
"""Input для создания одного placement (channel + детали)"""
|
||||
|
||||
username: str = pydantic.Field(pattern=r'^[^@]')
|
||||
channel_id: uuid.UUID
|
||||
status: PlacementStatus | None = None
|
||||
comment: str | None = None
|
||||
details: PlacementDetails | None = None
|
||||
|
||||
@@ -21,6 +21,7 @@ from .auth.get_jwt_by_telegram_id import get_jwt_by_telegram_id
|
||||
from .auth.get_me import get_me
|
||||
from .auth.validate_login_token import validate_login_token
|
||||
from .channel.attach_channel_to_workspace import attach_channel_to_workspace
|
||||
from .channel.create_channels import create_channels
|
||||
from .channel.get_channel import get_channel
|
||||
from .channel.get_channels import get_channels
|
||||
from .creative.create_creative import create_creative
|
||||
@@ -98,12 +99,15 @@ class JWTEncoder(typing.Protocol):
|
||||
|
||||
class FetchChannelResponse(typing.Protocol):
|
||||
telegram_id: int
|
||||
username: str
|
||||
title: str
|
||||
username: str | None
|
||||
title: str | None
|
||||
access_hash: int | None
|
||||
pts: int | None
|
||||
|
||||
|
||||
class Parser(typing.Protocol):
|
||||
async def fetch_telegram_channel(self, username: str) -> FetchChannelResponse | None: ...
|
||||
async def resolve_telegram_channel_by_invite(self, invite_link: str) -> FetchChannelResponse | None: ...
|
||||
|
||||
|
||||
class S3Storage(typing.Protocol):
|
||||
@@ -185,6 +189,7 @@ class Usecase:
|
||||
update_project_permissions = update_project_permissions
|
||||
update_project_invite_link_type = update_project_invite_link_type
|
||||
get_channels = get_channels
|
||||
create_channels = create_channels
|
||||
get_channel = get_channel
|
||||
attach_channel_to_workspace = attach_channel_to_workspace
|
||||
# Placement (user-managed) use cases
|
||||
|
||||
93
src/usecase/channel/create_channels.py
Normal file
93
src/usecase/channel/create_channels.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_channels(self: 'Usecase', input: dto.CreateChannelsInput) -> dto.CreateChannelsOutput:
|
||||
results: list[dto.CreateChannelResult] = []
|
||||
|
||||
for index, channel_input in enumerate(input.channels):
|
||||
try:
|
||||
if channel_input.username:
|
||||
parser_response = await self.parser.fetch_telegram_channel(channel_input.username)
|
||||
if not parser_response:
|
||||
raise domain.TelegramChannelNotFound(channel_input.username)
|
||||
else:
|
||||
parser_response = await self.parser.resolve_telegram_channel_by_invite(channel_input.invite_link or '')
|
||||
if not parser_response:
|
||||
raise ValueError('Telegram channel not found by invite link')
|
||||
|
||||
parsed_username = parser_response.username or None
|
||||
is_private = channel_input.invite_link != ""
|
||||
|
||||
channel = await self.database.get_channel(telegram_id=parser_response.telegram_id)
|
||||
if not channel and parsed_username:
|
||||
channel = await self.database.get_channel(username=parsed_username)
|
||||
|
||||
status = 'created'
|
||||
if channel:
|
||||
status = 'updated'
|
||||
updated = False
|
||||
if parsed_username is not None and channel.username != parsed_username:
|
||||
channel.username = parsed_username
|
||||
updated = True
|
||||
if parser_response.title is not None and channel.title != parser_response.title:
|
||||
channel.title = parser_response.title
|
||||
updated = True
|
||||
if parser_response.telegram_id is not None and channel.telegram_id != parser_response.telegram_id:
|
||||
channel.telegram_id = parser_response.telegram_id
|
||||
updated = True
|
||||
if parser_response.access_hash is not None and channel.access_hash != parser_response.access_hash:
|
||||
channel.access_hash = parser_response.access_hash
|
||||
updated = True
|
||||
if parser_response.pts is not None and channel.pts != parser_response.pts:
|
||||
channel.pts = parser_response.pts
|
||||
updated = True
|
||||
if is_private and channel.pts != 0:
|
||||
channel.pts = 0
|
||||
updated = True
|
||||
if channel_input.invite_link and channel.invite_link != channel_input.invite_link:
|
||||
channel.invite_link = channel_input.invite_link
|
||||
updated = True
|
||||
if updated:
|
||||
await self.database.update_channel(channel)
|
||||
else:
|
||||
channel = domain.Channel(
|
||||
username=parsed_username,
|
||||
telegram_id=parser_response.telegram_id,
|
||||
title=parser_response.title,
|
||||
access_hash=parser_response.access_hash,
|
||||
pts=0 if is_private else parser_response.pts,
|
||||
invite_link=channel_input.invite_link,
|
||||
)
|
||||
await self.database.create_channel(channel)
|
||||
|
||||
results.append(
|
||||
dto.CreateChannelResult(
|
||||
index=index,
|
||||
status=status,
|
||||
channel=dto.ChannelOutput(
|
||||
id=channel.id,
|
||||
telegram_id=channel.telegram_id,
|
||||
title=channel.title,
|
||||
username=channel.username,
|
||||
),
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning('Failed to create channel at index %s: %s', index, exc)
|
||||
results.append(
|
||||
dto.CreateChannelResult(
|
||||
index=index,
|
||||
status='failed',
|
||||
error=str(exc),
|
||||
)
|
||||
)
|
||||
|
||||
return dto.CreateChannelsOutput(results=results)
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
from src import dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
@@ -12,44 +12,6 @@ log = logging.getLogger(__name__)
|
||||
async def get_channels(self: 'Usecase', input: dto.GetChannelsInput) -> list[dto.ChannelOutput]:
|
||||
channels = await self.database.search_channels(username_query=input.username)
|
||||
|
||||
if not channels and input.username:
|
||||
try:
|
||||
parser_response = await self.parser.fetch_telegram_channel(input.username)
|
||||
except Exception as exc:
|
||||
log.warning('Parser fetch failed for username query %s: %s', input.username, exc)
|
||||
parser_response = None
|
||||
|
||||
if parser_response:
|
||||
channel = await self.database.get_channel(telegram_id=parser_response.telegram_id)
|
||||
if not channel:
|
||||
channel = await self.database.get_channel(username=parser_response.username)
|
||||
|
||||
if channel:
|
||||
updated = False
|
||||
if channel.username != parser_response.username:
|
||||
channel.username = parser_response.username
|
||||
updated = True
|
||||
if channel.title != parser_response.title:
|
||||
channel.title = parser_response.title
|
||||
updated = True
|
||||
if channel.telegram_id != parser_response.telegram_id:
|
||||
channel.telegram_id = parser_response.telegram_id
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
await self.database.update_channel(channel)
|
||||
log.info('Updated channel @%s from parser response', channel.username)
|
||||
else:
|
||||
channel = domain.Channel(
|
||||
username=parser_response.username,
|
||||
telegram_id=parser_response.telegram_id,
|
||||
title=parser_response.title,
|
||||
)
|
||||
await self.database.create_channel(channel)
|
||||
log.info('Created channel @%s from parser response', channel.username)
|
||||
|
||||
channels = [channel]
|
||||
|
||||
log.debug('Found %s channels for username query: %s', len(channels), input.username)
|
||||
|
||||
return [
|
||||
|
||||
@@ -29,13 +29,11 @@ async def move_project_to_workspace(
|
||||
if not target_workspace:
|
||||
raise domain.WorkspaceNotFound(target_workspace_id)
|
||||
|
||||
# Проверяем permissions в source workspace (scope на конкретный project)
|
||||
await self.ensure_workspace_permission(
|
||||
source_workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE, for_project_id=project_id
|
||||
)
|
||||
# Проверяем права владельца в source workspace
|
||||
await self.ensure_workspace_permission(source_workspace_id, user_id, domain.PermissionKey.ADMIN_FULL)
|
||||
|
||||
# Проверяем permissions в target workspace
|
||||
await self.ensure_workspace_permission(target_workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE)
|
||||
# Проверяем права владельца в target workspace
|
||||
await self.ensure_workspace_permission(target_workspace_id, user_id, domain.PermissionKey.ADMIN_FULL)
|
||||
|
||||
# Проверяем что канал не существует в target workspace (UNIQUE constraint)
|
||||
if await self.database.check_channel_exists_in_workspace(project.channel.id, target_workspace_id):
|
||||
|
||||
@@ -79,28 +79,90 @@ async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto
|
||||
workspace = await self.get_or_create_personal_workspace(user)
|
||||
workspaces = [workspace]
|
||||
|
||||
invite_link = ""
|
||||
parser_response = None
|
||||
is_private = input.username is None
|
||||
if is_private:
|
||||
try:
|
||||
invite_link = await self.telegram_bot.create_chat_invite_link(input.telegram_id)
|
||||
parser_response = await self.parser.resolve_telegram_channel_by_invite(invite_link)
|
||||
except Exception as exc:
|
||||
log.warning('Failed to resolve private channel %s: %s', input.telegram_id, exc)
|
||||
await self.telegram_bot.send_message(
|
||||
f'⚠️ Канал "{input.title}" не может быть подключен.\n\n'
|
||||
'Не удалось создать или проверить инвайт-ссылку.',
|
||||
input.user_telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
def build_channel_title() -> str:
|
||||
if parser_response and parser_response.title:
|
||||
return parser_response.title
|
||||
return input.title
|
||||
|
||||
def build_channel_username() -> str | None:
|
||||
if parser_response and parser_response.username:
|
||||
return parser_response.username
|
||||
return input.username
|
||||
|
||||
def build_channel_telegram_id() -> int:
|
||||
if parser_response and parser_response.telegram_id:
|
||||
return parser_response.telegram_id
|
||||
return input.telegram_id
|
||||
|
||||
def update_channel_meta(channel: domain.Channel) -> bool:
|
||||
updated = False
|
||||
title = build_channel_title()
|
||||
username = build_channel_username()
|
||||
telegram_id = build_channel_telegram_id()
|
||||
if channel.title != title:
|
||||
channel.title = title
|
||||
updated = True
|
||||
if username is not None and channel.username != username:
|
||||
channel.username = username
|
||||
updated = True
|
||||
if channel.telegram_id != telegram_id:
|
||||
channel.telegram_id = telegram_id
|
||||
updated = True
|
||||
if parser_response and parser_response.access_hash is not None and channel.access_hash != parser_response.access_hash:
|
||||
channel.access_hash = parser_response.access_hash
|
||||
updated = True
|
||||
if parser_response and parser_response.pts is not None and channel.pts != parser_response.pts:
|
||||
channel.pts = parser_response.pts
|
||||
updated = True
|
||||
if is_private and channel.pts != 0:
|
||||
channel.pts = 0
|
||||
updated = True
|
||||
if invite_link and channel.invite_link != invite_link:
|
||||
channel.invite_link = invite_link
|
||||
updated = True
|
||||
return updated
|
||||
|
||||
if len(workspaces) == 1:
|
||||
workspace = workspaces[0]
|
||||
|
||||
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)
|
||||
if update_channel_meta(channel):
|
||||
await self.database.update_channel(channel)
|
||||
else:
|
||||
if input.username is None:
|
||||
log.warning('Cannot create channel %s without username', input.telegram_id)
|
||||
username = build_channel_username()
|
||||
if username is None and invite_link == "":
|
||||
log.warning('Cannot create channel %s without username or invite link', input.telegram_id)
|
||||
await self.telegram_bot.send_message(
|
||||
f'⚠️ Канал "{input.title}" не может быть подключен.\n\nУ канала отсутствует публичный username.',
|
||||
f'⚠️ Канал "{input.title}" не может быть подключен.\n\n'
|
||||
'У канала отсутствует публичный username и не удалось получить invite link.',
|
||||
input.user_telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
channel = domain.Channel(
|
||||
telegram_id=input.telegram_id,
|
||||
title=input.title,
|
||||
username=input.username,
|
||||
telegram_id=build_channel_telegram_id(),
|
||||
title=build_channel_title(),
|
||||
username=username,
|
||||
access_hash=(parser_response.access_hash if parser_response else None),
|
||||
pts=(0 if is_private else (parser_response.pts if parser_response else None)),
|
||||
invite_link=invite_link or None,
|
||||
)
|
||||
await self.database.create_channel(channel)
|
||||
|
||||
@@ -139,23 +201,26 @@ async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto
|
||||
# Если >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)
|
||||
if update_channel_meta(channel):
|
||||
await self.database.update_channel(channel)
|
||||
else:
|
||||
if input.username is None:
|
||||
log.warning('Cannot create channel %s without username', input.telegram_id)
|
||||
username = build_channel_username()
|
||||
if username is None and invite_link == "":
|
||||
log.warning('Cannot create channel %s without username or invite link', input.telegram_id)
|
||||
await self.telegram_bot.send_message(
|
||||
f'⚠️ Канал "{input.title}" не может быть подключен.\n\nУ канала отсутствует публичный username.',
|
||||
f'⚠️ Канал "{input.title}" не может быть подключен.\n\n'
|
||||
'У канала отсутствует публичный username и не удалось получить invite link.',
|
||||
input.user_telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
channel = domain.Channel(
|
||||
telegram_id=input.telegram_id,
|
||||
title=input.title,
|
||||
username=input.username,
|
||||
telegram_id=build_channel_telegram_id(),
|
||||
title=build_channel_title(),
|
||||
username=username,
|
||||
access_hash=(parser_response.access_hash if parser_response else None),
|
||||
pts=(0 if is_private else (parser_response.pts if parser_response else None)),
|
||||
invite_link=invite_link or None,
|
||||
)
|
||||
await self.database.create_channel(channel)
|
||||
|
||||
|
||||
@@ -79,20 +79,9 @@ async def create_placements(
|
||||
creatives_by_id: dict[uuid.UUID, domain.Creative] = {}
|
||||
|
||||
for channel_input in input.channels:
|
||||
channel = await self.database.get_channel(username=channel_input.username)
|
||||
|
||||
channel = await self.database.get_channel(channel_id=channel_input.channel_id)
|
||||
if not channel:
|
||||
parser_response = await self.parser.fetch_telegram_channel(channel_input.username)
|
||||
if not parser_response:
|
||||
raise domain.TelegramChannelNotFound(channel_input.username)
|
||||
|
||||
channel = domain.Channel(
|
||||
username=parser_response.username,
|
||||
telegram_id=parser_response.telegram_id,
|
||||
title=parser_response.title,
|
||||
)
|
||||
await self.database.create_channel(channel)
|
||||
log.info('Created channel @%s with telegram_id=%s', channel.username, channel.telegram_id)
|
||||
raise domain.ChannelNotFound(channel_input.channel_id)
|
||||
|
||||
# Объединяем общие детали с деталями конкретного канала
|
||||
channel_details = channel_input.details
|
||||
|
||||
Reference in New Issue
Block a user