diff --git a/migrations/models/16_20260202115014_add_invite_link_name.py b/migrations/models/16_20260202115014_add_invite_link_name.py
new file mode 100644
index 0000000..93e8efd
--- /dev/null
+++ b/migrations/models/16_20260202115014_add_invite_link_name.py
@@ -0,0 +1,105 @@
+# ruff: noqa
+# mypy: ignore-errors
+from tortoise import BaseDBAsyncClient
+
+RUN_IN_TRANSACTION = True
+
+
+async def upgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ ALTER TABLE "placement" ADD "invite_link_name" VARCHAR(32);"""
+
+
+async def downgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ ALTER TABLE "placement" DROP COLUMN "invite_link_name";"""
+
+
+MODELS_STATE = (
+ "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"
+ "cQREBB"
+)
diff --git a/src/adapter/telegram_bot.py b/src/adapter/telegram_bot.py
index d54a222..6f9ca93 100644
--- a/src/adapter/telegram_bot.py
+++ b/src/adapter/telegram_bot.py
@@ -33,8 +33,12 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
)
return message.message_id
- async def create_chat_invite_link(self, chat_id: int, requires_approval: bool = False) -> str:
- invite_link = await self.bot.create_chat_invite_link(chat_id=chat_id, creates_join_request=requires_approval)
+ async def create_chat_invite_link(
+ self, chat_id: int, requires_approval: bool = False, name: str | None = None
+ ) -> str:
+ invite_link = await self.bot.create_chat_invite_link(
+ chat_id=chat_id, creates_join_request=requires_approval, name=name
+ )
return invite_link.invite_link
async def send_message_with_inline_keyboard(
@@ -122,13 +126,9 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
for index, item in enumerate(media_items):
item_caption = caption if index == 0 else None
if item.media_type == 'photo':
- group.append(
- InputMediaPhoto(media=item.media_file_id, caption=item_caption, parse_mode=parse_mode)
- )
+ group.append(InputMediaPhoto(media=item.media_file_id, caption=item_caption, parse_mode=parse_mode))
elif item.media_type == 'video':
- group.append(
- InputMediaVideo(media=item.media_file_id, caption=item_caption, parse_mode=parse_mode)
- )
+ group.append(InputMediaVideo(media=item.media_file_id, caption=item_caption, parse_mode=parse_mode))
else:
raise ValueError(f'Unsupported media type for group: {item.media_type}')
diff --git a/src/domain/creative.py b/src/domain/creative.py
index 6c7e1da..677935b 100644
--- a/src/domain/creative.py
+++ b/src/domain/creative.py
@@ -11,7 +11,6 @@ from .error import (
CreativeMediaGroupUnsupported,
CreativeMediaTooLarge,
CreativeMediaTooMany,
- CreativeMultipleInviteLinks,
)
if TYPE_CHECKING:
@@ -89,44 +88,43 @@ def _strip_html(text: str) -> str:
def replace_invite_link_with_tag(text: str) -> str:
- """Replace Telegram invite link (t.me/+xxx) with tracking tag."""
- # Проверяем, есть ли уже замененная ссылка
- replaced_count = len(_INVITE_LINK_REPLACED.findall(text))
- html_count = len(_INVITE_LINK_HTML.findall(text))
+ """Replace ALL Telegram invite links (t.me/+xxx) with tracking tags."""
+ # Check for any invite links first (excluding tg-link which are already replaced)
+ replaced = _INVITE_LINK_REPLACED.findall(text)
+ html_links = _INVITE_LINK_HTML.findall(text)
- # Сначала удаляем HTML-ссылки и tg-link теги, чтобы не считать их дважды
+ # Remove HTML links and tg-link tags to find plain text links
text_without_links = _INVITE_LINK_HTML.sub('', text)
text_without_links = _INVITE_LINK_REPLACED.sub('', text_without_links)
- plain_count = len(_INVITE_LINK_PLAIN.findall(text_without_links))
+ plain_links = _INVITE_LINK_PLAIN.findall(text_without_links)
- total = replaced_count + html_count + plain_count
+ total = len(replaced) + len(html_links) + len(plain_links)
if total == 0:
raise CreativeInviteLinkNotFound()
- if total > 1:
- raise CreativeMultipleInviteLinks()
- # Если ссылка уже заменена, нормализуем текст если внутри был URL или теги
- if replaced_count == 1:
- def _normalize_replaced(match: re.Match[str]) -> str:
- inner = _strip_html(match.group(1))
- if inner == "" or _INVITE_LINK_PLAIN.search(inner):
- return ''
- return f'{inner}'
+ # Normalize already replaced links (preserve anchor text)
+ def _normalize_replaced(match: re.Match[str]) -> str:
+ inner = _strip_html(match.group(1))
+ if inner == "" or _INVITE_LINK_PLAIN.search(inner):
+ return ''
+ return f'{inner}'
- return _INVITE_LINK_REPLACED.sub(_normalize_replaced, text)
+ text = _INVITE_LINK_REPLACED.sub(_normalize_replaced, text)
- if html_count == 1:
+ # Replace HTML links with tracking tags (preserve anchor text)
+ def _replace_html(match: re.Match[str]) -> str:
+ inner = _strip_html(match.group(1))
+ if _INVITE_LINK_PLAIN.fullmatch(inner):
+ return ''
+ return f'{inner}'
- def _replace_html(match: re.Match[str]) -> str:
- inner = _strip_html(match.group(1))
- if _INVITE_LINK_PLAIN.fullmatch(inner):
- return ''
- return f'{inner}'
+ text = _INVITE_LINK_HTML.sub(_replace_html, text)
- return _INVITE_LINK_HTML.sub(_replace_html, text)
+ # Replace plain text invite links
+ text = _INVITE_LINK_PLAIN.sub('', text)
- return _INVITE_LINK_PLAIN.sub('', text)
+ return text
def validate_media_size(media_data: bytes | None) -> None:
diff --git a/src/domain/placement.py b/src/domain/placement.py
index ffe4363..becb114 100644
--- a/src/domain/placement.py
+++ b/src/domain/placement.py
@@ -99,6 +99,8 @@ class Placement(TimestampedModel):
invite_link_created_at = fields.DatetimeField(null=True)
invite_link_type = fields.CharEnumField(InviteLinkType)
+ invite_link_name = fields.CharField(max_length=32, null=True)
+
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True
)
diff --git a/src/dto/project.py b/src/dto/project.py
index 708cecc..94c6a1f 100644
--- a/src/dto/project.py
+++ b/src/dto/project.py
@@ -4,6 +4,7 @@ import pydantic
from src import domain
from src.domain.project import ProjectStatus
+from .channel import ChannelOutput
class ChannelBotPermissions(pydantic.BaseModel):
@@ -33,6 +34,7 @@ class ProjectOutput(pydantic.BaseModel):
username: str | None
status: ProjectStatus
purchase_invite_type_default: domain.InviteLinkType
+ channel: ChannelOutput
class UpdateProjectInviteLinkTypeInput(pydantic.BaseModel):
diff --git a/src/dto/purchase.py b/src/dto/purchase.py
index 8d525b0..420ceba 100644
--- a/src/dto/purchase.py
+++ b/src/dto/purchase.py
@@ -7,12 +7,7 @@ from src.domain.placement import CostType, InviteLinkType, PlacementStatus, Plac
from src.domain.placement_post import PlacementPostStatus
from .channel import ChannelOutput
-
-
-class ProjectOutput(pydantic.BaseModel):
- id: uuid.UUID
- status: str
- channel: ChannelOutput
+from .project import ProjectOutput
class CostInfo(pydantic.BaseModel):
@@ -43,7 +38,7 @@ class PlacementOutput(pydantic.BaseModel):
invite_link_type: InviteLinkType
channel: ChannelOutput
project: ProjectOutput | None = None
- placement_number: int
+ short_id: str
details: PlacementDetails | None = None
diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py
index d663458..86d9495 100644
--- a/src/usecase/__init__.py
+++ b/src/usecase/__init__.py
@@ -114,7 +114,9 @@ class TelegramBotWriter(typing.Protocol):
async def edit_message_reply_markup(self, chat_id: int, message_id: int) -> None: ...
- async def create_chat_invite_link(self, chat_id: int, requires_approval: bool = False) -> str: ...
+ async def create_chat_invite_link(
+ self, chat_id: int, requires_approval: bool = False, name: str | None = None
+ ) -> str: ...
class JWTEncoder(typing.Protocol):
diff --git a/src/usecase/channel/attach_channel_to_workspace.py b/src/usecase/channel/attach_channel_to_workspace.py
index 5abbe4c..ab5b377 100644
--- a/src/usecase/channel/attach_channel_to_workspace.py
+++ b/src/usecase/channel/attach_channel_to_workspace.py
@@ -46,4 +46,10 @@ async def attach_channel_to_workspace(self: 'Usecase', input: dto.AttachChannelT
username=channel.username,
status=project.status,
purchase_invite_type_default=project.purchase_invite_type_default,
+ channel=dto.ChannelOutput(
+ id=channel.id,
+ telegram_id=channel.telegram_id,
+ title=channel.title,
+ username=channel.username,
+ ),
)
diff --git a/src/usecase/project/archive_project.py b/src/usecase/project/archive_project.py
index dd4fd87..e7304b7 100644
--- a/src/usecase/project/archive_project.py
+++ b/src/usecase/project/archive_project.py
@@ -27,6 +27,12 @@ async def archive_project(self: 'Usecase', input: dto.ArchiveProjectInput) -> dt
username=project.channel.username,
status=project.status,
purchase_invite_type_default=project.purchase_invite_type_default,
+ channel=dto.ChannelOutput(
+ id=project.channel.id,
+ telegram_id=project.channel.telegram_id,
+ title=project.channel.title,
+ username=project.channel.username,
+ ),
)
@@ -51,4 +57,10 @@ async def unarchive_project(self: 'Usecase', input: dto.ArchiveProjectInput) ->
username=project.channel.username,
status=project.status,
purchase_invite_type_default=project.purchase_invite_type_default,
+ channel=dto.ChannelOutput(
+ id=project.channel.id,
+ telegram_id=project.channel.telegram_id,
+ title=project.channel.title,
+ username=project.channel.username,
+ ),
)
diff --git a/src/usecase/project/get_project.py b/src/usecase/project/get_project.py
index 8e5eed6..5c3824c 100644
--- a/src/usecase/project/get_project.py
+++ b/src/usecase/project/get_project.py
@@ -21,4 +21,10 @@ async def get_project(self: 'Usecase', input: dto.GetProjectInput) -> dto.Projec
username=project.channel.username,
status=project.status,
purchase_invite_type_default=project.purchase_invite_type_default,
+ channel=dto.ChannelOutput(
+ id=project.channel.id,
+ telegram_id=project.channel.telegram_id,
+ title=project.channel.title,
+ username=project.channel.username,
+ ),
)
diff --git a/src/usecase/project/get_workspace_projects.py b/src/usecase/project/get_workspace_projects.py
index ef38b15..f6c2c42 100644
--- a/src/usecase/project/get_workspace_projects.py
+++ b/src/usecase/project/get_workspace_projects.py
@@ -25,6 +25,12 @@ async def get_workspace_projects(self: 'Usecase', input: dto.GetWorkspaceProject
username=project.channel.username,
status=project.status,
purchase_invite_type_default=project.purchase_invite_type_default,
+ channel=dto.ChannelOutput(
+ id=project.channel.id,
+ telegram_id=project.channel.telegram_id,
+ title=project.channel.title,
+ username=project.channel.username,
+ ),
)
for project in projects
]
diff --git a/src/usecase/project/move_project_to_workspace.py b/src/usecase/project/move_project_to_workspace.py
index c591112..f90d2cf 100644
--- a/src/usecase/project/move_project_to_workspace.py
+++ b/src/usecase/project/move_project_to_workspace.py
@@ -67,4 +67,10 @@ async def move_project_to_workspace(
username=project.channel.username,
status=project.status,
purchase_invite_type_default=project.purchase_invite_type_default,
+ channel=dto.ChannelOutput(
+ id=project.channel.id,
+ telegram_id=project.channel.telegram_id,
+ title=project.channel.title,
+ username=project.channel.username,
+ ),
)
diff --git a/src/usecase/project/tg_add_project.py b/src/usecase/project/tg_add_project.py
index 44d3e0d..9304423 100644
--- a/src/usecase/project/tg_add_project.py
+++ b/src/usecase/project/tg_add_project.py
@@ -200,6 +200,12 @@ async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto
username=channel.username,
status=project.status,
purchase_invite_type_default=project.purchase_invite_type_default,
+ channel=dto.ChannelOutput(
+ id=channel.id,
+ telegram_id=channel.telegram_id,
+ title=channel.title,
+ username=channel.username,
+ ),
)
# Если >1 workspace - создаем/обновляем канал и отправляем уведомление
diff --git a/src/usecase/project/update_project_invite_link_type.py b/src/usecase/project/update_project_invite_link_type.py
index e97b3b8..01dd1ab 100644
--- a/src/usecase/project/update_project_invite_link_type.py
+++ b/src/usecase/project/update_project_invite_link_type.py
@@ -39,4 +39,10 @@ async def update_project_invite_link_type(
username=project.channel.username,
status=project.status,
purchase_invite_type_default=project.purchase_invite_type_default,
+ channel=dto.ChannelOutput(
+ id=project.channel.id,
+ telegram_id=project.channel.telegram_id,
+ title=project.channel.title,
+ username=project.channel.username,
+ ),
)
diff --git a/src/usecase/purchase/build_placement_creative.py b/src/usecase/purchase/build_placement_creative.py
index 131fa5d..29c128a 100644
--- a/src/usecase/purchase/build_placement_creative.py
+++ b/src/usecase/purchase/build_placement_creative.py
@@ -8,6 +8,8 @@ from tortoise import timezone
from src import domain, dto
+from .create_placements import generate_invite_link_name, uuid_to_short_id
+
if TYPE_CHECKING:
from .. import Usecase
@@ -152,8 +154,15 @@ async def build_placement_creative(
if project.channel.telegram_id is None:
raise domain.ChannelNotFound(project.channel.id)
+ # Generate invite_link_name if not set
+ if not placement.invite_link_name:
+ short_id = uuid_to_short_id(placement.id)
+ placement.invite_link_name = generate_invite_link_name(short_id, project.channel.title)
+
requires_approval = placement.invite_link_type == domain.InviteLinkType.APPROVAL
- invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval)
+ invite_link = await self.telegram_bot.create_chat_invite_link(
+ project.channel.telegram_id, requires_approval, name=placement.invite_link_name
+ )
placement.invite_link = invite_link
placement.invite_link_created_at = timezone.now()
await self.database.update_placement(placement)
diff --git a/src/usecase/purchase/create_placements.py b/src/usecase/purchase/create_placements.py
index 7a9c774..78fccce 100644
--- a/src/usecase/purchase/create_placements.py
+++ b/src/usecase/purchase/create_placements.py
@@ -9,6 +9,76 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__)
+# Base62 alphabet for encoding
+BASE62_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
+
+
+def uuid_to_short_id(placement_uuid: uuid.UUID) -> str:
+ """
+ Конвертирует UUID в короткий ID (8 символов base62).
+
+ Берёт первые 8 байт UUID (64 бита) и кодирует в base62.
+ """
+ # Берём первые 8 байт UUID
+ uuid_bytes = placement_uuid.bytes[:8]
+
+ # Конвертируем байты в integer
+ num = int.from_bytes(uuid_bytes, byteorder='big')
+
+ # Кодируем в base62
+ if num == 0:
+ return BASE62_ALPHABET[0]
+
+ result = []
+ base = len(BASE62_ALPHABET)
+ while num > 0:
+ num, remainder = divmod(num, base)
+ result.append(BASE62_ALPHABET[remainder])
+
+ # Разворачиваем и ограничиваем до 8 символов
+ short_id = ''.join(reversed(result))[:8]
+ return short_id.lower()
+
+
+def generate_invite_link_name(short_id: str, project_channel_title: str | None) -> str:
+ """
+ Генерирует название для invite link с умным сокращением.
+
+ Формат: "{short_id} {channel_name}"
+ Приоритет: short_id всегда полный, channel_name сокращается.
+
+ Args:
+ short_id: Короткий ID размещения
+ project_channel_title: Название канала проекта
+
+ Returns:
+ Строка до 32 символов
+ """
+ channel_name = project_channel_title or 'Unknown'
+
+ # Базовый формат: "{short_id} {name}"
+ base_name = f'{short_id} {channel_name}'
+
+ # Если помещается, возвращаем как есть
+ if len(base_name) <= 32:
+ return base_name
+
+ # Считаем доступное место для канала
+ # Формат: "{short_id} " занимает len(short_id) + 1 символов
+ prefix_len = len(short_id) + 1 # например: "a3b9x2m " = 8 символов
+ max_channel_len = 32 - prefix_len
+
+ # Если места слишком мало, возвращаем только short_id
+ if max_channel_len < 3:
+ return short_id
+
+ # Сокращаем название канала (с многоточием если нужно)
+ truncated_channel = channel_name[: max_channel_len - 3]
+ if len(truncated_channel) < len(channel_name):
+ truncated_channel = truncated_channel + '...'
+
+ return f'{short_id} {truncated_channel}'
+
def _build_cost_info(cost_type: domain.CostType | None, cost_value: float | None) -> dto.CostInfo | None:
if cost_type is None or cost_value is None:
@@ -34,7 +104,6 @@ def _build_placement_details(placement: domain.Placement) -> dto.PlacementDetail
def _build_placement_output(
placement: domain.Placement,
project: domain.Project | None = None,
- placement_number: int = 0,
creative_name: str | None = None,
) -> dto.PlacementOutput:
channel = placement.channel
@@ -42,24 +111,31 @@ def _build_placement_output(
log.error('Placement %s has no channel prefetched', placement.id)
raise ValueError(f'Placement {placement.id} has no channel')
+ # Generate short_id from UUID
+ short_id = uuid_to_short_id(placement.id)
+
# Build project output if project is provided
project_output: dto.ProjectOutput | None = None
if project is not None:
project_channel = project.channel
if project_channel is None:
- log.error('Project %s has no channel', project.id)
- raise ValueError(f'Project {project.id} has no channel')
-
- project_output = dto.ProjectOutput(
- id=project.id,
- status=project.status.value,
- channel=dto.ChannelOutput(
- id=project_channel.id,
- telegram_id=project_channel.telegram_id,
- title=project_channel.title,
- username=project_channel.username,
- ),
- )
+ log.warning('Project %s has no channel prefetched, skipping project output', project.id)
+ else:
+ project_output = dto.ProjectOutput(
+ id=project.id,
+ telegram_id=project.channel.telegram_id,
+ title=project.channel.title,
+ username=project.channel.username,
+ status=project.status,
+ purchase_invite_type_default=project.purchase_invite_type_default,
+ channel=dto.ChannelOutput(
+ id=project.channel.id,
+ telegram_id=project.channel.telegram_id,
+ title=project.channel.title,
+ username=project.channel.username,
+ ),
+ )
+ log.debug('Built project output for project %s', project.id)
return dto.PlacementOutput(
id=placement.id,
@@ -76,7 +152,7 @@ def _build_placement_output(
username=channel.username,
),
project=project_output,
- placement_number=placement_number,
+ short_id=short_id,
details=_build_placement_details(placement),
)
@@ -132,7 +208,9 @@ async def create_placements(
creative_id=creative.id if creative else None,
channel_id=channel.id,
invite_link=None,
- invite_link_type=(channel_details.invite_link_type if channel_details and channel_details.invite_link_type else None)
+ invite_link_type=(
+ channel_details.invite_link_type if channel_details and channel_details.invite_link_type else None
+ )
or (details.invite_link_type if details and details.invite_link_type else None)
or project.purchase_invite_type_default,
status=channel_input.status or domain.PlacementStatus.NO_STATUS,
@@ -145,9 +223,17 @@ async def create_placements(
or (details.cost.type if details and details.cost else None),
cost_value=(channel_details.cost.value if channel_details and channel_details.cost else None)
or (details.cost.value if details and details.cost else None),
- cost_before_bargain_type=(channel_details.cost_before_bargain.type if channel_details and channel_details.cost_before_bargain else None)
+ cost_before_bargain_type=(
+ channel_details.cost_before_bargain.type
+ if channel_details and channel_details.cost_before_bargain
+ else None
+ )
or (details.cost_before_bargain.type if details and details.cost_before_bargain else None),
- cost_before_bargain=(channel_details.cost_before_bargain.value if channel_details and channel_details.cost_before_bargain else None)
+ cost_before_bargain=(
+ channel_details.cost_before_bargain.value
+ if channel_details and channel_details.cost_before_bargain
+ else None
+ )
or (details.cost_before_bargain.value if details and details.cost_before_bargain else None),
placement_type=details.placement_type if details else None,
format=(channel_details.format if channel_details else None) or (details.format if details else None),
@@ -155,6 +241,12 @@ async def create_placements(
await self.database.create_placement(placement)
placement.channel = channel
+
+ # Generate invite_link_name after placement has UUID
+ short_id = uuid_to_short_id(placement.id)
+ placement.invite_link_name = generate_invite_link_name(short_id, project.channel.title)
+ await self.database.update_placement(placement)
+
placements.append(placement)
log.info(
diff --git a/src/usecase/purchase/get_placement.py b/src/usecase/purchase/get_placement.py
index 6c09dc3..460f3cc 100644
--- a/src/usecase/purchase/get_placement.py
+++ b/src/usecase/purchase/get_placement.py
@@ -89,18 +89,6 @@ async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> d
if creative:
creative_name = creative.name
- # Calculate placement number (ordinal number of this placement)
- placement_number = await self.database.count_placements_by_project_and_channel(
- project.id, placement.channel_id
- )
- # Count placements created before this one
- placements_before = await domain.Placement.filter(
- project_id=project.id,
- channel_id=placement.channel_id,
- created_at__lt=placement.created_at,
- ).count()
- placement_number = placements_before + 1
-
placement_posts = await self.database.get_workspace_placement_posts(
input.workspace_id,
placement_id=placement.id,
@@ -139,7 +127,6 @@ async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> d
placement_output = _build_placement_output(
placement,
project=project,
- placement_number=placement_number,
creative_name=creative_name,
)
return dto.PlacementWithPostsOutput(
diff --git a/src/usecase/purchase/get_placements.py b/src/usecase/purchase/get_placements.py
index a9770db..a0efd09 100644
--- a/src/usecase/purchase/get_placements.py
+++ b/src/usecase/purchase/get_placements.py
@@ -27,6 +27,11 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, project.id)
+ # Prefetch project channel for output building
+ if project.channel is None:
+ project_channel = await self.database.get_channel(channel_id=project.channel_id)
+ project.channel = project_channel
+
placements = await self.database.get_project_placements(input.workspace_id, project.id)
log.debug('Fetched %s placements for project %s', len(placements), project.id)
diff --git a/tg_bot/backend/backend_client.go b/tg_bot/backend/backend_client.go
index 058274c..73978ce 100644
--- a/tg_bot/backend/backend_client.go
+++ b/tg_bot/backend/backend_client.go
@@ -570,9 +570,13 @@ type CreateChannelResult struct {
}
type ProjectOutput struct {
- ID string `json:"id"`
- Status string `json:"status"`
- Channel Channel `json:"channel"`
+ ID string `json:"id"`
+ TelegramID int64 `json:"telegram_id"`
+ Title string `json:"title"`
+ Username *string `json:"username"`
+ Status string `json:"status"`
+ PurchaseInviteTypeDefault string `json:"purchase_invite_type_default"`
+ Channel Channel `json:"channel"`
}
type CreateChannelsOutput struct {
@@ -598,18 +602,18 @@ type PlacementDetails struct {
}
type PlacementOutput struct {
- ID string `json:"id"`
- Status string `json:"status"`
- CreativeID *string `json:"creative_id,omitempty"`
- CreativeName *string `json:"creative_name,omitempty"`
- Comment *string `json:"comment,omitempty"`
- InviteLink *string `json:"invite_link,omitempty"`
- InviteLinkType string `json:"invite_link_type"`
- Channel Channel `json:"channel"`
- Project *ProjectOutput `json:"project"`
- PlacementNumber int `json:"placement_number"`
- Details *PlacementDetails `json:"details,omitempty"`
- PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"`
+ ID string `json:"id"`
+ Status string `json:"status"`
+ CreativeID *string `json:"creative_id,omitempty"`
+ CreativeName *string `json:"creative_name,omitempty"`
+ Comment *string `json:"comment,omitempty"`
+ InviteLink *string `json:"invite_link,omitempty"`
+ InviteLinkType string `json:"invite_link_type"`
+ Channel Channel `json:"channel"`
+ Project *ProjectOutput `json:"project"`
+ ShortID string `json:"short_id"`
+ Details *PlacementDetails `json:"details,omitempty"`
+ PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"`
}
type PlacementPostOutput struct {
diff --git a/tg_bot/screens/add_creative.go b/tg_bot/screens/add_creative.go
index 83f8e80..802337a 100644
--- a/tg_bot/screens/add_creative.go
+++ b/tg_bot/screens/add_creative.go
@@ -55,7 +55,7 @@ const msgDownloadMediaError = `
const msgInviteLinkRequired = `
❌ Ошибка валидации
-Текст должен содержать ОДНУ инвайт-ссылку вашего канала!
+Текст должен содержать хотя бы одну инвайт-ссылку вашего канала!
Формат ссылки:
https://t.me/+xxx
diff --git a/tg_bot/screens/creative_details.go b/tg_bot/screens/creative_details.go
index b3d150a..3e7ef21 100644
--- a/tg_bot/screens/creative_details.go
+++ b/tg_bot/screens/creative_details.go
@@ -16,9 +16,15 @@ import (
type CreativeDetails struct {
CreativeEditorFields // Встраивание общих полей
- CreativeID string
- ProjectID string
- BackState bot.State
+ CreativeID string
+ ProjectID string
+ ProjectTitle string // Название проекта для сообщений
+
+ BackState bot.State
+
+ // View mode: открыты из режима просмотра
+ ViewMode bool // Открыты из CreativeView
+ ViewBackTo bot.State // Состояние для возврата (CreativeView)
// Флаг подтверждения удаления
confirmingDelete bool
@@ -89,7 +95,10 @@ func (s *CreativeDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
switch {
case data == "cancel", data == "back":
// Отмена - возвращаемся назад
- if s.BackState != nil {
+ if s.ViewMode && s.ViewBackTo != nil {
+ // Если открыты из view mode, возвращаемся к просмотру
+ b.SetState(s.ViewBackTo, bot.EditMessage)
+ } else if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
@@ -355,7 +364,12 @@ func (s *CreativeDetails) updateCreative(b *bot.Bot) {
log.Info().Str("creative_id", s.CreativeID).Msg("Creative updated successfully")
// Возвращаемся назад
- if s.BackState != nil {
+ if s.ViewMode && s.ViewBackTo != nil {
+ // Если открыты из view mode, возвращаемся к просмотру
+ b.SendNew("✅ Креатив успешно обновлен!", Keyboard())
+ b.SetState(s.ViewBackTo, bot.EditMessage)
+ } else if s.BackState != nil {
+ // Иначе возвращаемся к BackState
b.SendNew("✅ Креатив успешно обновлен!", Keyboard())
b.SetState(s.BackState, bot.EditMessage)
}
diff --git a/tg_bot/screens/creative_view.go b/tg_bot/screens/creative_view.go
new file mode 100644
index 0000000..01e4ffc
--- /dev/null
+++ b/tg_bot/screens/creative_view.go
@@ -0,0 +1,218 @@
+package screens
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/NicoNex/echotron/v3"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/backend"
+ "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
+ "github.com/rs/zerolog/log"
+)
+
+// CreativeView - экран просмотра креатива (read-only mode)
+// Показывает превью креатива и кнопки действий: Редактировать, Тег, Закупить
+type CreativeView struct {
+ CreativeEditorFields // Встраивание общих полей для отображения превью
+
+ CreativeID string
+ ProjectID string
+
+ // Данные проекта для передачи в AddPurchase
+ ProjectTitle string
+ ProjectTelegramID int64
+ ProjectUsername string
+ ProjectStatus string
+
+ BackState bot.State
+}
+
+const msgCreativeView = `
+📋 Просмотр креатива
+
+Выберите действие:
+`
+
+func (s *CreativeView) Enter(b *bot.Bot, mode bot.RenderMode) {
+ // Загружаем креатив
+ creative, err := b.Backend.GetCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID)
+ if err != nil {
+ log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to get creative")
+ b.SendNew("❌ Не удалось загрузить креатив", Keyboard(
+ Row(Button("← Назад", "back")),
+ ))
+ return
+ }
+
+ // Загружаем проект для получения данных
+ project, err := b.Backend.GetProject(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectID)
+ if err != nil {
+ log.Error().Err(err).Str("project_id", s.ProjectID).Msg("Failed to get project")
+ } else {
+ s.ProjectTitle = project.Title
+ s.ProjectTelegramID = project.TelegramID
+ s.ProjectStatus = project.Status
+ if project.Username != nil {
+ s.ProjectUsername = *project.Username
+ }
+ }
+
+ // Заполняем поля креатива для отображения превью
+ s.Name = &creative.Name
+ if creative.Text != "" {
+ s.Text = &creative.Text
+ }
+ s.MediaItems = nil
+ if len(creative.MediaItems) > 0 {
+ for _, item := range creative.MediaItems {
+ s.MediaItems = append(s.MediaItems, MediaItem{
+ MediaType: item.MediaType,
+ MediaFileID: item.MediaFileID,
+ })
+ }
+ }
+ s.Buttons = nil
+ for _, btn := range creative.Buttons {
+ s.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL})
+ }
+ if creative.Tag != "" {
+ s.Tag = &creative.Tag
+ }
+
+ // Отправляем превью креатива
+ s.SendCreativePreview(b)
+
+ // Отправляем панель управления с кнопками действий
+ s.showActionPanel(b)
+}
+
+func (s *CreativeView) HandleCallback(b *bot.Bot, u *echotron.Update) {
+ if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
+ return
+ }
+
+ data := u.CallbackQuery.Data
+
+ switch {
+ case data == "back":
+ if s.BackState != nil {
+ b.SetState(s.BackState, bot.EditMessage)
+ }
+
+ case data == "edit":
+ // Переход к редактированию креатива
+ b.SetState(&CreativeDetails{
+ CreativeID: s.CreativeID,
+ ProjectID: s.ProjectID,
+ ViewMode: true, // Флаг, что мы открылись из view mode
+ ViewBackTo: s, // Возврат к этому экрану после сохранения
+ ProjectTitle: s.ProjectTitle,
+ BackState: s.BackState,
+ }, bot.NewMessage)
+
+ case data == "toggle_tag":
+ // Переключение тега (testing <-> production)
+ s.toggleTag(b)
+
+ case data == "purchase_creative":
+ // Переход к созданию закупа с предвыбором проекта и креатива
+ creativeTitle := ""
+ if s.Name != nil {
+ creativeTitle = *s.Name
+ }
+
+ b.SetState(&AddPurchase{
+ ProjectID: s.ProjectID,
+ ProjectTitle: s.ProjectTitle,
+ ProjectTelegramID: s.ProjectTelegramID,
+ ProjectUsername: s.ProjectUsername,
+ ProjectStatus: s.ProjectStatus,
+ CreativeID: s.CreativeID,
+ CreativeTitle: creativeTitle,
+ BackState: s,
+ }, bot.NewMessage)
+
+ default:
+ log.Warn().Str("callback", data).Msg("Unknown callback in CreativeView")
+ }
+}
+
+func (s *CreativeView) HandleMessage(_ *bot.Bot, _ *echotron.Update) {}
+
+func (s *CreativeView) Handle(_ *bot.Bot, _ *echotron.Update) { return }
+
+func (s *CreativeView) Exit() {}
+
+// showActionPanel показывает панель действий с кнопками
+func (s *CreativeView) showActionPanel(b *bot.Bot) {
+ tagLabel := s.GetTagLabel()
+ purchaseLabel := "🛒 Закупить на креатив"
+
+ // Если креатив архивирован (в будущем), можно изменить кнопки
+ var buttons [][]echotron.InlineKeyboardButton
+
+ // Кнопка редактирования
+ buttons = append(buttons, Row(
+ Button("✏️ Редактировать", "edit"),
+ ))
+
+ // Кнопка тега
+ buttons = append(buttons, Row(
+ Button(tagLabel, "toggle_tag"),
+ ))
+
+ // Кнопка закупа
+ buttons = append(buttons, Row(
+ Button(purchaseLabel, "purchase_creative"),
+ ))
+
+ // Кнопка назад
+ buttons = append(buttons, Row(
+ Button("← Назад", "back"),
+ ))
+
+ s.ShowControlPanel(b, msgCreativeView, buttons)
+}
+
+// toggleTag переключает тег креатива (testing <-> production)
+func (s *CreativeView) toggleTag(b *bot.Bot) {
+ newTag := "testing"
+ if s.Tag != nil && *s.Tag == "testing" {
+ newTag = "production"
+ }
+
+ // Обновляем тег через backend
+ input := backend.UpdateCreativeInput{
+ Tag: &newTag,
+ }
+
+ _, err := b.Backend.UpdateCreative(
+ context.Background(),
+ b.Session.JWT,
+ b.Session.WorkspaceID,
+ s.CreativeID,
+ input,
+ )
+
+ if err != nil {
+ log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to update creative tag")
+ s.ShowControlPanel(b, "❌ Не удалось изменить тег\n\nПопробуйте еще раз.", [][]echotron.InlineKeyboardButton{
+ Row(Button("← Назад", "back")),
+ Row(Button("↻ Попробовать снова", "toggle_tag")),
+ })
+ return
+ }
+
+ // Обновляем локальное значение
+ s.Tag = &newTag
+
+ // Показываем успех и обновляем панель
+ tagText := "🧪 Тестовый"
+ if newTag == "production" {
+ tagText = "🚀 Рабочий"
+ }
+ b.SendNew(fmt.Sprintf("✅ Тег изменен на «%s»", tagText), Keyboard())
+
+ // Обновляем панель
+ s.showActionPanel(b)
+}
diff --git a/tg_bot/screens/creatives.go b/tg_bot/screens/creatives.go
index 099d3c2..c49d93d 100644
--- a/tg_bot/screens/creatives.go
+++ b/tg_bot/screens/creatives.go
@@ -120,10 +120,16 @@ func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) {
return
}
- b.SetState(&CreativeDetails{
+ // Открываем CreativeView вместо CreativeDetails
+ b.SetState(&CreativeView{
CreativeID: creativeID,
ProjectID: s.ProjectID,
- BackState: s,
+ // Данные проекта для передачи в AddPurchase
+ ProjectTitle: s.ProjectTitle,
+ ProjectTelegramID: s.ProjectTelegramID,
+ ProjectUsername: s.ProjectUsername,
+ ProjectStatus: s.ProjectStatus,
+ BackState: s,
}, bot.NewMessage)
default:
diff --git a/tg_bot/screens/help.go b/tg_bot/screens/help.go
index 32fe913..cd38e90 100644
--- a/tg_bot/screens/help.go
+++ b/tg_bot/screens/help.go
@@ -29,7 +29,7 @@ type Help struct{}
func (s *Help) Enter(b *bot.Bot, mode bot.RenderMode) {
keyboard := Keyboard(
- Row(URLButton("Поддержка", "https://example.com")),
+ Row(URLButton("Поддержка", "https://t.me/SmartpostSupport")),
Row(Button("↩ В главное меню", "main_menu")),
)
diff --git a/tg_bot/screens/main_menu.go b/tg_bot/screens/main_menu.go
index a410de5..f8d00b9 100644
--- a/tg_bot/screens/main_menu.go
+++ b/tg_bot/screens/main_menu.go
@@ -14,14 +14,22 @@ type MainMenu struct{}
const msgAbout = `
Главное меню
-Smartpost — платформа для покупки и продажи рекламы без ЛС и ручных таблиц.
+
+За что отвечает каждая кнопка в этом окне: -• Внутренний счёт и безопасные сделки -• Фиксация цен, дат и форматов -• Автоматические отчёты -• Повторные закупки в пару кликов +• Рабочее пространство — Одно можно использовать для своих проектов, а другое, например, для проектов, где вы являетесь закупщиком. -Комиссия — 7%. Без скрытых платежей. +• Мои проекты — Управляйте вашими телеграм-каналами: добавляйте новые, привязывайте к ним креативы, создавайте закупы. + +• Размещения — Просматривайте статистику по всем сделанным размещениям или создавайте новые (бот автоматически сформирует для вас креатив и заменит в нём пригласительную ссылку). + +• Веб-платформа — Используйте функционал нашего сервиса на все 100%: планируйте размещения, просматривайте аналитику, следите за показателями проектов. + +• Помощь — Изучите инструкции, как пользоваться нашим сервисом или напишите в поддержку, если возникли какие-то вопросы. ++ +Чтобы делать закупы, добавьте канал и креатив: +/addchannel — новый канал ` func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) { @@ -65,8 +73,8 @@ func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) { keyboard := Keyboard( Row(Button(fmt.Sprintf("Рабочее пространство: %s", workspaceName), "workspace_menu")), - Row(Button("Мои проекты", "my_projects"), URLButton("Поддержка", "https://example.com")), - Row(Button("Размещения", "placements"), URLButton("Дашборд", "https://example.com")), + Row(Button("Мои проекты", "my_projects"), URLButton("Поддержка", "https://t.me/SmartpostSupport")), + Row(Button("Размещения", "placements"), URLButton("Дашборд", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s", b.Session.WorkspaceID))), ) b.Render(msg, keyboard, mode) diff --git a/tg_bot/screens/placement_details.go b/tg_bot/screens/placement_details.go index 8b3e35e..bda0877 100644 --- a/tg_bot/screens/placement_details.go +++ b/tg_bot/screens/placement_details.go @@ -57,9 +57,9 @@ func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode) { // Тип ссылки text += fmt.Sprintf("🔓 Тип ссылки: %s\n", formatInviteLinkType(placement.InviteLinkType)) - // Порядковый номер размещения - if placement.PlacementNumber > 0 { - text += fmt.Sprintf("🔢 Размещение №%d в этом канале\n", placement.PlacementNumber) + // Short ID + if placement.ShortID != "" { + text += fmt.Sprintf("🔢 ID: %s\n", placement.ShortID) } // Креатив @@ -242,15 +242,63 @@ func formatDateTime(value string) string { if value == "" { return value } - if parsed, err := time.Parse(time.RFC3339, value); err == nil { - return parsed.Format("02.01.2006 15:04") + var parsed time.Time + var err error + + // Try parsing with timezone + if parsed, err = time.Parse(time.RFC3339, value); err == nil { + return formatCompactDateTime(parsed) } - if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil { - return parsed.Format("02.01.2006 15:04") + if parsed, err = time.Parse(time.RFC3339Nano, value); err == nil { + return formatCompactDateTime(parsed) + } + if parsed, err = time.Parse("2006-01-02T15:04:05", value); err == nil { + return formatCompactDateTime(parsed) + } + if parsed, err = time.Parse("2006-01-02", value); err == nil { + return formatCompactDateTime(parsed) } return value } +func WeekdayName(day time.Weekday) string { + switch day { + case time.Monday: + return "Пн" + case time.Tuesday: + return "Вт" + case time.Wednesday: + return "Ср" + case time.Thursday: + return "Чт" + case time.Friday: + return "Пт" + case time.Saturday: + return "Сб" + case time.Sunday: + return "Вс" + default: + return "" + } +} + +func MonthShort(month time.Month) string { + months := []string{ + "янв", "фев", "мар", "апр", "май", "июн", + "июл", "авг", "сен", "окт", "ноя", "дек", + } + if int(month) < 1 || int(month) > len(months) { + return "" + } + return months[int(month)-1] +} + +func formatCompactDateTime(value time.Time) string { + weekday := WeekdayName(value.Weekday()) + month := MonthShort(value.Month()) + return fmt.Sprintf("%s %02d %s %s", weekday, value.Day(), month, value.Format("15:04")) +} + func (s *PlacementDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { return diff --git a/tg_bot/screens/placements.go b/tg_bot/screens/placements.go index a67c6e2..b3b1a94 100644 --- a/tg_bot/screens/placements.go +++ b/tg_bot/screens/placements.go @@ -30,7 +30,7 @@ func (s *Placements) Enter(b *bot.Bot, mode bot.RenderMode) { rows = append(rows, Row(Button("+ Создать размещение", "add_purchase"))) rows = append(rows, Row(Button("Размещения", "placements_list"))) - rows = append(rows, Row(URLButton("План закупов", "https://example.com"))) + rows = append(rows, Row(URLButton("План закупов", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s/purchase-plans/%s", b.Session.WorkspaceID, s.ProjectID)))) rows = append(rows, Row(Button("← Назад", "back"))) keyboard := Keyboard(rows...) diff --git a/tg_bot/screens/platform_link.go b/tg_bot/screens/platform_link.go index 13edc38..8534293 100644 --- a/tg_bot/screens/platform_link.go +++ b/tg_bot/screens/platform_link.go @@ -1,6 +1,8 @@ package screens import ( + "fmt" + "github.com/NicoNex/echotron/v3" "github.com/TelegramExchange/tgex-backend/tg_bot/bot" ) @@ -17,7 +19,7 @@ type PlatformLink struct{} func (s *PlatformLink) Enter(b *bot.Bot, mode bot.RenderMode) { keyboard := Keyboard( - Row(URLButton("Открыть платформу", "https://example.com")), + Row(URLButton("Открыть платформу", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s", b.Session.WorkspaceID))), Row(Button("↩ В главное меню", "main_menu")), ) diff --git a/tg_bot/screens/purchase_optional_details.go b/tg_bot/screens/purchase_optional_details.go index a8cd945..3d0d010 100644 --- a/tg_bot/screens/purchase_optional_details.go +++ b/tg_bot/screens/purchase_optional_details.go @@ -111,12 +111,6 @@ func (s *PurchaseOptionalDetails) Enter(b *bot.Bot, mode bot.RenderMode) { s.inviteLinkTypeButton(), )) - if row := s.costBeforeDeleteRow(); row != nil { - rows = append(rows, row) - } - if row := s.commentDeleteRow(); row != nil { - rows = append(rows, row) - } rows = append(rows, Row(Button("Назад", "back"), Button("Далее", "next"))) keyboard := Keyboard(rows...) @@ -683,10 +677,21 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM } typeLabel := s.costBeforeTypeLabelForCurrent() - keyboard := Keyboard( - Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_before_type_toggle")), - Row(Button("← Назад", s.backAction())), - ) + var keyboard echotron.InlineKeyboardMarkup + + // Показываем кнопку удаления только если есть значение и это не режим per-channel + if s.CurrentChannel == "" && s.CostBeforeBargain != nil && s.CostBeforeBargain.Value != nil { + keyboard = Keyboard( + Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_before_type_toggle")), + Row(Button("⌫ Удалить до торга", "delete_cost_before")), + Row(Button("← Назад", s.backAction())), + ) + } else { + keyboard = Keyboard( + Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_before_type_toggle")), + Row(Button("← Назад", s.backAction())), + ) + } if mode == bot.EditMessage { b.Edit(text, keyboard) @@ -750,6 +755,19 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM return case "comment": text += "Введите комментарий" + keyboard := Keyboard(Row(Button("← Назад", s.backAction()))) + if s.Comment != "" { + keyboard = Keyboard( + Row(Button("⌫ Удалить комментарий", "delete_comment")), + Row(Button("← Назад", s.backAction())), + ) + } + if mode == bot.EditMessage { + b.Edit(text, keyboard) + } else { + b.SendNew(text, keyboard) + } + return default: s.InputMode = "" s.Enter(b, mode) diff --git a/tg_parser/internal/adapter/database/update_channel.go b/tg_parser/internal/adapter/database/update_channel.go index 08d2387..624dc1b 100644 --- a/tg_parser/internal/adapter/database/update_channel.go +++ b/tg_parser/internal/adapter/database/update_channel.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/jackc/pgx/v5/pgtype" + "github.com/rs/zerolog/log" "github.com/TelegramExchange/pkg/transaction" "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" @@ -19,6 +20,7 @@ SET telegram_id = $2, access_hash = $5, pts = $6, is_accessible = $7, + invite_link = $8, updated_at = CURRENT_TIMESTAMP WHERE id = $1;` @@ -32,6 +34,23 @@ WHERE id = $1;` usernameDTO = pgtype.Text{Valid: false} } + inviteLink := strings.TrimSpace(channel.InviteLink) + var inviteLinkDTO pgtype.Text + if inviteLink != "" { + inviteLinkDTO = pgtype.Text{String: inviteLink, Valid: true} + } else { + inviteLinkDTO = pgtype.Text{Valid: false} + } + + // Log warning if both identity fields are empty (will violate CHECK constraint) + if !usernameDTO.Valid && !inviteLinkDTO.Valid { + log.Warn(). + Str("channel_id", channel.ID.String()). + Int64("telegram_id", channel.TelegramID). + Str("title", channel.Title). + Msg("UpdateChannel: both username and invite_link are empty, this may violate CHECK constraint") + } + dto := updateChannelDTO{ ID: pgtype.UUID{Bytes: channel.ID, Valid: true}, TelegramID: pgtype.Int8{Int64: channel.TelegramID, Valid: true}, @@ -40,9 +59,10 @@ WHERE id = $1;` AccessHash: pgtype.Int8{Int64: channel.AccessHash, Valid: true}, Pts: pgtype.Int4{Int32: int32(channel.Pts), Valid: true}, IsAccessible: pgtype.Bool{Bool: channel.IsAccessible, Valid: true}, + InviteLink: inviteLinkDTO, } - _, err := txOrPool.Exec(ctx, query, dto.ID, dto.TelegramID, dto.Username, dto.Title, dto.AccessHash, dto.Pts, dto.IsAccessible) + _, err := txOrPool.Exec(ctx, query, dto.ID, dto.TelegramID, dto.Username, dto.Title, dto.AccessHash, dto.Pts, dto.IsAccessible, dto.InviteLink) if err != nil { return fmt.Errorf("txOrPool.Exec: %w", err) } @@ -58,4 +78,5 @@ type updateChannelDTO struct { AccessHash pgtype.Int8 Pts pgtype.Int4 IsAccessible pgtype.Bool + InviteLink pgtype.Text }