приватные каналы
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):
|
class FetchChannelResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
telegram_id: int
|
telegram_id: int
|
||||||
username: str
|
username: str | None
|
||||||
title: str
|
title: str | None
|
||||||
access_hash: int
|
access_hash: int
|
||||||
pts: int
|
pts: int
|
||||||
|
|
||||||
@@ -52,3 +52,33 @@ class ParserClient(Parser):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error('Parser unexpected error for @%s: %s', username, e)
|
log.error('Parser unexpected error for @%s: %s', username, e)
|
||||||
raise
|
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]
|
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}')
|
@channels_router.get('/{channel_id}')
|
||||||
async def get_channel(
|
async def get_channel(
|
||||||
channel_id: uuid.UUID, _: Annotated[JWTPayload, Depends(deps.get_current_user)]
|
channel_id: uuid.UUID, _: Annotated[JWTPayload, Depends(deps.get_current_user)]
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ from .base import TimestampedModel
|
|||||||
class Channel(TimestampedModel):
|
class Channel(TimestampedModel):
|
||||||
telegram_id = fields.BigIntField(unique=True, index=True)
|
telegram_id = fields.BigIntField(unique=True, index=True)
|
||||||
title = fields.CharField(max_length=255)
|
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)
|
access_hash = fields.BigIntField(null=True)
|
||||||
pts = fields.IntField(null=True)
|
pts = fields.IntField(null=True)
|
||||||
|
invite_link = fields.CharField(max_length=1024, null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = 'channel'
|
table = 'channel'
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ __all__ = (
|
|||||||
'ChannelOutput',
|
'ChannelOutput',
|
||||||
'GetChannelInput',
|
'GetChannelInput',
|
||||||
'GetChannelsInput',
|
'GetChannelsInput',
|
||||||
|
'CreateChannelInput',
|
||||||
|
'CreateChannelsInput',
|
||||||
|
'CreateChannelResult',
|
||||||
|
'CreateChannelsOutput',
|
||||||
'GetChannelsOutput',
|
'GetChannelsOutput',
|
||||||
'AttachChannelToWorkspaceInput',
|
'AttachChannelToWorkspaceInput',
|
||||||
'PlacementOutput',
|
'PlacementOutput',
|
||||||
@@ -121,6 +125,10 @@ from .channel import (
|
|||||||
ChannelOutput,
|
ChannelOutput,
|
||||||
GetChannelInput,
|
GetChannelInput,
|
||||||
GetChannelsInput,
|
GetChannelsInput,
|
||||||
|
CreateChannelInput,
|
||||||
|
CreateChannelsInput,
|
||||||
|
CreateChannelResult,
|
||||||
|
CreateChannelsOutput,
|
||||||
GetChannelsOutput,
|
GetChannelsOutput,
|
||||||
)
|
)
|
||||||
from .creative import (
|
from .creative import (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
import pydantic
|
import pydantic
|
||||||
|
|
||||||
@@ -26,3 +27,54 @@ class AttachChannelToWorkspaceInput(pydantic.BaseModel):
|
|||||||
channel_id: uuid.UUID
|
channel_id: uuid.UUID
|
||||||
workspace_id: uuid.UUID
|
workspace_id: uuid.UUID
|
||||||
user_telegram_id: int
|
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):
|
class CreatePlacementChannelInput(pydantic.BaseModel):
|
||||||
"""Input для создания одного placement (channel + детали)"""
|
"""Input для создания одного placement (channel + детали)"""
|
||||||
|
|
||||||
username: str = pydantic.Field(pattern=r'^[^@]')
|
channel_id: uuid.UUID
|
||||||
status: PlacementStatus | None = None
|
status: PlacementStatus | None = None
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
details: PlacementDetails | 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.get_me import get_me
|
||||||
from .auth.validate_login_token import validate_login_token
|
from .auth.validate_login_token import validate_login_token
|
||||||
from .channel.attach_channel_to_workspace import attach_channel_to_workspace
|
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_channel import get_channel
|
||||||
from .channel.get_channels import get_channels
|
from .channel.get_channels import get_channels
|
||||||
from .creative.create_creative import create_creative
|
from .creative.create_creative import create_creative
|
||||||
@@ -98,12 +99,15 @@ class JWTEncoder(typing.Protocol):
|
|||||||
|
|
||||||
class FetchChannelResponse(typing.Protocol):
|
class FetchChannelResponse(typing.Protocol):
|
||||||
telegram_id: int
|
telegram_id: int
|
||||||
username: str
|
username: str | None
|
||||||
title: str
|
title: str | None
|
||||||
|
access_hash: int | None
|
||||||
|
pts: int | None
|
||||||
|
|
||||||
|
|
||||||
class Parser(typing.Protocol):
|
class Parser(typing.Protocol):
|
||||||
async def fetch_telegram_channel(self, username: str) -> FetchChannelResponse | None: ...
|
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):
|
class S3Storage(typing.Protocol):
|
||||||
@@ -185,6 +189,7 @@ class Usecase:
|
|||||||
update_project_permissions = update_project_permissions
|
update_project_permissions = update_project_permissions
|
||||||
update_project_invite_link_type = update_project_invite_link_type
|
update_project_invite_link_type = update_project_invite_link_type
|
||||||
get_channels = get_channels
|
get_channels = get_channels
|
||||||
|
create_channels = create_channels
|
||||||
get_channel = get_channel
|
get_channel = get_channel
|
||||||
attach_channel_to_workspace = attach_channel_to_workspace
|
attach_channel_to_workspace = attach_channel_to_workspace
|
||||||
# Placement (user-managed) use cases
|
# 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
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from src import domain, dto
|
from src import dto
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .. import Usecase
|
from .. import Usecase
|
||||||
@@ -12,44 +12,6 @@ log = logging.getLogger(__name__)
|
|||||||
async def get_channels(self: 'Usecase', input: dto.GetChannelsInput) -> list[dto.ChannelOutput]:
|
async def get_channels(self: 'Usecase', input: dto.GetChannelsInput) -> list[dto.ChannelOutput]:
|
||||||
channels = await self.database.search_channels(username_query=input.username)
|
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)
|
log.debug('Found %s channels for username query: %s', len(channels), input.username)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -29,13 +29,11 @@ async def move_project_to_workspace(
|
|||||||
if not target_workspace:
|
if not target_workspace:
|
||||||
raise domain.WorkspaceNotFound(target_workspace_id)
|
raise domain.WorkspaceNotFound(target_workspace_id)
|
||||||
|
|
||||||
# Проверяем permissions в source workspace (scope на конкретный project)
|
# Проверяем права владельца в source workspace
|
||||||
await self.ensure_workspace_permission(
|
await self.ensure_workspace_permission(source_workspace_id, user_id, domain.PermissionKey.ADMIN_FULL)
|
||||||
source_workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE, for_project_id=project_id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Проверяем permissions в target workspace
|
# Проверяем права владельца в target workspace
|
||||||
await self.ensure_workspace_permission(target_workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE)
|
await self.ensure_workspace_permission(target_workspace_id, user_id, domain.PermissionKey.ADMIN_FULL)
|
||||||
|
|
||||||
# Проверяем что канал не существует в target workspace (UNIQUE constraint)
|
# Проверяем что канал не существует в target workspace (UNIQUE constraint)
|
||||||
if await self.database.check_channel_exists_in_workspace(project.channel.id, target_workspace_id):
|
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)
|
workspace = await self.get_or_create_personal_workspace(user)
|
||||||
workspaces = [workspace]
|
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:
|
if len(workspaces) == 1:
|
||||||
workspace = workspaces[0]
|
workspace = workspaces[0]
|
||||||
|
|
||||||
channel = await self.database.get_channel(telegram_id=input.telegram_id)
|
channel = await self.database.get_channel(telegram_id=input.telegram_id)
|
||||||
if channel:
|
if channel:
|
||||||
channel.title = input.title
|
if update_channel_meta(channel):
|
||||||
if input.username is not None:
|
await self.database.update_channel(channel)
|
||||||
channel.username = input.username
|
|
||||||
await self.database.update_channel(channel)
|
|
||||||
else:
|
else:
|
||||||
if input.username is None:
|
username = build_channel_username()
|
||||||
log.warning('Cannot create channel %s without username', input.telegram_id)
|
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(
|
await self.telegram_bot.send_message(
|
||||||
f'⚠️ Канал "{input.title}" не может быть подключен.\n\nУ канала отсутствует публичный username.',
|
f'⚠️ Канал "{input.title}" не может быть подключен.\n\n'
|
||||||
|
'У канала отсутствует публичный username и не удалось получить invite link.',
|
||||||
input.user_telegram_id,
|
input.user_telegram_id,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
channel = domain.Channel(
|
channel = domain.Channel(
|
||||||
telegram_id=input.telegram_id,
|
telegram_id=build_channel_telegram_id(),
|
||||||
title=input.title,
|
title=build_channel_title(),
|
||||||
username=input.username,
|
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)
|
await self.database.create_channel(channel)
|
||||||
|
|
||||||
@@ -139,23 +201,26 @@ async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto
|
|||||||
# Если >1 workspace - создаем/обновляем канал и отправляем уведомление
|
# Если >1 workspace - создаем/обновляем канал и отправляем уведомление
|
||||||
channel = await self.database.get_channel(telegram_id=input.telegram_id)
|
channel = await self.database.get_channel(telegram_id=input.telegram_id)
|
||||||
if channel:
|
if channel:
|
||||||
channel.title = input.title
|
if update_channel_meta(channel):
|
||||||
if input.username is not None:
|
await self.database.update_channel(channel)
|
||||||
channel.username = input.username
|
|
||||||
await self.database.update_channel(channel)
|
|
||||||
else:
|
else:
|
||||||
if input.username is None:
|
username = build_channel_username()
|
||||||
log.warning('Cannot create channel %s without username', input.telegram_id)
|
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(
|
await self.telegram_bot.send_message(
|
||||||
f'⚠️ Канал "{input.title}" не может быть подключен.\n\nУ канала отсутствует публичный username.',
|
f'⚠️ Канал "{input.title}" не может быть подключен.\n\n'
|
||||||
|
'У канала отсутствует публичный username и не удалось получить invite link.',
|
||||||
input.user_telegram_id,
|
input.user_telegram_id,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
channel = domain.Channel(
|
channel = domain.Channel(
|
||||||
telegram_id=input.telegram_id,
|
telegram_id=build_channel_telegram_id(),
|
||||||
title=input.title,
|
title=build_channel_title(),
|
||||||
username=input.username,
|
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)
|
await self.database.create_channel(channel)
|
||||||
|
|
||||||
|
|||||||
@@ -79,20 +79,9 @@ async def create_placements(
|
|||||||
creatives_by_id: dict[uuid.UUID, domain.Creative] = {}
|
creatives_by_id: dict[uuid.UUID, domain.Creative] = {}
|
||||||
|
|
||||||
for channel_input in input.channels:
|
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:
|
if not channel:
|
||||||
parser_response = await self.parser.fetch_telegram_channel(channel_input.username)
|
raise domain.ChannelNotFound(channel_input.channel_id)
|
||||||
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)
|
|
||||||
|
|
||||||
# Объединяем общие детали с деталями конкретного канала
|
# Объединяем общие детали с деталями конкретного канала
|
||||||
channel_details = channel_input.details
|
channel_details = channel_input.details
|
||||||
|
|||||||
@@ -542,6 +542,26 @@ type Channel struct {
|
|||||||
Username *string `json:"username"`
|
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 CostInfo struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Value float64 `json:"value"`
|
Value float64 `json:"value"`
|
||||||
@@ -588,10 +608,10 @@ type PostOutput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreatePlacementChannelInput struct {
|
type CreatePlacementChannelInput struct {
|
||||||
Username string `json:"username"`
|
ChannelID string `json:"channel_id"`
|
||||||
Status *string `json:"status,omitempty"`
|
Status *string `json:"status,omitempty"`
|
||||||
Comment *string `json:"comment,omitempty"`
|
Comment *string `json:"comment,omitempty"`
|
||||||
Details *PlacementDetails `json:"details,omitempty"`
|
Details *PlacementDetails `json:"details,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreatePlacementsInput struct {
|
type CreatePlacementsInput struct {
|
||||||
@@ -635,6 +655,24 @@ func (c *Client) CreatePlacements(
|
|||||||
return &placements, err
|
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(
|
func (c *Client) GetPlacements(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
jwt string,
|
jwt string,
|
||||||
|
|||||||
@@ -521,7 +521,7 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM
|
|||||||
case "cost_value":
|
case "cost_value":
|
||||||
text += "<b>Ввод стоимости</b>\n\nНапример: <code>15000</code>"
|
text += "<b>Ввод стоимости</b>\n\nНапример: <code>15000</code>"
|
||||||
if s.CurrentChannel != "" {
|
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()
|
typeLabel := s.costTypeLabelForCurrent()
|
||||||
@@ -539,12 +539,12 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM
|
|||||||
case "cost_before_value":
|
case "cost_before_value":
|
||||||
text += "<b>Ввод стоимости до торга</b>\n\nНапример: <code>20000</code>"
|
text += "<b>Ввод стоимости до торга</b>\n\nНапример: <code>20000</code>"
|
||||||
if s.CurrentChannel != "" {
|
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":
|
case "format_select":
|
||||||
text = "<i>Страница ввода формата размещения</i>\n\n"
|
text = "<i>Страница ввода формата размещения</i>\n\n"
|
||||||
if s.CurrentChannel != "" {
|
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"
|
text += "Выберите формат\n\n"
|
||||||
keyboard := Keyboard(
|
keyboard := Keyboard(
|
||||||
@@ -707,7 +707,7 @@ func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.Rende
|
|||||||
// Список каналов с кнопками в зависимости от режима
|
// Список каналов с кнопками в зависимости от режима
|
||||||
for i := start; i < end; i++ {
|
for i := start; i < end; i++ {
|
||||||
label := fmt.Sprintf("%d", i+1)
|
label := fmt.Sprintf("%d", i+1)
|
||||||
channelUsername := s.Channels[i].Username
|
channelKey := channelKey(s.Channels[i])
|
||||||
|
|
||||||
if s.ChannelEditMode == "copy" {
|
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)))
|
channelRow = append(channelRow, Button("⌫", fmt.Sprintf("param_clear:%s:%d", param, i)))
|
||||||
} else {
|
} else {
|
||||||
channelRow = append(channelRow, Button(" ", "noop"))
|
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)))
|
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 {
|
if hasValue {
|
||||||
channelRow = append(channelRow, Button("Изменить", fmt.Sprintf("param_edit:%s:%d", param, i)))
|
channelRow = append(channelRow, Button("Изменить", fmt.Sprintf("param_edit:%s:%d", param, i)))
|
||||||
} else {
|
} else {
|
||||||
@@ -772,8 +772,9 @@ func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.Rende
|
|||||||
func (s *PurchaseOptionalDetails) renderParamChannelSummary(param string) string {
|
func (s *PurchaseOptionalDetails) renderParamChannelSummary(param string) string {
|
||||||
var lines []string
|
var lines []string
|
||||||
for i, ch := range s.Channels {
|
for i, ch := range s.Channels {
|
||||||
value := s.formatParamValue(param, ch.Username)
|
channelKey := channelKey(ch)
|
||||||
lines = append(lines, fmt.Sprintf("%d. @%s — %s", i+1, ch.Username, value))
|
value := s.formatParamValue(param, channelKey)
|
||||||
|
lines = append(lines, fmt.Sprintf("%d. %s — %s", i+1, channelLabel(ch), value))
|
||||||
}
|
}
|
||||||
return strings.Join(lines, "\n")
|
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) {
|
if index >= 0 && index < len(s.Channels) {
|
||||||
s.CurrentParam = param
|
s.CurrentParam = param
|
||||||
s.CurrentChannel = s.Channels[index].Username
|
s.CurrentChannel = channelKey(s.Channels[index])
|
||||||
s.openChannelEditor(b, param, s.CurrentChannel)
|
s.openChannelEditor(b, param, s.CurrentChannel)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -801,8 +802,8 @@ func (s *PurchaseOptionalDetails) handleParamCallback(b *bot.Bot, data string) b
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if index >= 0 && index < len(s.Channels) {
|
if index >= 0 && index < len(s.Channels) {
|
||||||
username := s.Channels[index].Username
|
channelKey := channelKey(s.Channels[index])
|
||||||
s.copyParamValue(param, username)
|
s.copyParamValue(param, channelKey)
|
||||||
s.Enter(b, bot.EditMessage)
|
s.Enter(b, bot.EditMessage)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -815,8 +816,8 @@ func (s *PurchaseOptionalDetails) handleParamCallback(b *bot.Bot, data string) b
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if index >= 0 && index < len(s.Channels) {
|
if index >= 0 && index < len(s.Channels) {
|
||||||
username := s.Channels[index].Username
|
channelKey := channelKey(s.Channels[index])
|
||||||
s.pasteParamValue(param, username)
|
s.pasteParamValue(param, channelKey)
|
||||||
s.Enter(b, bot.EditMessage)
|
s.Enter(b, bot.EditMessage)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -829,8 +830,8 @@ func (s *PurchaseOptionalDetails) handleParamCallback(b *bot.Bot, data string) b
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if index >= 0 && index < len(s.Channels) {
|
if index >= 0 && index < len(s.Channels) {
|
||||||
username := s.Channels[index].Username
|
channelKey := channelKey(s.Channels[index])
|
||||||
s.clearParamValue(param, username)
|
s.clearParamValue(param, channelKey)
|
||||||
s.Enter(b, bot.EditMessage)
|
s.Enter(b, bot.EditMessage)
|
||||||
return true
|
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 {
|
switch param {
|
||||||
case "placement":
|
case "placement":
|
||||||
s.InputMode = "placement_channels"
|
s.InputMode = "placement_channels"
|
||||||
b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{
|
b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{
|
||||||
Title: "Дата и время размещения",
|
Title: "Дата и время размещения",
|
||||||
Key: "placement_datetime:" + username,
|
Key: "placement_datetime:" + channelKey,
|
||||||
IncludeTime: true,
|
IncludeTime: true,
|
||||||
Selected: s.PlacementByChannel[username],
|
Selected: s.PlacementByChannel[channelKey],
|
||||||
BackState: s,
|
BackState: s,
|
||||||
}), bot.EditMessage)
|
}), bot.EditMessage)
|
||||||
case "cost":
|
case "cost":
|
||||||
s.InputMode = "cost_value"
|
s.InputMode = "cost_value"
|
||||||
s.ReturnMode = "cost_channels"
|
s.ReturnMode = "cost_channels"
|
||||||
s.CurrentChannel = username
|
s.CurrentChannel = channelKey
|
||||||
s.Enter(b, bot.EditMessage)
|
s.Enter(b, bot.EditMessage)
|
||||||
case "cost_before":
|
case "cost_before":
|
||||||
s.InputMode = "cost_before_value"
|
s.InputMode = "cost_before_value"
|
||||||
s.ReturnMode = "cost_before_channels"
|
s.ReturnMode = "cost_before_channels"
|
||||||
s.CurrentChannel = username
|
s.CurrentChannel = channelKey
|
||||||
s.Enter(b, bot.EditMessage)
|
s.Enter(b, bot.EditMessage)
|
||||||
case "format":
|
case "format":
|
||||||
s.InputMode = "format_select"
|
s.InputMode = "format_select"
|
||||||
s.ReturnMode = "format_channels"
|
s.ReturnMode = "format_channels"
|
||||||
s.CurrentChannel = username
|
s.CurrentChannel = channelKey
|
||||||
s.Enter(b, bot.EditMessage)
|
s.Enter(b, bot.EditMessage)
|
||||||
default:
|
default:
|
||||||
s.InputMode = ""
|
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 {
|
switch param {
|
||||||
case "placement":
|
case "placement":
|
||||||
s.PlacementCopy = s.PlacementByChannel[username]
|
s.PlacementCopy = s.PlacementByChannel[channelKey]
|
||||||
case "cost":
|
case "cost":
|
||||||
entry := s.CostByChannel[username]
|
entry := s.CostByChannel[channelKey]
|
||||||
copied := entry
|
copied := entry
|
||||||
if entry.Value == nil {
|
if entry.Value == nil {
|
||||||
copied.Value = nil
|
copied.Value = nil
|
||||||
@@ -949,9 +950,9 @@ func (s *PurchaseOptionalDetails) copyParamValue(param, username string) {
|
|||||||
}
|
}
|
||||||
s.CostCopy = &copied
|
s.CostCopy = &copied
|
||||||
case "cost_before":
|
case "cost_before":
|
||||||
s.CostBeforeCopy = s.CostBeforeByChannel[username]
|
s.CostBeforeCopy = s.CostBeforeByChannel[channelKey]
|
||||||
case "format":
|
case "format":
|
||||||
if value, ok := s.FormatByChannel[username]; ok {
|
if value, ok := s.FormatByChannel[channelKey]; ok {
|
||||||
copied := value
|
copied := value
|
||||||
s.FormatCopy = &copied
|
s.FormatCopy = &copied
|
||||||
} else {
|
} 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 {
|
switch param {
|
||||||
case "placement":
|
case "placement":
|
||||||
if s.PlacementCopy == nil {
|
if s.PlacementCopy == nil {
|
||||||
s.PlacementByChannel[username] = nil
|
s.PlacementByChannel[channelKey] = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
value := s.PlacementCopy.In(ui2.MskLocation)
|
value := s.PlacementCopy.In(ui2.MskLocation)
|
||||||
s.PlacementByChannel[username] = &value
|
s.PlacementByChannel[channelKey] = &value
|
||||||
case "cost":
|
case "cost":
|
||||||
if s.CostCopy == nil {
|
if s.CostCopy == nil {
|
||||||
delete(s.CostByChannel, username)
|
delete(s.CostByChannel, channelKey)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
copied := *s.CostCopy
|
copied := *s.CostCopy
|
||||||
@@ -979,20 +980,20 @@ func (s *PurchaseOptionalDetails) pasteParamValue(param, username string) {
|
|||||||
value := *copied.Value
|
value := *copied.Value
|
||||||
copied.Value = &value
|
copied.Value = &value
|
||||||
}
|
}
|
||||||
s.CostByChannel[username] = copied
|
s.CostByChannel[channelKey] = copied
|
||||||
case "cost_before":
|
case "cost_before":
|
||||||
if s.CostBeforeCopy == nil {
|
if s.CostBeforeCopy == nil {
|
||||||
s.CostBeforeByChannel[username] = nil
|
s.CostBeforeByChannel[channelKey] = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
value := *s.CostBeforeCopy
|
value := *s.CostBeforeCopy
|
||||||
s.CostBeforeByChannel[username] = &value
|
s.CostBeforeByChannel[channelKey] = &value
|
||||||
case "format":
|
case "format":
|
||||||
if s.FormatCopy == nil {
|
if s.FormatCopy == nil {
|
||||||
delete(s.FormatByChannel, username)
|
delete(s.FormatByChannel, channelKey)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.FormatByChannel[username] = *s.FormatCopy
|
s.FormatByChannel[channelKey] = *s.FormatCopy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1000,33 +1001,33 @@ func (s *PurchaseOptionalDetails) applyParamToAll(param string) {
|
|||||||
switch param {
|
switch param {
|
||||||
case "placement":
|
case "placement":
|
||||||
for _, ch := range s.Channels {
|
for _, ch := range s.Channels {
|
||||||
s.pasteParamValue(param, ch.Username)
|
s.pasteParamValue(param, channelKey(ch))
|
||||||
}
|
}
|
||||||
case "cost":
|
case "cost":
|
||||||
for _, ch := range s.Channels {
|
for _, ch := range s.Channels {
|
||||||
s.pasteParamValue(param, ch.Username)
|
s.pasteParamValue(param, channelKey(ch))
|
||||||
}
|
}
|
||||||
case "cost_before":
|
case "cost_before":
|
||||||
for _, ch := range s.Channels {
|
for _, ch := range s.Channels {
|
||||||
s.pasteParamValue(param, ch.Username)
|
s.pasteParamValue(param, channelKey(ch))
|
||||||
}
|
}
|
||||||
case "format":
|
case "format":
|
||||||
for _, ch := range s.Channels {
|
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 {
|
switch param {
|
||||||
case "placement":
|
case "placement":
|
||||||
s.PlacementByChannel[username] = nil
|
s.PlacementByChannel[channelKey] = nil
|
||||||
case "cost":
|
case "cost":
|
||||||
delete(s.CostByChannel, username)
|
delete(s.CostByChannel, channelKey)
|
||||||
case "cost_before":
|
case "cost_before":
|
||||||
s.CostBeforeByChannel[username] = nil
|
s.CostBeforeByChannel[channelKey] = nil
|
||||||
case "format":
|
case "format":
|
||||||
delete(s.FormatByChannel, username)
|
delete(s.FormatByChannel, channelKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1116,7 +1117,10 @@ func (s *PurchaseOptionalDetails) ensureDefaults() {
|
|||||||
func (s *PurchaseOptionalDetails) syncChannelMaps() {
|
func (s *PurchaseOptionalDetails) syncChannelMaps() {
|
||||||
valid := make(map[string]struct{}, len(s.Channels))
|
valid := make(map[string]struct{}, len(s.Channels))
|
||||||
for _, ch := range s.Channels {
|
for _, ch := range s.Channels {
|
||||||
valid[ch.Username] = struct{}{}
|
key := channelKey(ch)
|
||||||
|
if key != "" {
|
||||||
|
valid[key] = struct{}{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for key := range s.PlacementByChannel {
|
for key := range s.PlacementByChannel {
|
||||||
if _, ok := valid[key]; !ok {
|
if _, ok := valid[key]; !ok {
|
||||||
@@ -1196,17 +1200,17 @@ func (s *PurchaseOptionalDetails) formatFormatDetails() string {
|
|||||||
return "\n" + s.renderParamChannelSummary("format")
|
return "\n" + s.renderParamChannelSummary("format")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PurchaseOptionalDetails) formatParamValue(param, username string) string {
|
func (s *PurchaseOptionalDetails) formatParamValue(param, channelKey string) string {
|
||||||
switch param {
|
switch param {
|
||||||
case "placement":
|
case "placement":
|
||||||
value := s.PlacementByChannel[username]
|
value := s.PlacementByChannel[channelKey]
|
||||||
return s.formatDateTime(value)
|
return s.formatDateTime(value)
|
||||||
case "cost":
|
case "cost":
|
||||||
return s.formatCostForChannel(username)
|
return s.formatCostForChannel(channelKey)
|
||||||
case "cost_before":
|
case "cost_before":
|
||||||
return s.formatCostBeforeForChannel(username)
|
return s.formatCostBeforeForChannel(channelKey)
|
||||||
case "format":
|
case "format":
|
||||||
if value, ok := s.FormatByChannel[username]; ok && value != "" {
|
if value, ok := s.FormatByChannel[channelKey]; ok && value != "" {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
return "—"
|
return "—"
|
||||||
@@ -1215,8 +1219,8 @@ func (s *PurchaseOptionalDetails) formatParamValue(param, username string) strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PurchaseOptionalDetails) formatCostForChannel(username string) string {
|
func (s *PurchaseOptionalDetails) formatCostForChannel(channelKey string) string {
|
||||||
entry, ok := s.CostByChannel[username]
|
entry, ok := s.CostByChannel[channelKey]
|
||||||
if !ok || entry.Value == nil {
|
if !ok || entry.Value == nil {
|
||||||
return "—"
|
return "—"
|
||||||
}
|
}
|
||||||
@@ -1224,8 +1228,8 @@ func (s *PurchaseOptionalDetails) formatCostForChannel(username string) string {
|
|||||||
return fmt.Sprintf("%s %.0f₽", label, *entry.Value)
|
return fmt.Sprintf("%s %.0f₽", label, *entry.Value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PurchaseOptionalDetails) formatCostBeforeForChannel(username string) string {
|
func (s *PurchaseOptionalDetails) formatCostBeforeForChannel(channelKey string) string {
|
||||||
value, ok := s.CostBeforeByChannel[username]
|
value, ok := s.CostBeforeByChannel[channelKey]
|
||||||
if !ok || value == nil {
|
if !ok || value == nil {
|
||||||
return "—"
|
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 {
|
switch param {
|
||||||
case "placement":
|
case "placement":
|
||||||
value, ok := s.PlacementByChannel[username]
|
value, ok := s.PlacementByChannel[channelKey]
|
||||||
return ok && value != nil
|
return ok && value != nil
|
||||||
case "cost":
|
case "cost":
|
||||||
entry, ok := s.CostByChannel[username]
|
entry, ok := s.CostByChannel[channelKey]
|
||||||
return ok && entry.Value != nil
|
return ok && entry.Value != nil
|
||||||
case "cost_before":
|
case "cost_before":
|
||||||
value, ok := s.CostBeforeByChannel[username]
|
value, ok := s.CostBeforeByChannel[channelKey]
|
||||||
return ok && value != nil
|
return ok && value != nil
|
||||||
case "format":
|
case "format":
|
||||||
value, ok := s.FormatByChannel[username]
|
value, ok := s.FormatByChannel[channelKey]
|
||||||
return ok && value != ""
|
return ok && value != ""
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -1412,14 +1416,14 @@ func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) {
|
|||||||
|
|
||||||
apiChannels := make([]backend.CreatePlacementChannelInput, 0, len(s.Channels))
|
apiChannels := make([]backend.CreatePlacementChannelInput, 0, len(s.Channels))
|
||||||
for _, ch := range s.Channels {
|
for _, ch := range s.Channels {
|
||||||
channelDetails := s.buildChannelDetails(ch.Username)
|
channelDetails := s.buildChannelDetails(channelKey(ch))
|
||||||
if channelDetails != nil && s.isChannelDetailsEmpty(channelDetails) {
|
if channelDetails != nil && s.isChannelDetailsEmpty(channelDetails) {
|
||||||
channelDetails = nil
|
channelDetails = nil
|
||||||
}
|
}
|
||||||
apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{
|
apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{
|
||||||
Username: ch.Username,
|
ChannelID: ch.ChannelID,
|
||||||
Comment: ch.Comment,
|
Comment: ch.Comment,
|
||||||
Details: channelDetails,
|
Details: channelDetails,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1495,29 +1499,29 @@ func (s *PurchaseOptionalDetails) buildPlacementDetails(placementType *string) *
|
|||||||
return details
|
return details
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PurchaseOptionalDetails) buildChannelDetails(username string) *backend.PlacementDetails {
|
func (s *PurchaseOptionalDetails) buildChannelDetails(channelKey string) *backend.PlacementDetails {
|
||||||
if len(s.Channels) <= 1 {
|
if len(s.Channels) <= 1 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
details := &backend.PlacementDetails{}
|
details := &backend.PlacementDetails{}
|
||||||
if s.PlacementMode == "per_channel" {
|
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)
|
formatted := value.Format(time.RFC3339)
|
||||||
details.PlacementAt = &formatted
|
details.PlacementAt = &formatted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.CostMode == "per_channel" {
|
if s.CostMode == "per_channel" {
|
||||||
entry := s.CostByChannel[username]
|
entry := s.CostByChannel[channelKey]
|
||||||
details.Cost = s.buildCostInfo(entry.Type, entry.Value)
|
details.Cost = s.buildCostInfo(entry.Type, entry.Value)
|
||||||
}
|
}
|
||||||
if s.CostBeforeMode == "per_channel" {
|
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
|
details.CostBeforeBargain = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.FormatMode == "per_channel" {
|
if s.FormatMode == "per_channel" {
|
||||||
if value, ok := s.FormatByChannel[username]; ok && value != "" {
|
if value, ok := s.FormatByChannel[channelKey]; ok && value != "" {
|
||||||
details.Format = &value
|
details.Format = &value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type PurchaseChannelInput struct {
|
type PurchaseChannelInput struct {
|
||||||
|
ChannelID string
|
||||||
Username string
|
Username string
|
||||||
|
Title string
|
||||||
|
InviteLink string
|
||||||
PlannedCost *float64
|
PlannedCost *float64
|
||||||
Comment *string
|
Comment *string
|
||||||
}
|
}
|
||||||
@@ -48,15 +51,15 @@ func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.
|
|||||||
if len(s.Channels) == 0 {
|
if len(s.Channels) == 0 {
|
||||||
text += `Добавьте каналы для размещения рекламы
|
text += `Добавьте каналы для размещения рекламы
|
||||||
|
|
||||||
Отправьте username канала (без @)
|
Отправьте username (без @) или invite link приватного канала
|
||||||
Например: <code>channel_name</code>
|
Например: <code>channel_name</code> или <code>https://t.me/+abcdef</code>
|
||||||
|
|
||||||
После добавления всех каналов нажмите <b>Далее</b>`
|
После добавления всех каналов нажмите <b>Далее</b>`
|
||||||
} else {
|
} else {
|
||||||
text += fmt.Sprintf("Добавлено каналов: <b>%d</b>\n\n", len(s.Channels))
|
text += fmt.Sprintf("Добавлено каналов: <b>%d</b>\n\n", len(s.Channels))
|
||||||
|
|
||||||
for i, ch := range 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 {
|
if ch.PlannedCost != nil {
|
||||||
channelText += fmt.Sprintf(" — %.0f₽", *ch.PlannedCost)
|
channelText += fmt.Sprintf(" — %.0f₽", *ch.PlannedCost)
|
||||||
}
|
}
|
||||||
@@ -73,9 +76,9 @@ func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.
|
|||||||
var slots []echotron.InlineKeyboardButton
|
var slots []echotron.InlineKeyboardButton
|
||||||
for i := start; i < end; i++ {
|
for i := start; i < end; i++ {
|
||||||
ch := s.Channels[i]
|
ch := s.Channels[i]
|
||||||
buttonText := fmt.Sprintf("@%s", ch.Username)
|
buttonText := channelLabel(ch)
|
||||||
if ch.PlannedCost != nil {
|
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)))
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем существование каналов через API
|
// Создаем/обновляем каналы в БД через API
|
||||||
jwt := b.Session.JWT
|
jwt := b.Session.JWT
|
||||||
if jwt == "" {
|
if jwt == "" {
|
||||||
log.Error().Msg("JWT is empty in session")
|
log.Error().Msg("JWT is empty in session")
|
||||||
@@ -169,11 +172,11 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
notFoundChannels := s.validateChannels(b, jwt)
|
failed := s.resolveChannels(b, jwt)
|
||||||
if len(notFoundChannels) > 0 {
|
if len(failed) > 0 {
|
||||||
text := "❌ Следующие каналы не найдены:\n\n"
|
text := "❌ Не удалось добавить каналы:\n\n"
|
||||||
for _, username := range notFoundChannels {
|
for _, label := range failed {
|
||||||
text += fmt.Sprintf("• @%s\n", username)
|
text += fmt.Sprintf("• %s\n", label)
|
||||||
}
|
}
|
||||||
text += "\nУдалите их из списка и попробуйте снова"
|
text += "\nУдалите их из списка и попробуйте снова"
|
||||||
|
|
||||||
@@ -242,9 +245,9 @@ func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{
|
apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{
|
||||||
Username: ch.Username,
|
ChannelID: ch.ChannelID,
|
||||||
Comment: ch.Comment,
|
Comment: ch.Comment,
|
||||||
Details: details,
|
Details: details,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,22 +297,32 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update
|
|||||||
added := 0
|
added := 0
|
||||||
var invalid []string
|
var invalid []string
|
||||||
for _, token := range tokens {
|
for _, token := range tokens {
|
||||||
username := strings.TrimSpace(token)
|
entry := strings.TrimSpace(token)
|
||||||
username = strings.TrimPrefix(username, "@")
|
entry = strings.Trim(entry, ",;")
|
||||||
username = strings.Trim(username, "@")
|
if entry == "" {
|
||||||
username = strings.Trim(username, ",;")
|
|
||||||
|
|
||||||
if username == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !channelUsernameRe.MatchString(username) {
|
|
||||||
invalid = append(invalid, username)
|
var username string
|
||||||
continue
|
var inviteLink string
|
||||||
|
if isInviteLink(entry) {
|
||||||
|
inviteLink = entry
|
||||||
|
} else {
|
||||||
|
username = strings.TrimPrefix(entry, "@")
|
||||||
|
username = strings.Trim(username, "@")
|
||||||
|
if !channelUsernameRe.MatchString(username) {
|
||||||
|
invalid = append(invalid, entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exists := false
|
exists := false
|
||||||
for _, ch := range s.Channels {
|
for _, ch := range s.Channels {
|
||||||
if ch.Username == username {
|
if inviteLink != "" && ch.InviteLink == inviteLink {
|
||||||
|
exists = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if username != "" && ch.Username == username {
|
||||||
exists = true
|
exists = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -319,7 +332,8 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.Channels = append(s.Channels, PurchaseChannelInput{
|
s.Channels = append(s.Channels, PurchaseChannelInput{
|
||||||
Username: username,
|
Username: username,
|
||||||
|
InviteLink: inviteLink,
|
||||||
})
|
})
|
||||||
added++
|
added++
|
||||||
}
|
}
|
||||||
@@ -336,37 +350,48 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update
|
|||||||
s.Enter(b, bot.NewMessage)
|
s.Enter(b, bot.NewMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SelectChannelsForPurchase) validateChannels(b *bot.Bot, jwt string) []string {
|
func (s *SelectChannelsForPurchase) resolveChannels(b *bot.Bot, jwt string) []string {
|
||||||
var notFound []string
|
inputs := make([]backend.CreateChannelInput, 0, len(s.Channels))
|
||||||
|
|
||||||
for _, ch := range s.Channels {
|
for _, ch := range s.Channels {
|
||||||
channels, err := b.Backend.SearchChannels(
|
if ch.InviteLink != "" {
|
||||||
context.Background(),
|
link := ch.InviteLink
|
||||||
jwt,
|
inputs = append(inputs, backend.CreateChannelInput{InviteLink: &link})
|
||||||
ch.Username,
|
} else {
|
||||||
)
|
username := ch.Username
|
||||||
|
inputs = append(inputs, backend.CreateChannelInput{Username: &username})
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Str("username", ch.Username).Msg("Failed to search channel")
|
|
||||||
notFound = append(notFound, ch.Username)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем что нашли хотя бы один канал с точным совпадением username
|
|
||||||
found := false
|
|
||||||
for _, channel := range channels {
|
|
||||||
if channel.Username != nil && *channel.Username == ch.Username {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
notFound = append(notFound, ch.Username)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return notFound
|
resp, err := b.Backend.CreateChannels(context.Background(), jwt, backend.CreateChannelsInput{
|
||||||
|
Channels: inputs,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return failed
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SelectChannelsForPurchase) Handle(b *bot.Bot, u *echotron.Update) {
|
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 (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,
|
username,
|
||||||
title,
|
title,
|
||||||
access_hash,
|
access_hash,
|
||||||
pts
|
pts,
|
||||||
|
invite_link
|
||||||
FROM channel
|
FROM channel
|
||||||
WHERE deleted_at IS NULL;`
|
WHERE deleted_at IS NULL;`
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ type getChannelsDTO struct {
|
|||||||
Title pgtype.Text
|
Title pgtype.Text
|
||||||
AccessHash pgtype.Int8
|
AccessHash pgtype.Int8
|
||||||
Pts pgtype.Int4
|
Pts pgtype.Int4
|
||||||
|
InviteLink pgtype.Text
|
||||||
}
|
}
|
||||||
|
|
||||||
func (dto *getChannelsDTO) destination() []any {
|
func (dto *getChannelsDTO) destination() []any {
|
||||||
@@ -64,6 +66,7 @@ func (dto *getChannelsDTO) destination() []any {
|
|||||||
&dto.Title,
|
&dto.Title,
|
||||||
&dto.AccessHash,
|
&dto.AccessHash,
|
||||||
&dto.Pts,
|
&dto.Pts,
|
||||||
|
&dto.InviteLink,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,5 +78,6 @@ func (dto *getChannelsDTO) toDomain() domain.Channel {
|
|||||||
Title: dto.Title.String,
|
Title: dto.Title.String,
|
||||||
AccessHash: dto.AccessHash.Int64,
|
AccessHash: dto.AccessHash.Int64,
|
||||||
Pts: int(dto.Pts.Int32),
|
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.username,
|
||||||
c.title,
|
c.title,
|
||||||
c.access_hash,
|
c.access_hash,
|
||||||
c.pts
|
c.pts,
|
||||||
|
c.invite_link
|
||||||
FROM channel c
|
FROM channel c
|
||||||
INNER JOIN placement p ON p.channel_id = c.id
|
INNER JOIN placement p ON p.channel_id = c.id
|
||||||
INNER JOIN placement_post pp ON pp.placement_id = p.id
|
INNER JOIN placement_post pp ON pp.placement_id = p.id
|
||||||
@@ -55,6 +56,7 @@ type getChannelsWithTrackedPostsDTO struct {
|
|||||||
Title pgtype.Text
|
Title pgtype.Text
|
||||||
AccessHash pgtype.Int8
|
AccessHash pgtype.Int8
|
||||||
Pts pgtype.Int4
|
Pts pgtype.Int4
|
||||||
|
InviteLink pgtype.Text
|
||||||
}
|
}
|
||||||
|
|
||||||
func (dto *getChannelsWithTrackedPostsDTO) destination() []any {
|
func (dto *getChannelsWithTrackedPostsDTO) destination() []any {
|
||||||
@@ -65,6 +67,7 @@ func (dto *getChannelsWithTrackedPostsDTO) destination() []any {
|
|||||||
&dto.Title,
|
&dto.Title,
|
||||||
&dto.AccessHash,
|
&dto.AccessHash,
|
||||||
&dto.Pts,
|
&dto.Pts,
|
||||||
|
&dto.InviteLink,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,5 +79,6 @@ func (dto *getChannelsWithTrackedPostsDTO) toDomain() domain.Channel {
|
|||||||
Title: dto.Title.String,
|
Title: dto.Title.String,
|
||||||
AccessHash: dto.AccessHash.Int64,
|
AccessHash: dto.AccessHash.Int64,
|
||||||
Pts: int(dto.Pts.Int32),
|
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")
|
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{
|
return &Server{
|
||||||
httpServer: &http.Server{
|
httpServer: &http.Server{
|
||||||
@@ -80,11 +132,21 @@ func (s *Server) Shutdown(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isNotFoundError(err error) bool {
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
return strings.Contains(msg, "no chats in resolve result") ||
|
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
|
Title string
|
||||||
AccessHash int64
|
AccessHash int64
|
||||||
Pts int
|
Pts int
|
||||||
|
InviteLink string
|
||||||
}
|
}
|
||||||
|
|
||||||
const channelIDOffset int64 = 1000000000000
|
const channelIDOffset int64 = 1000000000000
|
||||||
@@ -47,13 +48,14 @@ func NormalizeChatID(id int64) int64 {
|
|||||||
|
|
||||||
func (c Channel) String() string {
|
func (c Channel) String() string {
|
||||||
return fmt.Sprintf(
|
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.ID,
|
||||||
c.TelegramID,
|
c.TelegramID,
|
||||||
c.Username,
|
c.Username,
|
||||||
c.Title,
|
c.Title,
|
||||||
c.AccessHash,
|
c.AccessHash,
|
||||||
c.Pts,
|
c.Pts,
|
||||||
|
c.InviteLink != "",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,16 @@ type Post struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewPost(channel Channel, messageID int, text string, views int) Post {
|
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{
|
return Post{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
ChannelID: channel.ID,
|
ChannelID: channel.ID,
|
||||||
MessageID: messageID,
|
MessageID: messageID,
|
||||||
Text: text,
|
Text: text,
|
||||||
Link: fmt.Sprintf("https://t.me/%s/%d", channel.Username, messageID),
|
Link: link,
|
||||||
Views: views,
|
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"
|
"fmt"
|
||||||
|
|
||||||
"github.com/TelegramExchange/pkg/transaction"
|
"github.com/TelegramExchange/pkg/transaction"
|
||||||
|
"github.com/gotd/td/tgerr"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
|
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
|
||||||
@@ -23,6 +24,11 @@ func (uc *UseCase) FetchChannels(ctx context.Context) error {
|
|||||||
c = updatedChannel
|
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)
|
err := uc.processChannel(ctx, c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("uc.processChannel: %w", err)
|
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 {
|
func (uc *UseCase) processChannel(ctx context.Context, channel domain.Channel) error {
|
||||||
diff, err := uc.telegram.GetChannelDiff(ctx, channel, 20)
|
diff, err := uc.telegram.GetChannelDiff(ctx, channel, 20)
|
||||||
if err != nil {
|
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)
|
return fmt.Errorf("telegram.GetChannelDiff: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,10 +99,26 @@ func (uc *UseCase) processChannel(ctx context.Context, channel domain.Channel) e
|
|||||||
return nil
|
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) {
|
func (uc *UseCase) initializeChannel(ctx context.Context, channel domain.Channel) (domain.Channel, error) {
|
||||||
c, err := uc.telegram.ParseChannelMeta(ctx, channel.Username)
|
c := channel
|
||||||
if err != nil {
|
if channel.Username != "" {
|
||||||
return domain.Channel{}, fmt.Errorf("telegram.ParseChannelMeta: %w", err)
|
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.
|
// Preserve persistent channel ID from the database so foreign keys remain valid.
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
|
|
||||||
type Telegram interface {
|
type Telegram interface {
|
||||||
ParseChannelMeta(ctx context.Context, username string) (domain.Channel, error)
|
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)
|
GetChannelHistory(ctx context.Context, channel domain.Channel, limit int) ([]domain.Post, error)
|
||||||
GetChannelDiff(ctx context.Context, channel domain.Channel, limit int) (domain.ChannelDiff, error)
|
GetChannelDiff(ctx context.Context, channel domain.Channel, limit int) (domain.ChannelDiff, error)
|
||||||
UpdatePostsViews(ctx context.Context, channel domain.Channel, posts []domain.Post) error
|
UpdatePostsViews(ctx context.Context, channel domain.Channel, posts []domain.Post) error
|
||||||
|
|||||||
Reference in New Issue
Block a user