приватные каналы

This commit is contained in:
Artem Tsyrulnikov
2026-01-21 02:41:20 +03:00
parent 233afb5451
commit b5695360c2
27 changed files with 1026 additions and 217 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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