приватные каналы
This commit is contained in:
94
migrations/models/7_20260121021919_update.py
Normal file
94
migrations/models/7_20260121021919_update.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "channel" ADD "invite_link" VARCHAR(1024);
|
||||
ALTER TABLE "channel" ALTER COLUMN "username" DROP NOT NULL;"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "channel" DROP COLUMN "invite_link";
|
||||
ALTER TABLE "channel" ALTER COLUMN "username" SET NOT NULL;"""
|
||||
|
||||
|
||||
MODELS_STATE = (
|
||||
"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"
|
||||
)
|
||||
96
migrations/models/8_20260121022045_add_channel_constraint.py
Normal file
96
migrations/models/8_20260121022045_add_channel_constraint.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "channel"
|
||||
ADD CONSTRAINT "channel_identity_required"
|
||||
CHECK (username IS NOT NULL OR invite_link IS NOT NULL); \
|
||||
"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "channel" DROP CONSTRAINT IF EXISTS "channel_identity_required"; \
|
||||
"""
|
||||
|
||||
|
||||
MODELS_STATE = (
|
||||
'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'
|
||||
)
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -542,6 +542,26 @@ type Channel struct {
|
||||
Username *string `json:"username"`
|
||||
}
|
||||
|
||||
type CreateChannelInput struct {
|
||||
Username *string `json:"username,omitempty"`
|
||||
InviteLink *string `json:"invite_link,omitempty"`
|
||||
}
|
||||
|
||||
type CreateChannelsInput struct {
|
||||
Channels []CreateChannelInput `json:"channels"`
|
||||
}
|
||||
|
||||
type CreateChannelResult struct {
|
||||
Index int `json:"index"`
|
||||
Status string `json:"status"`
|
||||
Channel *Channel `json:"channel,omitempty"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type CreateChannelsOutput struct {
|
||||
Results []CreateChannelResult `json:"results"`
|
||||
}
|
||||
|
||||
type CostInfo struct {
|
||||
Type string `json:"type"`
|
||||
Value float64 `json:"value"`
|
||||
@@ -588,7 +608,7 @@ type PostOutput struct {
|
||||
}
|
||||
|
||||
type CreatePlacementChannelInput struct {
|
||||
Username string `json:"username"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
Details *PlacementDetails `json:"details,omitempty"`
|
||||
@@ -635,6 +655,24 @@ func (c *Client) CreatePlacements(
|
||||
return &placements, err
|
||||
}
|
||||
|
||||
func (c *Client) CreateChannels(
|
||||
ctx context.Context,
|
||||
jwt string,
|
||||
input CreateChannelsInput,
|
||||
) (*CreateChannelsOutput, error) {
|
||||
var response CreateChannelsOutput
|
||||
err := c.do(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
"api/v1/channels",
|
||||
input,
|
||||
&response,
|
||||
withBearer(jwt),
|
||||
)
|
||||
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (c *Client) GetPlacements(
|
||||
ctx context.Context,
|
||||
jwt string,
|
||||
|
||||
@@ -521,7 +521,7 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM
|
||||
case "cost_value":
|
||||
text += "<b>Ввод стоимости</b>\n\nНапример: <code>15000</code>"
|
||||
if s.CurrentChannel != "" {
|
||||
text += fmt.Sprintf("\n\nКанал: <b>@%s</b>", s.CurrentChannel)
|
||||
text += fmt.Sprintf("\n\nКанал: <b>%s</b>", channelLabelByKey(s.Channels, s.CurrentChannel))
|
||||
}
|
||||
|
||||
typeLabel := s.costTypeLabelForCurrent()
|
||||
@@ -539,12 +539,12 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM
|
||||
case "cost_before_value":
|
||||
text += "<b>Ввод стоимости до торга</b>\n\nНапример: <code>20000</code>"
|
||||
if s.CurrentChannel != "" {
|
||||
text += fmt.Sprintf("\n\nКанал: <b>@%s</b>", s.CurrentChannel)
|
||||
text += fmt.Sprintf("\n\nКанал: <b>%s</b>", channelLabelByKey(s.Channels, s.CurrentChannel))
|
||||
}
|
||||
case "format_select":
|
||||
text = "<i>Страница ввода формата размещения</i>\n\n"
|
||||
if s.CurrentChannel != "" {
|
||||
text += fmt.Sprintf("Канал: <b>@%s</b>\n\n", s.CurrentChannel)
|
||||
text += fmt.Sprintf("Канал: <b>%s</b>\n\n", channelLabelByKey(s.Channels, s.CurrentChannel))
|
||||
}
|
||||
text += "Выберите формат\n\n"
|
||||
keyboard := Keyboard(
|
||||
@@ -707,7 +707,7 @@ func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.Rende
|
||||
// Список каналов с кнопками в зависимости от режима
|
||||
for i := start; i < end; i++ {
|
||||
label := fmt.Sprintf("%d", i+1)
|
||||
channelUsername := s.Channels[i].Username
|
||||
channelKey := channelKey(s.Channels[i])
|
||||
|
||||
if s.ChannelEditMode == "copy" {
|
||||
// Режим копирования
|
||||
@@ -723,7 +723,7 @@ func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.Rende
|
||||
}
|
||||
|
||||
// Кнопка удаления - только если есть данные у канала
|
||||
if s.hasChannelValue(param, channelUsername) {
|
||||
if s.hasChannelValue(param, channelKey) {
|
||||
channelRow = append(channelRow, Button("⌫", fmt.Sprintf("param_clear:%s:%d", param, i)))
|
||||
} else {
|
||||
channelRow = append(channelRow, Button(" ", "noop"))
|
||||
@@ -736,7 +736,7 @@ func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.Rende
|
||||
channelRow = append(channelRow, Button(label, fmt.Sprintf("param_edit:%s:%d", param, i)))
|
||||
|
||||
// Кнопка "Добавить" или "Изменить" в зависимости от наличия данных
|
||||
hasValue := s.hasChannelValue(param, channelUsername)
|
||||
hasValue := s.hasChannelValue(param, channelKey)
|
||||
if hasValue {
|
||||
channelRow = append(channelRow, Button("Изменить", fmt.Sprintf("param_edit:%s:%d", param, i)))
|
||||
} else {
|
||||
@@ -772,8 +772,9 @@ func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.Rende
|
||||
func (s *PurchaseOptionalDetails) renderParamChannelSummary(param string) string {
|
||||
var lines []string
|
||||
for i, ch := range s.Channels {
|
||||
value := s.formatParamValue(param, ch.Username)
|
||||
lines = append(lines, fmt.Sprintf("%d. @%s — %s", i+1, ch.Username, value))
|
||||
channelKey := channelKey(ch)
|
||||
value := s.formatParamValue(param, channelKey)
|
||||
lines = append(lines, fmt.Sprintf("%d. %s — %s", i+1, channelLabel(ch), value))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -788,7 +789,7 @@ func (s *PurchaseOptionalDetails) handleParamCallback(b *bot.Bot, data string) b
|
||||
}
|
||||
if index >= 0 && index < len(s.Channels) {
|
||||
s.CurrentParam = param
|
||||
s.CurrentChannel = s.Channels[index].Username
|
||||
s.CurrentChannel = channelKey(s.Channels[index])
|
||||
s.openChannelEditor(b, param, s.CurrentChannel)
|
||||
return true
|
||||
}
|
||||
@@ -801,8 +802,8 @@ func (s *PurchaseOptionalDetails) handleParamCallback(b *bot.Bot, data string) b
|
||||
return true
|
||||
}
|
||||
if index >= 0 && index < len(s.Channels) {
|
||||
username := s.Channels[index].Username
|
||||
s.copyParamValue(param, username)
|
||||
channelKey := channelKey(s.Channels[index])
|
||||
s.copyParamValue(param, channelKey)
|
||||
s.Enter(b, bot.EditMessage)
|
||||
return true
|
||||
}
|
||||
@@ -815,8 +816,8 @@ func (s *PurchaseOptionalDetails) handleParamCallback(b *bot.Bot, data string) b
|
||||
return true
|
||||
}
|
||||
if index >= 0 && index < len(s.Channels) {
|
||||
username := s.Channels[index].Username
|
||||
s.pasteParamValue(param, username)
|
||||
channelKey := channelKey(s.Channels[index])
|
||||
s.pasteParamValue(param, channelKey)
|
||||
s.Enter(b, bot.EditMessage)
|
||||
return true
|
||||
}
|
||||
@@ -829,8 +830,8 @@ func (s *PurchaseOptionalDetails) handleParamCallback(b *bot.Bot, data string) b
|
||||
return true
|
||||
}
|
||||
if index >= 0 && index < len(s.Channels) {
|
||||
username := s.Channels[index].Username
|
||||
s.clearParamValue(param, username)
|
||||
channelKey := channelKey(s.Channels[index])
|
||||
s.clearParamValue(param, channelKey)
|
||||
s.Enter(b, bot.EditMessage)
|
||||
return true
|
||||
}
|
||||
@@ -902,31 +903,31 @@ func (s *PurchaseOptionalDetails) openCommonEditor(b *bot.Bot, param string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PurchaseOptionalDetails) openChannelEditor(b *bot.Bot, param, username string) {
|
||||
func (s *PurchaseOptionalDetails) openChannelEditor(b *bot.Bot, param, channelKey string) {
|
||||
switch param {
|
||||
case "placement":
|
||||
s.InputMode = "placement_channels"
|
||||
b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{
|
||||
Title: "Дата и время размещения",
|
||||
Key: "placement_datetime:" + username,
|
||||
Key: "placement_datetime:" + channelKey,
|
||||
IncludeTime: true,
|
||||
Selected: s.PlacementByChannel[username],
|
||||
Selected: s.PlacementByChannel[channelKey],
|
||||
BackState: s,
|
||||
}), bot.EditMessage)
|
||||
case "cost":
|
||||
s.InputMode = "cost_value"
|
||||
s.ReturnMode = "cost_channels"
|
||||
s.CurrentChannel = username
|
||||
s.CurrentChannel = channelKey
|
||||
s.Enter(b, bot.EditMessage)
|
||||
case "cost_before":
|
||||
s.InputMode = "cost_before_value"
|
||||
s.ReturnMode = "cost_before_channels"
|
||||
s.CurrentChannel = username
|
||||
s.CurrentChannel = channelKey
|
||||
s.Enter(b, bot.EditMessage)
|
||||
case "format":
|
||||
s.InputMode = "format_select"
|
||||
s.ReturnMode = "format_channels"
|
||||
s.CurrentChannel = username
|
||||
s.CurrentChannel = channelKey
|
||||
s.Enter(b, bot.EditMessage)
|
||||
default:
|
||||
s.InputMode = ""
|
||||
@@ -934,12 +935,12 @@ func (s *PurchaseOptionalDetails) openChannelEditor(b *bot.Bot, param, username
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PurchaseOptionalDetails) copyParamValue(param, username string) {
|
||||
func (s *PurchaseOptionalDetails) copyParamValue(param, channelKey string) {
|
||||
switch param {
|
||||
case "placement":
|
||||
s.PlacementCopy = s.PlacementByChannel[username]
|
||||
s.PlacementCopy = s.PlacementByChannel[channelKey]
|
||||
case "cost":
|
||||
entry := s.CostByChannel[username]
|
||||
entry := s.CostByChannel[channelKey]
|
||||
copied := entry
|
||||
if entry.Value == nil {
|
||||
copied.Value = nil
|
||||
@@ -949,9 +950,9 @@ func (s *PurchaseOptionalDetails) copyParamValue(param, username string) {
|
||||
}
|
||||
s.CostCopy = &copied
|
||||
case "cost_before":
|
||||
s.CostBeforeCopy = s.CostBeforeByChannel[username]
|
||||
s.CostBeforeCopy = s.CostBeforeByChannel[channelKey]
|
||||
case "format":
|
||||
if value, ok := s.FormatByChannel[username]; ok {
|
||||
if value, ok := s.FormatByChannel[channelKey]; ok {
|
||||
copied := value
|
||||
s.FormatCopy = &copied
|
||||
} else {
|
||||
@@ -960,18 +961,18 @@ func (s *PurchaseOptionalDetails) copyParamValue(param, username string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PurchaseOptionalDetails) pasteParamValue(param, username string) {
|
||||
func (s *PurchaseOptionalDetails) pasteParamValue(param, channelKey string) {
|
||||
switch param {
|
||||
case "placement":
|
||||
if s.PlacementCopy == nil {
|
||||
s.PlacementByChannel[username] = nil
|
||||
s.PlacementByChannel[channelKey] = nil
|
||||
return
|
||||
}
|
||||
value := s.PlacementCopy.In(ui2.MskLocation)
|
||||
s.PlacementByChannel[username] = &value
|
||||
s.PlacementByChannel[channelKey] = &value
|
||||
case "cost":
|
||||
if s.CostCopy == nil {
|
||||
delete(s.CostByChannel, username)
|
||||
delete(s.CostByChannel, channelKey)
|
||||
return
|
||||
}
|
||||
copied := *s.CostCopy
|
||||
@@ -979,20 +980,20 @@ func (s *PurchaseOptionalDetails) pasteParamValue(param, username string) {
|
||||
value := *copied.Value
|
||||
copied.Value = &value
|
||||
}
|
||||
s.CostByChannel[username] = copied
|
||||
s.CostByChannel[channelKey] = copied
|
||||
case "cost_before":
|
||||
if s.CostBeforeCopy == nil {
|
||||
s.CostBeforeByChannel[username] = nil
|
||||
s.CostBeforeByChannel[channelKey] = nil
|
||||
return
|
||||
}
|
||||
value := *s.CostBeforeCopy
|
||||
s.CostBeforeByChannel[username] = &value
|
||||
s.CostBeforeByChannel[channelKey] = &value
|
||||
case "format":
|
||||
if s.FormatCopy == nil {
|
||||
delete(s.FormatByChannel, username)
|
||||
delete(s.FormatByChannel, channelKey)
|
||||
return
|
||||
}
|
||||
s.FormatByChannel[username] = *s.FormatCopy
|
||||
s.FormatByChannel[channelKey] = *s.FormatCopy
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1000,33 +1001,33 @@ func (s *PurchaseOptionalDetails) applyParamToAll(param string) {
|
||||
switch param {
|
||||
case "placement":
|
||||
for _, ch := range s.Channels {
|
||||
s.pasteParamValue(param, ch.Username)
|
||||
s.pasteParamValue(param, channelKey(ch))
|
||||
}
|
||||
case "cost":
|
||||
for _, ch := range s.Channels {
|
||||
s.pasteParamValue(param, ch.Username)
|
||||
s.pasteParamValue(param, channelKey(ch))
|
||||
}
|
||||
case "cost_before":
|
||||
for _, ch := range s.Channels {
|
||||
s.pasteParamValue(param, ch.Username)
|
||||
s.pasteParamValue(param, channelKey(ch))
|
||||
}
|
||||
case "format":
|
||||
for _, ch := range s.Channels {
|
||||
s.pasteParamValue(param, ch.Username)
|
||||
s.pasteParamValue(param, channelKey(ch))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PurchaseOptionalDetails) clearParamValue(param, username string) {
|
||||
func (s *PurchaseOptionalDetails) clearParamValue(param, channelKey string) {
|
||||
switch param {
|
||||
case "placement":
|
||||
s.PlacementByChannel[username] = nil
|
||||
s.PlacementByChannel[channelKey] = nil
|
||||
case "cost":
|
||||
delete(s.CostByChannel, username)
|
||||
delete(s.CostByChannel, channelKey)
|
||||
case "cost_before":
|
||||
s.CostBeforeByChannel[username] = nil
|
||||
s.CostBeforeByChannel[channelKey] = nil
|
||||
case "format":
|
||||
delete(s.FormatByChannel, username)
|
||||
delete(s.FormatByChannel, channelKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1116,7 +1117,10 @@ func (s *PurchaseOptionalDetails) ensureDefaults() {
|
||||
func (s *PurchaseOptionalDetails) syncChannelMaps() {
|
||||
valid := make(map[string]struct{}, len(s.Channels))
|
||||
for _, ch := range s.Channels {
|
||||
valid[ch.Username] = struct{}{}
|
||||
key := channelKey(ch)
|
||||
if key != "" {
|
||||
valid[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
for key := range s.PlacementByChannel {
|
||||
if _, ok := valid[key]; !ok {
|
||||
@@ -1196,17 +1200,17 @@ func (s *PurchaseOptionalDetails) formatFormatDetails() string {
|
||||
return "\n" + s.renderParamChannelSummary("format")
|
||||
}
|
||||
|
||||
func (s *PurchaseOptionalDetails) formatParamValue(param, username string) string {
|
||||
func (s *PurchaseOptionalDetails) formatParamValue(param, channelKey string) string {
|
||||
switch param {
|
||||
case "placement":
|
||||
value := s.PlacementByChannel[username]
|
||||
value := s.PlacementByChannel[channelKey]
|
||||
return s.formatDateTime(value)
|
||||
case "cost":
|
||||
return s.formatCostForChannel(username)
|
||||
return s.formatCostForChannel(channelKey)
|
||||
case "cost_before":
|
||||
return s.formatCostBeforeForChannel(username)
|
||||
return s.formatCostBeforeForChannel(channelKey)
|
||||
case "format":
|
||||
if value, ok := s.FormatByChannel[username]; ok && value != "" {
|
||||
if value, ok := s.FormatByChannel[channelKey]; ok && value != "" {
|
||||
return value
|
||||
}
|
||||
return "—"
|
||||
@@ -1215,8 +1219,8 @@ func (s *PurchaseOptionalDetails) formatParamValue(param, username string) strin
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PurchaseOptionalDetails) formatCostForChannel(username string) string {
|
||||
entry, ok := s.CostByChannel[username]
|
||||
func (s *PurchaseOptionalDetails) formatCostForChannel(channelKey string) string {
|
||||
entry, ok := s.CostByChannel[channelKey]
|
||||
if !ok || entry.Value == nil {
|
||||
return "—"
|
||||
}
|
||||
@@ -1224,8 +1228,8 @@ func (s *PurchaseOptionalDetails) formatCostForChannel(username string) string {
|
||||
return fmt.Sprintf("%s %.0f₽", label, *entry.Value)
|
||||
}
|
||||
|
||||
func (s *PurchaseOptionalDetails) formatCostBeforeForChannel(username string) string {
|
||||
value, ok := s.CostBeforeByChannel[username]
|
||||
func (s *PurchaseOptionalDetails) formatCostBeforeForChannel(channelKey string) string {
|
||||
value, ok := s.CostBeforeByChannel[channelKey]
|
||||
if !ok || value == nil {
|
||||
return "—"
|
||||
}
|
||||
@@ -1247,19 +1251,19 @@ func (s *PurchaseOptionalDetails) hasCopyBuffer(param string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PurchaseOptionalDetails) hasChannelValue(param, username string) bool {
|
||||
func (s *PurchaseOptionalDetails) hasChannelValue(param, channelKey string) bool {
|
||||
switch param {
|
||||
case "placement":
|
||||
value, ok := s.PlacementByChannel[username]
|
||||
value, ok := s.PlacementByChannel[channelKey]
|
||||
return ok && value != nil
|
||||
case "cost":
|
||||
entry, ok := s.CostByChannel[username]
|
||||
entry, ok := s.CostByChannel[channelKey]
|
||||
return ok && entry.Value != nil
|
||||
case "cost_before":
|
||||
value, ok := s.CostBeforeByChannel[username]
|
||||
value, ok := s.CostBeforeByChannel[channelKey]
|
||||
return ok && value != nil
|
||||
case "format":
|
||||
value, ok := s.FormatByChannel[username]
|
||||
value, ok := s.FormatByChannel[channelKey]
|
||||
return ok && value != ""
|
||||
default:
|
||||
return false
|
||||
@@ -1412,12 +1416,12 @@ func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) {
|
||||
|
||||
apiChannels := make([]backend.CreatePlacementChannelInput, 0, len(s.Channels))
|
||||
for _, ch := range s.Channels {
|
||||
channelDetails := s.buildChannelDetails(ch.Username)
|
||||
channelDetails := s.buildChannelDetails(channelKey(ch))
|
||||
if channelDetails != nil && s.isChannelDetailsEmpty(channelDetails) {
|
||||
channelDetails = nil
|
||||
}
|
||||
apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{
|
||||
Username: ch.Username,
|
||||
ChannelID: ch.ChannelID,
|
||||
Comment: ch.Comment,
|
||||
Details: channelDetails,
|
||||
})
|
||||
@@ -1495,29 +1499,29 @@ func (s *PurchaseOptionalDetails) buildPlacementDetails(placementType *string) *
|
||||
return details
|
||||
}
|
||||
|
||||
func (s *PurchaseOptionalDetails) buildChannelDetails(username string) *backend.PlacementDetails {
|
||||
func (s *PurchaseOptionalDetails) buildChannelDetails(channelKey string) *backend.PlacementDetails {
|
||||
if len(s.Channels) <= 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
details := &backend.PlacementDetails{}
|
||||
if s.PlacementMode == "per_channel" {
|
||||
if value, ok := s.PlacementByChannel[username]; ok && value != nil {
|
||||
if value, ok := s.PlacementByChannel[channelKey]; ok && value != nil {
|
||||
formatted := value.Format(time.RFC3339)
|
||||
details.PlacementAt = &formatted
|
||||
}
|
||||
}
|
||||
if s.CostMode == "per_channel" {
|
||||
entry := s.CostByChannel[username]
|
||||
entry := s.CostByChannel[channelKey]
|
||||
details.Cost = s.buildCostInfo(entry.Type, entry.Value)
|
||||
}
|
||||
if s.CostBeforeMode == "per_channel" {
|
||||
if value, ok := s.CostBeforeByChannel[username]; ok && value != nil {
|
||||
if value, ok := s.CostBeforeByChannel[channelKey]; ok && value != nil {
|
||||
details.CostBeforeBargain = value
|
||||
}
|
||||
}
|
||||
if s.FormatMode == "per_channel" {
|
||||
if value, ok := s.FormatByChannel[username]; ok && value != "" {
|
||||
if value, ok := s.FormatByChannel[channelKey]; ok && value != "" {
|
||||
details.Format = &value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,10 @@ import (
|
||||
)
|
||||
|
||||
type PurchaseChannelInput struct {
|
||||
ChannelID string
|
||||
Username string
|
||||
Title string
|
||||
InviteLink string
|
||||
PlannedCost *float64
|
||||
Comment *string
|
||||
}
|
||||
@@ -48,15 +51,15 @@ func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.
|
||||
if len(s.Channels) == 0 {
|
||||
text += `Добавьте каналы для размещения рекламы
|
||||
|
||||
Отправьте username канала (без @)
|
||||
Например: <code>channel_name</code>
|
||||
Отправьте username (без @) или invite link приватного канала
|
||||
Например: <code>channel_name</code> или <code>https://t.me/+abcdef</code>
|
||||
|
||||
После добавления всех каналов нажмите <b>Далее</b>`
|
||||
} else {
|
||||
text += fmt.Sprintf("Добавлено каналов: <b>%d</b>\n\n", len(s.Channels))
|
||||
|
||||
for i, ch := range s.Channels {
|
||||
channelText := fmt.Sprintf(" %d. @%s", i+1, ch.Username)
|
||||
channelText := fmt.Sprintf(" %d. %s", i+1, channelLabel(ch))
|
||||
if ch.PlannedCost != nil {
|
||||
channelText += fmt.Sprintf(" — %.0f₽", *ch.PlannedCost)
|
||||
}
|
||||
@@ -73,9 +76,9 @@ func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.
|
||||
var slots []echotron.InlineKeyboardButton
|
||||
for i := start; i < end; i++ {
|
||||
ch := s.Channels[i]
|
||||
buttonText := fmt.Sprintf("@%s", ch.Username)
|
||||
buttonText := channelLabel(ch)
|
||||
if ch.PlannedCost != nil {
|
||||
buttonText = fmt.Sprintf("@%s — %.0f₽", ch.Username, *ch.PlannedCost)
|
||||
buttonText = fmt.Sprintf("%s — %.0f₽", channelLabel(ch), *ch.PlannedCost)
|
||||
}
|
||||
slots = append(slots, Button(buttonText, fmt.Sprintf("remove_channel:%d", i)))
|
||||
}
|
||||
@@ -161,7 +164,7 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем существование каналов через API
|
||||
// Создаем/обновляем каналы в БД через API
|
||||
jwt := b.Session.JWT
|
||||
if jwt == "" {
|
||||
log.Error().Msg("JWT is empty in session")
|
||||
@@ -169,11 +172,11 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
|
||||
return
|
||||
}
|
||||
|
||||
notFoundChannels := s.validateChannels(b, jwt)
|
||||
if len(notFoundChannels) > 0 {
|
||||
text := "❌ Следующие каналы не найдены:\n\n"
|
||||
for _, username := range notFoundChannels {
|
||||
text += fmt.Sprintf("• @%s\n", username)
|
||||
failed := s.resolveChannels(b, jwt)
|
||||
if len(failed) > 0 {
|
||||
text := "❌ Не удалось добавить каналы:\n\n"
|
||||
for _, label := range failed {
|
||||
text += fmt.Sprintf("• %s\n", label)
|
||||
}
|
||||
text += "\nУдалите их из списка и попробуйте снова"
|
||||
|
||||
@@ -242,7 +245,7 @@ func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) {
|
||||
}
|
||||
}
|
||||
apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{
|
||||
Username: ch.Username,
|
||||
ChannelID: ch.ChannelID,
|
||||
Comment: ch.Comment,
|
||||
Details: details,
|
||||
})
|
||||
@@ -294,22 +297,32 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update
|
||||
added := 0
|
||||
var invalid []string
|
||||
for _, token := range tokens {
|
||||
username := strings.TrimSpace(token)
|
||||
username = strings.TrimPrefix(username, "@")
|
||||
username = strings.Trim(username, "@")
|
||||
username = strings.Trim(username, ",;")
|
||||
|
||||
if username == "" {
|
||||
entry := strings.TrimSpace(token)
|
||||
entry = strings.Trim(entry, ",;")
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var username string
|
||||
var inviteLink string
|
||||
if isInviteLink(entry) {
|
||||
inviteLink = entry
|
||||
} else {
|
||||
username = strings.TrimPrefix(entry, "@")
|
||||
username = strings.Trim(username, "@")
|
||||
if !channelUsernameRe.MatchString(username) {
|
||||
invalid = append(invalid, username)
|
||||
invalid = append(invalid, entry)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
exists := false
|
||||
for _, ch := range s.Channels {
|
||||
if ch.Username == username {
|
||||
if inviteLink != "" && ch.InviteLink == inviteLink {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
if username != "" && ch.Username == username {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
@@ -320,6 +333,7 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update
|
||||
|
||||
s.Channels = append(s.Channels, PurchaseChannelInput{
|
||||
Username: username,
|
||||
InviteLink: inviteLink,
|
||||
})
|
||||
added++
|
||||
}
|
||||
@@ -336,37 +350,48 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
|
||||
func (s *SelectChannelsForPurchase) validateChannels(b *bot.Bot, jwt string) []string {
|
||||
var notFound []string
|
||||
|
||||
func (s *SelectChannelsForPurchase) resolveChannels(b *bot.Bot, jwt string) []string {
|
||||
inputs := make([]backend.CreateChannelInput, 0, len(s.Channels))
|
||||
for _, ch := range s.Channels {
|
||||
channels, err := b.Backend.SearchChannels(
|
||||
context.Background(),
|
||||
jwt,
|
||||
ch.Username,
|
||||
)
|
||||
if ch.InviteLink != "" {
|
||||
link := ch.InviteLink
|
||||
inputs = append(inputs, backend.CreateChannelInput{InviteLink: &link})
|
||||
} else {
|
||||
username := ch.Username
|
||||
inputs = append(inputs, backend.CreateChannelInput{Username: &username})
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := b.Backend.CreateChannels(context.Background(), jwt, backend.CreateChannelsInput{
|
||||
Channels: inputs,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("username", ch.Username).Msg("Failed to search channel")
|
||||
notFound = append(notFound, ch.Username)
|
||||
log.Error().Err(err).Msg("Failed to create channels")
|
||||
return []string{"ошибка сервера"}
|
||||
}
|
||||
|
||||
var failed []string
|
||||
for _, result := range resp.Results {
|
||||
if result.Status == "failed" || result.Channel == nil || result.Channel.ID == "" {
|
||||
if result.Index >= 0 && result.Index < len(s.Channels) {
|
||||
failed = append(failed, channelLabel(s.Channels[result.Index]))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Проверяем что нашли хотя бы один канал с точным совпадением username
|
||||
found := false
|
||||
for _, channel := range channels {
|
||||
if channel.Username != nil && *channel.Username == ch.Username {
|
||||
found = true
|
||||
break
|
||||
if result.Index < 0 || result.Index >= len(s.Channels) {
|
||||
continue
|
||||
}
|
||||
ch := &s.Channels[result.Index]
|
||||
ch.ChannelID = result.Channel.ID
|
||||
if result.Channel.Username != nil {
|
||||
ch.Username = *result.Channel.Username
|
||||
}
|
||||
if result.Channel.Title != nil {
|
||||
ch.Title = *result.Channel.Title
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
notFound = append(notFound, ch.Username)
|
||||
}
|
||||
}
|
||||
|
||||
return notFound
|
||||
return failed
|
||||
}
|
||||
|
||||
func (s *SelectChannelsForPurchase) Handle(b *bot.Bot, u *echotron.Update) {
|
||||
@@ -380,3 +405,52 @@ func (s *SelectChannelsForPurchase) Handle(b *bot.Bot, u *echotron.Update) {
|
||||
}
|
||||
|
||||
func (s *SelectChannelsForPurchase) Exit() {}
|
||||
|
||||
func channelLabel(ch PurchaseChannelInput) string {
|
||||
if ch.Username != "" {
|
||||
return "@" + ch.Username
|
||||
}
|
||||
if ch.Title != "" {
|
||||
return ch.Title
|
||||
}
|
||||
if ch.InviteLink != "" {
|
||||
return "invite link"
|
||||
}
|
||||
if ch.ChannelID != "" {
|
||||
return ch.ChannelID
|
||||
}
|
||||
return "канал"
|
||||
}
|
||||
|
||||
func channelKey(ch PurchaseChannelInput) string {
|
||||
if ch.ChannelID != "" {
|
||||
return ch.ChannelID
|
||||
}
|
||||
if ch.Username != "" {
|
||||
return ch.Username
|
||||
}
|
||||
if ch.InviteLink != "" {
|
||||
return ch.InviteLink
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func channelLabelByKey(channels []PurchaseChannelInput, key string) string {
|
||||
for _, ch := range channels {
|
||||
if channelKey(ch) == key {
|
||||
return channelLabel(ch)
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func isInviteLink(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if strings.Contains(value, "t.me/") || strings.Contains(value, "telegram.me/") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(value, "tg://") || strings.HasPrefix(value, "tg:") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ func (d *Database) GetChannels(ctx context.Context) []domain.Channel {
|
||||
username,
|
||||
title,
|
||||
access_hash,
|
||||
pts
|
||||
pts,
|
||||
invite_link
|
||||
FROM channel
|
||||
WHERE deleted_at IS NULL;`
|
||||
|
||||
@@ -54,6 +55,7 @@ type getChannelsDTO struct {
|
||||
Title pgtype.Text
|
||||
AccessHash pgtype.Int8
|
||||
Pts pgtype.Int4
|
||||
InviteLink pgtype.Text
|
||||
}
|
||||
|
||||
func (dto *getChannelsDTO) destination() []any {
|
||||
@@ -64,6 +66,7 @@ func (dto *getChannelsDTO) destination() []any {
|
||||
&dto.Title,
|
||||
&dto.AccessHash,
|
||||
&dto.Pts,
|
||||
&dto.InviteLink,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,5 +78,6 @@ func (dto *getChannelsDTO) toDomain() domain.Channel {
|
||||
Title: dto.Title.String,
|
||||
AccessHash: dto.AccessHash.Int64,
|
||||
Pts: int(dto.Pts.Int32),
|
||||
InviteLink: dto.InviteLink.String,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ func (d *Database) GetChannelsWithTrackedPosts(ctx context.Context) ([]domain.Ch
|
||||
c.username,
|
||||
c.title,
|
||||
c.access_hash,
|
||||
c.pts
|
||||
c.pts,
|
||||
c.invite_link
|
||||
FROM channel c
|
||||
INNER JOIN placement p ON p.channel_id = c.id
|
||||
INNER JOIN placement_post pp ON pp.placement_id = p.id
|
||||
@@ -55,6 +56,7 @@ type getChannelsWithTrackedPostsDTO struct {
|
||||
Title pgtype.Text
|
||||
AccessHash pgtype.Int8
|
||||
Pts pgtype.Int4
|
||||
InviteLink pgtype.Text
|
||||
}
|
||||
|
||||
func (dto *getChannelsWithTrackedPostsDTO) destination() []any {
|
||||
@@ -65,6 +67,7 @@ func (dto *getChannelsWithTrackedPostsDTO) destination() []any {
|
||||
&dto.Title,
|
||||
&dto.AccessHash,
|
||||
&dto.Pts,
|
||||
&dto.InviteLink,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,5 +79,6 @@ func (dto *getChannelsWithTrackedPostsDTO) toDomain() domain.Channel {
|
||||
Title: dto.Title.String,
|
||||
AccessHash: dto.AccessHash.Int64,
|
||||
Pts: int(dto.Pts.Int32),
|
||||
InviteLink: dto.InviteLink.String,
|
||||
}
|
||||
}
|
||||
|
||||
33
tg_parser/internal/adapter/telegram/pts.go
Normal file
33
tg_parser/internal/adapter/telegram/pts.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
|
||||
"github.com/gotd/td/tg"
|
||||
)
|
||||
|
||||
func (t *Telegram) GetChannelPTS(ctx context.Context, channel domain.Channel) (int, error) {
|
||||
req := []tg.InputDialogPeerClass{
|
||||
&tg.InputDialogPeer{
|
||||
Peer: &tg.InputPeerChannel{
|
||||
ChannelID: channel.ChannelID(),
|
||||
AccessHash: channel.AccessHash,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
dialogs, err := t.API().MessagesGetPeerDialogs(ctx, req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("peer dialogs: %w", err)
|
||||
}
|
||||
|
||||
if len(dialogs.Dialogs) > 0 {
|
||||
if d, ok := dialogs.Dialogs[0].(*tg.Dialog); ok {
|
||||
return d.Pts, nil
|
||||
}
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
130
tg_parser/internal/adapter/telegram/resolve_invite.go
Normal file
130
tg_parser/internal/adapter/telegram/resolve_invite.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gotd/td/telegram/deeplink"
|
||||
"github.com/gotd/td/tg"
|
||||
)
|
||||
|
||||
func (t *Telegram) ParseChannelMetaByInvite(ctx context.Context, inviteLink string) (domain.Channel, error) {
|
||||
link, err := deeplink.Parse(inviteLink)
|
||||
if err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("parse invite link: %w", err)
|
||||
}
|
||||
if link.Type != deeplink.Join {
|
||||
return domain.Channel{}, fmt.Errorf("invite link is not a join link")
|
||||
}
|
||||
hash := link.Args.Get("invite")
|
||||
if hash == "" {
|
||||
return domain.Channel{}, fmt.Errorf("invite link missing hash")
|
||||
}
|
||||
|
||||
info, err := t.API().MessagesCheckChatInvite(ctx, hash)
|
||||
if err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("check invite: %w", err)
|
||||
}
|
||||
|
||||
var channel *tg.Channel
|
||||
|
||||
switch v := info.(type) {
|
||||
case *tg.ChatInviteAlready:
|
||||
channel = extractChannelFromChat(v.Chat)
|
||||
case *tg.ChatInvite:
|
||||
updates, err := t.API().MessagesImportChatInvite(ctx, hash)
|
||||
if err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("import invite: %w", err)
|
||||
}
|
||||
channel = extractChannelFromUpdates(updates)
|
||||
default:
|
||||
return domain.Channel{}, fmt.Errorf("unexpected invite response: %T", v)
|
||||
}
|
||||
|
||||
if channel == nil {
|
||||
return domain.Channel{}, fmt.Errorf("no channel in invite response")
|
||||
}
|
||||
if channel.AccessHash == 0 {
|
||||
return domain.Channel{}, fmt.Errorf("channel access hash missing")
|
||||
}
|
||||
|
||||
pts, err := getChannelPTS(ctx, t.API(), channel)
|
||||
if err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("get peer dialogs: %w", err)
|
||||
}
|
||||
|
||||
username := ""
|
||||
if usernameVal, ok := channel.GetUsername(); ok {
|
||||
username = usernameVal
|
||||
}
|
||||
|
||||
return domain.Channel{
|
||||
ID: uuid.New(),
|
||||
TelegramID: domain.ChatIDFromChannelID(channel.ID),
|
||||
Username: username,
|
||||
Title: channel.Title,
|
||||
AccessHash: channel.AccessHash,
|
||||
Pts: pts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func extractChannelFromChat(chat tg.ChatClass) *tg.Channel {
|
||||
switch v := chat.(type) {
|
||||
case *tg.Channel:
|
||||
return v
|
||||
case *tg.ChannelForbidden:
|
||||
return &tg.Channel{
|
||||
ID: v.ID,
|
||||
AccessHash: v.AccessHash,
|
||||
Title: v.Title,
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func extractChannelFromUpdates(updates tg.UpdatesClass) *tg.Channel {
|
||||
var chats []tg.ChatClass
|
||||
switch v := updates.(type) {
|
||||
case *tg.Updates:
|
||||
chats = v.Chats
|
||||
case *tg.UpdatesCombined:
|
||||
chats = v.Chats
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, chat := range chats {
|
||||
if channel := extractChannelFromChat(chat); channel != nil {
|
||||
return channel
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getChannelPTS(ctx context.Context, api *tg.Client, channel *tg.Channel) (int, error) {
|
||||
req := []tg.InputDialogPeerClass{
|
||||
&tg.InputDialogPeer{
|
||||
Peer: &tg.InputPeerChannel{
|
||||
ChannelID: channel.ID,
|
||||
AccessHash: channel.AccessHash,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
dialogs, err := api.MessagesGetPeerDialogs(ctx, req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if len(dialogs.Dialogs) > 0 {
|
||||
if d, ok := dialogs.Dialogs[0].(*tg.Dialog); ok {
|
||||
return d.Pts, nil
|
||||
}
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
@@ -62,6 +62,58 @@ func New(uc *usecase.UseCase, addr string) *Server {
|
||||
log.Error().Err(err).Msg("encode channel response")
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/resolve-channel-by-invite", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
InviteLink string `json:"invite_link"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
inviteLink := strings.TrimSpace(payload.InviteLink)
|
||||
if inviteLink == "" {
|
||||
http.Error(w, "missing invite_link", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
channel, err := uc.FetchChannelMetaByInvite(r.Context(), inviteLink)
|
||||
if err != nil {
|
||||
if isNotFoundError(err) {
|
||||
http.Error(w, "channel not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Error().Err(err).Msg("resolve channel by invite failed")
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp := struct {
|
||||
ID string `json:"id"`
|
||||
TelegramID int64 `json:"telegram_id"`
|
||||
Username string `json:"username"`
|
||||
Title string `json:"title"`
|
||||
AccessHash int64 `json:"access_hash"`
|
||||
Pts int `json:"pts"`
|
||||
}{
|
||||
ID: channel.ID.String(),
|
||||
TelegramID: channel.TelegramID,
|
||||
Username: channel.Username,
|
||||
Title: channel.Title,
|
||||
AccessHash: channel.AccessHash,
|
||||
Pts: channel.Pts,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
log.Error().Err(err).Msg("encode channel response")
|
||||
}
|
||||
})
|
||||
|
||||
return &Server{
|
||||
httpServer: &http.Server{
|
||||
@@ -80,11 +132,21 @@ func (s *Server) Shutdown(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func isNotFoundError(err error) bool {
|
||||
if tgerr.Is(err, "USERNAME_NOT_OCCUPIED", "USERNAME_INVALID", "CHANNEL_INVALID", "CHANNEL_PRIVATE") {
|
||||
if tgerr.Is(
|
||||
err,
|
||||
"USERNAME_NOT_OCCUPIED",
|
||||
"USERNAME_INVALID",
|
||||
"CHANNEL_INVALID",
|
||||
"CHANNEL_PRIVATE",
|
||||
"INVITE_HASH_INVALID",
|
||||
"INVITE_HASH_EXPIRED",
|
||||
"INVITE_HASH_EMPTY",
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "no chats in resolve result") ||
|
||||
strings.Contains(msg, "not a channel")
|
||||
strings.Contains(msg, "not a channel") ||
|
||||
strings.Contains(msg, "invite link")
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ type Channel struct {
|
||||
Title string
|
||||
AccessHash int64
|
||||
Pts int
|
||||
InviteLink string
|
||||
}
|
||||
|
||||
const channelIDOffset int64 = 1000000000000
|
||||
@@ -47,13 +48,14 @@ func NormalizeChatID(id int64) int64 {
|
||||
|
||||
func (c Channel) String() string {
|
||||
return fmt.Sprintf(
|
||||
"Channel{id=%s telegram_id=%d username=%q title=%q access_hash=%d pts=%d}",
|
||||
"Channel{id=%s telegram_id=%d username=%q title=%q access_hash=%d pts=%d invite_link=%t}",
|
||||
c.ID,
|
||||
c.TelegramID,
|
||||
c.Username,
|
||||
c.Title,
|
||||
c.AccessHash,
|
||||
c.Pts,
|
||||
c.InviteLink != "",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,12 +16,16 @@ type Post struct {
|
||||
}
|
||||
|
||||
func NewPost(channel Channel, messageID int, text string, views int) Post {
|
||||
link := ""
|
||||
if channel.Username != "" {
|
||||
link = fmt.Sprintf("https://t.me/%s/%d", channel.Username, messageID)
|
||||
}
|
||||
return Post{
|
||||
ID: uuid.New(),
|
||||
ChannelID: channel.ID,
|
||||
MessageID: messageID,
|
||||
Text: text,
|
||||
Link: fmt.Sprintf("https://t.me/%s/%d", channel.Username, messageID),
|
||||
Link: link,
|
||||
Views: views,
|
||||
}
|
||||
}
|
||||
|
||||
11
tg_parser/internal/usecase/fetch_channel_meta_by_invite.go
Normal file
11
tg_parser/internal/usecase/fetch_channel_meta_by_invite.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
|
||||
)
|
||||
|
||||
func (uc *UseCase) FetchChannelMetaByInvite(ctx context.Context, inviteLink string) (domain.Channel, error) {
|
||||
return uc.telegram.ParseChannelMetaByInvite(ctx, inviteLink)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/TelegramExchange/pkg/transaction"
|
||||
"github.com/gotd/td/tgerr"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
|
||||
@@ -23,6 +24,11 @@ func (uc *UseCase) FetchChannels(ctx context.Context) error {
|
||||
c = updatedChannel
|
||||
}
|
||||
|
||||
if c.Pts == 0 {
|
||||
log.Warn().Int64("telegram_id", c.TelegramID).Msg("Channel pts still empty, skipping diff")
|
||||
continue
|
||||
}
|
||||
|
||||
err := uc.processChannel(ctx, c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("uc.processChannel: %w", err)
|
||||
@@ -35,6 +41,25 @@ func (uc *UseCase) FetchChannels(ctx context.Context) error {
|
||||
func (uc *UseCase) processChannel(ctx context.Context, channel domain.Channel) error {
|
||||
diff, err := uc.telegram.GetChannelDiff(ctx, channel, 20)
|
||||
if err != nil {
|
||||
if isPrivateChannelError(err) {
|
||||
log.Warn().Err(err).Int64("telegram_id", channel.TelegramID).Msg("Private channel, attempting rejoin")
|
||||
if channel.InviteLink == "" {
|
||||
log.Warn().Int64("telegram_id", channel.TelegramID).Msg("No invite link stored, skipping channel")
|
||||
return nil
|
||||
}
|
||||
updatedChannel, joinErr := uc.telegram.ParseChannelMetaByInvite(ctx, channel.InviteLink)
|
||||
if joinErr != nil {
|
||||
log.Warn().Err(joinErr).Int64("telegram_id", channel.TelegramID).Msg("Invite rejoin failed")
|
||||
return nil
|
||||
}
|
||||
updatedChannel.ID = channel.ID
|
||||
updatedChannel.InviteLink = channel.InviteLink
|
||||
updatedChannel.Pts = 0
|
||||
if err := uc.database.UpdateChannel(ctx, updatedChannel); err != nil {
|
||||
return fmt.Errorf("database.UpdateChannel: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("telegram.GetChannelDiff: %w", err)
|
||||
}
|
||||
|
||||
@@ -74,11 +99,27 @@ func (uc *UseCase) processChannel(ctx context.Context, channel domain.Channel) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func isPrivateChannelError(err error) bool {
|
||||
return tgerr.Is(err, "CHANNEL_PRIVATE", "CHANNEL_INVALID", "CHANNEL_FORBIDDEN")
|
||||
}
|
||||
|
||||
func (uc *UseCase) initializeChannel(ctx context.Context, channel domain.Channel) (domain.Channel, error) {
|
||||
c, err := uc.telegram.ParseChannelMeta(ctx, channel.Username)
|
||||
c := channel
|
||||
if channel.Username != "" {
|
||||
updated, err := uc.telegram.ParseChannelMeta(ctx, channel.Username)
|
||||
if err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("telegram.ParseChannelMeta: %w", err)
|
||||
}
|
||||
c = updated
|
||||
} else if channel.AccessHash == 0 {
|
||||
return domain.Channel{}, fmt.Errorf("channel %s missing access hash", channel.ID)
|
||||
} else {
|
||||
pts, err := uc.telegram.GetChannelPTS(ctx, channel)
|
||||
if err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("telegram.GetChannelPTS: %w", err)
|
||||
}
|
||||
c.Pts = pts
|
||||
}
|
||||
|
||||
// Preserve persistent channel ID from the database so foreign keys remain valid.
|
||||
c.ID = channel.ID
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
|
||||
type Telegram interface {
|
||||
ParseChannelMeta(ctx context.Context, username string) (domain.Channel, error)
|
||||
ParseChannelMetaByInvite(ctx context.Context, inviteLink string) (domain.Channel, error)
|
||||
GetChannelPTS(ctx context.Context, channel domain.Channel) (int, error)
|
||||
GetChannelHistory(ctx context.Context, channel domain.Channel, limit int) ([]domain.Post, error)
|
||||
GetChannelDiff(ctx context.Context, channel domain.Channel, limit int) (domain.ChannelDiff, error)
|
||||
UpdatePostsViews(ctx context.Context, channel domain.Channel, posts []domain.Post) error
|
||||
|
||||
Reference in New Issue
Block a user