diff --git a/migrations/models/7_20260121021919_update.py b/migrations/models/7_20260121021919_update.py
new file mode 100644
index 0000000..17fd304
--- /dev/null
+++ b/migrations/models/7_20260121021919_update.py
@@ -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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"
+)
diff --git a/migrations/models/8_20260121022045_add_channel_constraint.py b/migrations/models/8_20260121022045_add_channel_constraint.py
new file mode 100644
index 0000000..c7acd67
--- /dev/null
+++ b/migrations/models/8_20260121022045_add_channel_constraint.py
@@ -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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'
+)
diff --git a/src/adapter/parser.py b/src/adapter/parser.py
index fa2dde2..2ab8199 100644
--- a/src/adapter/parser.py
+++ b/src/adapter/parser.py
@@ -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
diff --git a/src/controller/http_v1/channels.py b/src/controller/http_v1/channels.py
index 36a3f2b..c353305 100644
--- a/src/controller/http_v1/channels.py
+++ b/src/controller/http_v1/channels.py
@@ -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)]
diff --git a/src/domain/channel.py b/src/domain/channel.py
index e8e4455..81b6e04 100644
--- a/src/domain/channel.py
+++ b/src/domain/channel.py
@@ -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'
diff --git a/src/dto/__init__.py b/src/dto/__init__.py
index 1904b61..6ef4eaa 100644
--- a/src/dto/__init__.py
+++ b/src/dto/__init__.py
@@ -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 (
diff --git a/src/dto/channel.py b/src/dto/channel.py
index 867cccc..b19de98 100644
--- a/src/dto/channel.py
+++ b/src/dto/channel.py
@@ -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]
diff --git a/src/dto/purchase.py b/src/dto/purchase.py
index b77b4f6..466340c 100644
--- a/src/dto/purchase.py
+++ b/src/dto/purchase.py
@@ -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
diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py
index c73ca9d..3965f83 100644
--- a/src/usecase/__init__.py
+++ b/src/usecase/__init__.py
@@ -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
diff --git a/src/usecase/channel/create_channels.py b/src/usecase/channel/create_channels.py
new file mode 100644
index 0000000..9acef2f
--- /dev/null
+++ b/src/usecase/channel/create_channels.py
@@ -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)
diff --git a/src/usecase/channel/get_channels.py b/src/usecase/channel/get_channels.py
index 485fdc3..ba97f67 100644
--- a/src/usecase/channel/get_channels.py
+++ b/src/usecase/channel/get_channels.py
@@ -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 [
diff --git a/src/usecase/project/move_project_to_workspace.py b/src/usecase/project/move_project_to_workspace.py
index 4a3385f..c591112 100644
--- a/src/usecase/project/move_project_to_workspace.py
+++ b/src/usecase/project/move_project_to_workspace.py
@@ -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):
diff --git a/src/usecase/project/tg_add_project.py b/src/usecase/project/tg_add_project.py
index 115044e..bd38ad2 100644
--- a/src/usecase/project/tg_add_project.py
+++ b/src/usecase/project/tg_add_project.py
@@ -79,28 +79,90 @@ async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto
workspace = await self.get_or_create_personal_workspace(user)
workspaces = [workspace]
+ invite_link = ""
+ parser_response = None
+ is_private = input.username is None
+ if is_private:
+ try:
+ invite_link = await self.telegram_bot.create_chat_invite_link(input.telegram_id)
+ parser_response = await self.parser.resolve_telegram_channel_by_invite(invite_link)
+ except Exception as exc:
+ log.warning('Failed to resolve private channel %s: %s', input.telegram_id, exc)
+ await self.telegram_bot.send_message(
+ f'⚠️ Канал "{input.title}" не может быть подключен.\n\n'
+ 'Не удалось создать или проверить инвайт-ссылку.',
+ input.user_telegram_id,
+ )
+ return None
+
+ def build_channel_title() -> str:
+ if parser_response and parser_response.title:
+ return parser_response.title
+ return input.title
+
+ def build_channel_username() -> str | None:
+ if parser_response and parser_response.username:
+ return parser_response.username
+ return input.username
+
+ def build_channel_telegram_id() -> int:
+ if parser_response and parser_response.telegram_id:
+ return parser_response.telegram_id
+ return input.telegram_id
+
+ def update_channel_meta(channel: domain.Channel) -> bool:
+ updated = False
+ title = build_channel_title()
+ username = build_channel_username()
+ telegram_id = build_channel_telegram_id()
+ if channel.title != title:
+ channel.title = title
+ updated = True
+ if username is not None and channel.username != username:
+ channel.username = username
+ updated = True
+ if channel.telegram_id != telegram_id:
+ channel.telegram_id = telegram_id
+ updated = True
+ if parser_response and parser_response.access_hash is not None and channel.access_hash != parser_response.access_hash:
+ channel.access_hash = parser_response.access_hash
+ updated = True
+ if parser_response and parser_response.pts is not None and channel.pts != parser_response.pts:
+ channel.pts = parser_response.pts
+ updated = True
+ if is_private and channel.pts != 0:
+ channel.pts = 0
+ updated = True
+ if invite_link and channel.invite_link != invite_link:
+ channel.invite_link = invite_link
+ updated = True
+ return updated
+
if len(workspaces) == 1:
workspace = workspaces[0]
channel = await self.database.get_channel(telegram_id=input.telegram_id)
if channel:
- channel.title = input.title
- if input.username is not None:
- channel.username = input.username
- await self.database.update_channel(channel)
+ if update_channel_meta(channel):
+ await self.database.update_channel(channel)
else:
- if input.username is None:
- log.warning('Cannot create channel %s without username', input.telegram_id)
+ username = build_channel_username()
+ if username is None and invite_link == "":
+ log.warning('Cannot create channel %s without username or invite link', input.telegram_id)
await self.telegram_bot.send_message(
- f'⚠️ Канал "{input.title}" не может быть подключен.\n\nУ канала отсутствует публичный username.',
+ f'⚠️ Канал "{input.title}" не может быть подключен.\n\n'
+ 'У канала отсутствует публичный username и не удалось получить invite link.',
input.user_telegram_id,
)
return None
channel = domain.Channel(
- telegram_id=input.telegram_id,
- title=input.title,
- username=input.username,
+ telegram_id=build_channel_telegram_id(),
+ title=build_channel_title(),
+ username=username,
+ access_hash=(parser_response.access_hash if parser_response else None),
+ pts=(0 if is_private else (parser_response.pts if parser_response else None)),
+ invite_link=invite_link or None,
)
await self.database.create_channel(channel)
@@ -139,23 +201,26 @@ async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto
# Если >1 workspace - создаем/обновляем канал и отправляем уведомление
channel = await self.database.get_channel(telegram_id=input.telegram_id)
if channel:
- channel.title = input.title
- if input.username is not None:
- channel.username = input.username
- await self.database.update_channel(channel)
+ if update_channel_meta(channel):
+ await self.database.update_channel(channel)
else:
- if input.username is None:
- log.warning('Cannot create channel %s without username', input.telegram_id)
+ username = build_channel_username()
+ if username is None and invite_link == "":
+ log.warning('Cannot create channel %s without username or invite link', input.telegram_id)
await self.telegram_bot.send_message(
- f'⚠️ Канал "{input.title}" не может быть подключен.\n\nУ канала отсутствует публичный username.',
+ f'⚠️ Канал "{input.title}" не может быть подключен.\n\n'
+ 'У канала отсутствует публичный username и не удалось получить invite link.',
input.user_telegram_id,
)
return None
channel = domain.Channel(
- telegram_id=input.telegram_id,
- title=input.title,
- username=input.username,
+ telegram_id=build_channel_telegram_id(),
+ title=build_channel_title(),
+ username=username,
+ access_hash=(parser_response.access_hash if parser_response else None),
+ pts=(0 if is_private else (parser_response.pts if parser_response else None)),
+ invite_link=invite_link or None,
)
await self.database.create_channel(channel)
diff --git a/src/usecase/purchase/create_placements.py b/src/usecase/purchase/create_placements.py
index cd1690a..4cb3ba7 100644
--- a/src/usecase/purchase/create_placements.py
+++ b/src/usecase/purchase/create_placements.py
@@ -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
diff --git a/tg_bot/backend/backend_client.go b/tg_bot/backend/backend_client.go
index 5b17ba7..62a3603 100644
--- a/tg_bot/backend/backend_client.go
+++ b/tg_bot/backend/backend_client.go
@@ -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,10 +608,10 @@ type PostOutput struct {
}
type CreatePlacementChannelInput struct {
- Username string `json:"username"`
- Status *string `json:"status,omitempty"`
- Comment *string `json:"comment,omitempty"`
- Details *PlacementDetails `json:"details,omitempty"`
+ ChannelID string `json:"channel_id"`
+ Status *string `json:"status,omitempty"`
+ Comment *string `json:"comment,omitempty"`
+ Details *PlacementDetails `json:"details,omitempty"`
}
type CreatePlacementsInput struct {
@@ -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,
diff --git a/tg_bot/screens/purchase_optional_details.go b/tg_bot/screens/purchase_optional_details.go
index 629f187..f1bbb1f 100644
--- a/tg_bot/screens/purchase_optional_details.go
+++ b/tg_bot/screens/purchase_optional_details.go
@@ -521,7 +521,7 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM
case "cost_value":
text += "Ввод стоимости\n\nНапример: 15000"
if s.CurrentChannel != "" {
- text += fmt.Sprintf("\n\nКанал: @%s", s.CurrentChannel)
+ text += fmt.Sprintf("\n\nКанал: %s", 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 += "Ввод стоимости до торга\n\nНапример: 20000"
if s.CurrentChannel != "" {
- text += fmt.Sprintf("\n\nКанал: @%s", s.CurrentChannel)
+ text += fmt.Sprintf("\n\nКанал: %s", channelLabelByKey(s.Channels, s.CurrentChannel))
}
case "format_select":
text = "Страница ввода формата размещения\n\n"
if s.CurrentChannel != "" {
- text += fmt.Sprintf("Канал: @%s\n\n", s.CurrentChannel)
+ text += fmt.Sprintf("Канал: %s\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,14 +1416,14 @@ 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,
- Comment: ch.Comment,
- Details: channelDetails,
+ 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
}
}
diff --git a/tg_bot/screens/select_channels_for_purchase.go b/tg_bot/screens/select_channels_for_purchase.go
index 1cce617..7ada8ee 100644
--- a/tg_bot/screens/select_channels_for_purchase.go
+++ b/tg_bot/screens/select_channels_for_purchase.go
@@ -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 канала (без @)
-Например: channel_name
+Отправьте username (без @) или invite link приватного канала
+Например: channel_name или https://t.me/+abcdef
После добавления всех каналов нажмите Далее`
} else {
text += fmt.Sprintf("Добавлено каналов: %d\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,9 +245,9 @@ func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) {
}
}
apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{
- Username: ch.Username,
- Comment: ch.Comment,
- Details: details,
+ 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
}
- if !channelUsernameRe.MatchString(username) {
- invalid = append(invalid, username)
- 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, 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
}
@@ -319,7 +332,8 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update
}
s.Channels = append(s.Channels, PurchaseChannelInput{
- Username: username,
+ 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 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)
+ if ch.InviteLink != "" {
+ link := ch.InviteLink
+ inputs = append(inputs, backend.CreateChannelInput{InviteLink: &link})
+ } else {
+ username := ch.Username
+ inputs = append(inputs, backend.CreateChannelInput{Username: &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) {
@@ -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
+}
diff --git a/tg_parser/internal/adapter/database/get_channels.go b/tg_parser/internal/adapter/database/get_channels.go
index c98056b..9d43207 100644
--- a/tg_parser/internal/adapter/database/get_channels.go
+++ b/tg_parser/internal/adapter/database/get_channels.go
@@ -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,
}
}
diff --git a/tg_parser/internal/adapter/database/get_channels_with_tracked_posts.go b/tg_parser/internal/adapter/database/get_channels_with_tracked_posts.go
index 52065d1..6e53509 100644
--- a/tg_parser/internal/adapter/database/get_channels_with_tracked_posts.go
+++ b/tg_parser/internal/adapter/database/get_channels_with_tracked_posts.go
@@ -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,
}
}
diff --git a/tg_parser/internal/adapter/telegram/pts.go b/tg_parser/internal/adapter/telegram/pts.go
new file mode 100644
index 0000000..cb9d91c
--- /dev/null
+++ b/tg_parser/internal/adapter/telegram/pts.go
@@ -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
+}
diff --git a/tg_parser/internal/adapter/telegram/resolve_invite.go b/tg_parser/internal/adapter/telegram/resolve_invite.go
new file mode 100644
index 0000000..c81e376
--- /dev/null
+++ b/tg_parser/internal/adapter/telegram/resolve_invite.go
@@ -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
+}
diff --git a/tg_parser/internal/controller/httpserver/server.go b/tg_parser/internal/controller/httpserver/server.go
index bbbeb11..61e73dc 100644
--- a/tg_parser/internal/controller/httpserver/server.go
+++ b/tg_parser/internal/controller/httpserver/server.go
@@ -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")
}
diff --git a/tg_parser/internal/domain/channel.go b/tg_parser/internal/domain/channel.go
index 3d4abcb..6dc22d8 100644
--- a/tg_parser/internal/domain/channel.go
+++ b/tg_parser/internal/domain/channel.go
@@ -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 != "",
)
}
diff --git a/tg_parser/internal/domain/post.go b/tg_parser/internal/domain/post.go
index 64b8632..e7eea11 100644
--- a/tg_parser/internal/domain/post.go
+++ b/tg_parser/internal/domain/post.go
@@ -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,
}
}
diff --git a/tg_parser/internal/usecase/fetch_channel_meta_by_invite.go b/tg_parser/internal/usecase/fetch_channel_meta_by_invite.go
new file mode 100644
index 0000000..a358003
--- /dev/null
+++ b/tg_parser/internal/usecase/fetch_channel_meta_by_invite.go
@@ -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)
+}
diff --git a/tg_parser/internal/usecase/fetch_channels.go b/tg_parser/internal/usecase/fetch_channels.go
index 10fa36b..fc3d341 100644
--- a/tg_parser/internal/usecase/fetch_channels.go
+++ b/tg_parser/internal/usecase/fetch_channels.go
@@ -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,10 +99,26 @@ 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)
- if err != nil {
- return domain.Channel{}, fmt.Errorf("telegram.ParseChannelMeta: %w", err)
+ 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.
diff --git a/tg_parser/internal/usecase/usecase.go b/tg_parser/internal/usecase/usecase.go
index a98b498..7516578 100644
--- a/tg_parser/internal/usecase/usecase.go
+++ b/tg_parser/internal/usecase/usecase.go
@@ -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