правки
This commit is contained in:
@@ -0,0 +1,103 @@
|
|||||||
|
from tortoise import BaseDBAsyncClient
|
||||||
|
|
||||||
|
RUN_IN_TRANSACTION = True
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
ALTER TABLE "placement" ADD "cost_before_bargain_type" VARCHAR(8);
|
||||||
|
COMMENT ON COLUMN "placement"."cost_before_bargain_type" IS 'FIXED: fixed\nCPM: cpm';"""
|
||||||
|
|
||||||
|
|
||||||
|
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
ALTER TABLE "placement" DROP COLUMN "cost_before_bargain_type";"""
|
||||||
|
|
||||||
|
|
||||||
|
MODELS_STATE = (
|
||||||
|
"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"
|
||||||
|
)
|
||||||
@@ -17,11 +17,21 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
class TelegramBot(TelegramBase, TelegramBotWriter):
|
class TelegramBot(TelegramBase, TelegramBotWriter):
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self, text: str, chat_id: int, parse_mode: str | None = None, disable_preview: bool = False
|
self,
|
||||||
) -> None:
|
text: str,
|
||||||
await self.bot.send_message(
|
chat_id: int,
|
||||||
chat_id=chat_id, text=text, parse_mode=parse_mode, disable_web_page_preview=disable_preview
|
parse_mode: str | None = None,
|
||||||
|
disable_preview: bool = False,
|
||||||
|
reply_to_message_id: int | None = None,
|
||||||
|
) -> int:
|
||||||
|
message = await self.bot.send_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
text=text,
|
||||||
|
parse_mode=parse_mode,
|
||||||
|
disable_web_page_preview=disable_preview,
|
||||||
|
reply_to_message_id=reply_to_message_id,
|
||||||
)
|
)
|
||||||
|
return message.message_id
|
||||||
|
|
||||||
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) -> str:
|
||||||
invite_link = await self.bot.create_chat_invite_link(chat_id=chat_id, creates_join_request=requires_approval)
|
invite_link = await self.bot.create_chat_invite_link(chat_id=chat_id, creates_join_request=requires_approval)
|
||||||
@@ -34,15 +44,18 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
|
|||||||
buttons: list[list[InlineKeyboardButton]],
|
buttons: list[list[InlineKeyboardButton]],
|
||||||
parse_mode: str | None = None,
|
parse_mode: str | None = None,
|
||||||
disable_preview: bool = False,
|
disable_preview: bool = False,
|
||||||
) -> None:
|
reply_to_message_id: int | None = None,
|
||||||
|
) -> int:
|
||||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
await self.bot.send_message(
|
message = await self.bot.send_message(
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
text=text,
|
text=text,
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode=parse_mode,
|
parse_mode=parse_mode,
|
||||||
disable_web_page_preview=disable_preview,
|
disable_web_page_preview=disable_preview,
|
||||||
|
reply_to_message_id=reply_to_message_id,
|
||||||
)
|
)
|
||||||
|
return message.message_id
|
||||||
|
|
||||||
async def send_media_with_inline_keyboard(
|
async def send_media_with_inline_keyboard(
|
||||||
self,
|
self,
|
||||||
@@ -52,41 +65,47 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
|
|||||||
media_file_id: str,
|
media_file_id: str,
|
||||||
buttons: list[list[InlineKeyboardButton]],
|
buttons: list[list[InlineKeyboardButton]],
|
||||||
parse_mode: str | None = None,
|
parse_mode: str | None = None,
|
||||||
) -> None:
|
reply_to_message_id: int | None = None,
|
||||||
|
) -> int:
|
||||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) if buttons else None
|
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) if buttons else None
|
||||||
if media_type == 'photo':
|
if media_type == 'photo':
|
||||||
await self.bot.send_photo(
|
message = await self.bot.send_photo(
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
photo=media_file_id,
|
photo=media_file_id,
|
||||||
caption=text,
|
caption=text,
|
||||||
parse_mode=parse_mode,
|
parse_mode=parse_mode,
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
|
reply_to_message_id=reply_to_message_id,
|
||||||
)
|
)
|
||||||
return
|
return message.message_id
|
||||||
if media_type == 'video':
|
if media_type == 'video':
|
||||||
await self.bot.send_video(
|
message = await self.bot.send_video(
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
video=media_file_id,
|
video=media_file_id,
|
||||||
caption=text,
|
caption=text,
|
||||||
parse_mode=parse_mode,
|
parse_mode=parse_mode,
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
|
reply_to_message_id=reply_to_message_id,
|
||||||
)
|
)
|
||||||
return
|
return message.message_id
|
||||||
if media_type == 'animation':
|
if media_type == 'animation':
|
||||||
await self.bot.send_animation(
|
message = await self.bot.send_animation(
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
animation=media_file_id,
|
animation=media_file_id,
|
||||||
caption=text,
|
caption=text,
|
||||||
parse_mode=parse_mode,
|
parse_mode=parse_mode,
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
|
reply_to_message_id=reply_to_message_id,
|
||||||
)
|
)
|
||||||
return
|
return message.message_id
|
||||||
await self.bot.send_message(
|
message = await self.bot.send_message(
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
text=text,
|
text=text,
|
||||||
parse_mode=parse_mode,
|
parse_mode=parse_mode,
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
|
reply_to_message_id=reply_to_message_id,
|
||||||
)
|
)
|
||||||
|
return message.message_id
|
||||||
|
|
||||||
async def send_media_group(
|
async def send_media_group(
|
||||||
self,
|
self,
|
||||||
@@ -94,9 +113,10 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
|
|||||||
media_items: Sequence[Any],
|
media_items: Sequence[Any],
|
||||||
caption: str | None = None,
|
caption: str | None = None,
|
||||||
parse_mode: str | None = None,
|
parse_mode: str | None = None,
|
||||||
) -> None:
|
reply_to_message_id: int | None = None,
|
||||||
|
) -> int:
|
||||||
if not media_items:
|
if not media_items:
|
||||||
return
|
raise ValueError('No media items to send')
|
||||||
|
|
||||||
group: list[InputMediaPhoto | InputMediaVideo] = []
|
group: list[InputMediaPhoto | InputMediaVideo] = []
|
||||||
for index, item in enumerate(media_items):
|
for index, item in enumerate(media_items):
|
||||||
@@ -113,7 +133,11 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
|
|||||||
raise ValueError(f'Unsupported media type for group: {item.media_type}')
|
raise ValueError(f'Unsupported media type for group: {item.media_type}')
|
||||||
|
|
||||||
# Type ignore: aiogram expects Union type but we only use Photo/Video
|
# Type ignore: aiogram expects Union type but we only use Photo/Video
|
||||||
await self.bot.send_media_group(chat_id=chat_id, media=group) # type: ignore[arg-type]
|
messages = await self.bot.send_media_group(
|
||||||
|
chat_id=chat_id, media=group, reply_to_message_id=reply_to_message_id
|
||||||
|
) # type: ignore[arg-type]
|
||||||
|
# Return message_id of first message (with caption)
|
||||||
|
return messages[0].message_id
|
||||||
|
|
||||||
async def edit_message_text(self, text: str, chat_id: int, message_id: int) -> None:
|
async def edit_message_text(self, text: str, chat_id: int, message_id: int) -> None:
|
||||||
await self.bot.edit_message_text(chat_id=chat_id, message_id=message_id, text=text, reply_markup=None)
|
await self.bot.edit_message_text(chat_id=chat_id, message_id=message_id, text=text, reply_markup=None)
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ class Placement(TimestampedModel):
|
|||||||
payment_at = fields.DatetimeField(null=True)
|
payment_at = fields.DatetimeField(null=True)
|
||||||
cost_type = fields.CharEnumField(CostType, null=True, max_length=8)
|
cost_type = fields.CharEnumField(CostType, null=True, max_length=8)
|
||||||
cost_value = fields.FloatField(null=True)
|
cost_value = fields.FloatField(null=True)
|
||||||
|
cost_before_bargain_type = fields.CharEnumField(CostType, null=True, max_length=8)
|
||||||
cost_before_bargain = fields.FloatField(null=True)
|
cost_before_bargain = fields.FloatField(null=True)
|
||||||
placement_type = fields.CharEnumField(PlacementType, null=True, max_length=16)
|
placement_type = fields.CharEnumField(PlacementType, null=True, max_length=16)
|
||||||
format = fields.TextField(null=True)
|
format = fields.TextField(null=True)
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ class PlacementDetails(pydantic.BaseModel):
|
|||||||
placement_at: datetime.datetime | None = None
|
placement_at: datetime.datetime | None = None
|
||||||
payment_at: datetime.datetime | None = None
|
payment_at: datetime.datetime | None = None
|
||||||
cost: CostInfo | None = None
|
cost: CostInfo | None = None
|
||||||
cost_before_bargain: float | None = None
|
cost_before_bargain: CostInfo | None = None
|
||||||
placement_type: PlacementType | None = None
|
placement_type: PlacementType | None = None
|
||||||
format: str | None = None
|
format: str | None = None
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
creative_id: uuid.UUID | None = None
|
creative_id: uuid.UUID | None = None
|
||||||
|
invite_link_type: InviteLinkType | None = None
|
||||||
|
|
||||||
|
|
||||||
class PlacementOutput(pydantic.BaseModel):
|
class PlacementOutput(pydantic.BaseModel):
|
||||||
@@ -102,7 +103,7 @@ class UpdatePlacementInput(pydantic.BaseModel):
|
|||||||
placement_at: datetime.datetime | None = None
|
placement_at: datetime.datetime | None = None
|
||||||
payment_at: datetime.datetime | None = None
|
payment_at: datetime.datetime | None = None
|
||||||
cost: CostInfo | None = None
|
cost: CostInfo | None = None
|
||||||
cost_before_bargain: float | None = None
|
cost_before_bargain: CostInfo | None = None
|
||||||
placement_type: PlacementType | None = None
|
placement_type: PlacementType | None = None
|
||||||
format: str | None = None
|
format: str | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -72,8 +72,13 @@ class MediaItem(typing.Protocol):
|
|||||||
|
|
||||||
class TelegramBotWriter(typing.Protocol):
|
class TelegramBotWriter(typing.Protocol):
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self, text: str, chat_id: int, parse_mode: str | None = None, disable_preview: bool = False
|
self,
|
||||||
) -> None: ...
|
text: str,
|
||||||
|
chat_id: int,
|
||||||
|
parse_mode: str | None = None,
|
||||||
|
disable_preview: bool = False,
|
||||||
|
reply_to_message_id: int | None = None,
|
||||||
|
) -> int: ...
|
||||||
|
|
||||||
async def send_message_with_inline_keyboard(
|
async def send_message_with_inline_keyboard(
|
||||||
self,
|
self,
|
||||||
@@ -82,7 +87,8 @@ class TelegramBotWriter(typing.Protocol):
|
|||||||
buttons: list[list['InlineKeyboardButton']],
|
buttons: list[list['InlineKeyboardButton']],
|
||||||
parse_mode: str | None = None,
|
parse_mode: str | None = None,
|
||||||
disable_preview: bool = False,
|
disable_preview: bool = False,
|
||||||
) -> None: ...
|
reply_to_message_id: int | None = None,
|
||||||
|
) -> int: ...
|
||||||
|
|
||||||
async def send_media_with_inline_keyboard(
|
async def send_media_with_inline_keyboard(
|
||||||
self,
|
self,
|
||||||
@@ -92,7 +98,8 @@ class TelegramBotWriter(typing.Protocol):
|
|||||||
media_file_id: str,
|
media_file_id: str,
|
||||||
buttons: list[list['InlineKeyboardButton']],
|
buttons: list[list['InlineKeyboardButton']],
|
||||||
parse_mode: str | None = None,
|
parse_mode: str | None = None,
|
||||||
) -> None: ...
|
reply_to_message_id: int | None = None,
|
||||||
|
) -> int: ...
|
||||||
|
|
||||||
async def send_media_group(
|
async def send_media_group(
|
||||||
self,
|
self,
|
||||||
@@ -100,7 +107,8 @@ class TelegramBotWriter(typing.Protocol):
|
|||||||
media_items: Sequence[MediaItem],
|
media_items: Sequence[MediaItem],
|
||||||
caption: str | None = None,
|
caption: str | None = None,
|
||||||
parse_mode: str | None = None,
|
parse_mode: str | None = None,
|
||||||
) -> None: ...
|
reply_to_message_id: int | None = None,
|
||||||
|
) -> int: ...
|
||||||
|
|
||||||
async def edit_message_text(self, text: str, chat_id: int, message_id: int) -> None: ...
|
async def edit_message_text(self, text: str, chat_id: int, message_id: int) -> None: ...
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,73 @@ def _format_channel_name(channel: domain.Channel) -> str:
|
|||||||
return 'Без названия'
|
return 'Без названия'
|
||||||
|
|
||||||
|
|
||||||
|
def _format_channel_link(channel: domain.Channel) -> str:
|
||||||
|
"""Format channel as HTML link if possible, otherwise return plain text."""
|
||||||
|
name = _format_channel_name(channel)
|
||||||
|
|
||||||
|
# Try invite_link first
|
||||||
|
if channel.invite_link:
|
||||||
|
return f'<a href="{channel.invite_link}">{name}</a>'
|
||||||
|
|
||||||
|
# Try username
|
||||||
|
if channel.username:
|
||||||
|
return f'<a href="https://t.me/{channel.username}">{name}</a>'
|
||||||
|
|
||||||
|
# No link available, return plain name
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _build_info_message(
|
||||||
|
placement: domain.Placement,
|
||||||
|
project: domain.Project,
|
||||||
|
) -> str:
|
||||||
|
"""Build informational message about placement."""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Header
|
||||||
|
lines.append('Ссылка зашита с помощью @smartpost_tg_bot')
|
||||||
|
lines.append('')
|
||||||
|
|
||||||
|
# Placement channel (always present)
|
||||||
|
placement_channel_link = _format_channel_link(placement.channel)
|
||||||
|
lines.append(f'Размещение в канале: {placement_channel_link}')
|
||||||
|
|
||||||
|
# Project channel (always present)
|
||||||
|
project_channel_link = _format_channel_link(project.channel)
|
||||||
|
lines.append(f'Рекламируемый проект: {project_channel_link}')
|
||||||
|
|
||||||
|
# Invite link type (always present)
|
||||||
|
link_type_text = 'Открытая' if placement.invite_link_type == domain.InviteLinkType.PUBLIC else 'С заявками'
|
||||||
|
lines.append(f'Тип ссылки: {link_type_text}')
|
||||||
|
|
||||||
|
# Cost / CPM (optional)
|
||||||
|
if placement.cost_value is not None and placement.cost_value > 0:
|
||||||
|
if placement.cost_type == domain.CostType.CPM:
|
||||||
|
lines.append(f'Ставка CPM: {placement.cost_value:.0f} ₽')
|
||||||
|
else:
|
||||||
|
lines.append(f'Стоимость: {placement.cost_value:.0f} ₽')
|
||||||
|
|
||||||
|
# Date and time (optional)
|
||||||
|
if placement.placement_at:
|
||||||
|
# Check if time is 00:00 (midnight) - then show only date
|
||||||
|
if placement.placement_at.hour == 0 and placement.placement_at.minute == 0:
|
||||||
|
date_str = placement.placement_at.strftime('%d.%m.%Y')
|
||||||
|
lines.append(f'Дата: {date_str}')
|
||||||
|
else:
|
||||||
|
datetime_str = placement.placement_at.strftime('%d.%m.%Y %H:%M')
|
||||||
|
lines.append(f'Дата и время: {datetime_str}')
|
||||||
|
|
||||||
|
# Format (optional)
|
||||||
|
if placement.format:
|
||||||
|
lines.append(f'Формат: {placement.format}')
|
||||||
|
|
||||||
|
# Add empty line before footer
|
||||||
|
lines.append('')
|
||||||
|
lines.append('Пост ниже')
|
||||||
|
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _build_keyboard_buttons(buttons: list[dto.CreativeButton]) -> list[list[InlineKeyboardButton]]:
|
def _build_keyboard_buttons(buttons: list[dto.CreativeButton]) -> list[list[InlineKeyboardButton]]:
|
||||||
return [[InlineKeyboardButton(text=btn.text, url=btn.url)] for btn in buttons]
|
return [[InlineKeyboardButton(text=btn.text, url=btn.url)] for btn in buttons]
|
||||||
|
|
||||||
@@ -120,10 +187,12 @@ async def build_placement_creative(
|
|||||||
raise domain.UserNotFound(user_id)
|
raise domain.UserNotFound(user_id)
|
||||||
|
|
||||||
chat_id = user.telegram_user.telegram_id
|
chat_id = user.telegram_user.telegram_id
|
||||||
channel_name = _format_channel_name(placement.channel)
|
|
||||||
header = f'<b>Канал:</b> {channel_name}'
|
|
||||||
await self.telegram_bot.send_message(header, chat_id, parse_mode='HTML')
|
|
||||||
|
|
||||||
|
# Build and send informational message
|
||||||
|
info_message = _build_info_message(placement, project)
|
||||||
|
info_message_id = await self.telegram_bot.send_message(info_message, chat_id, parse_mode='HTML')
|
||||||
|
|
||||||
|
# Send creative as reply to informational message
|
||||||
keyboard_buttons = _build_keyboard_buttons(preview.buttons)
|
keyboard_buttons = _build_keyboard_buttons(preview.buttons)
|
||||||
if preview.media_items:
|
if preview.media_items:
|
||||||
if len(preview.media_items) == 1:
|
if len(preview.media_items) == 1:
|
||||||
@@ -135,6 +204,7 @@ async def build_placement_creative(
|
|||||||
media_file_id=media_item.media_file_id,
|
media_file_id=media_item.media_file_id,
|
||||||
buttons=keyboard_buttons,
|
buttons=keyboard_buttons,
|
||||||
parse_mode='HTML',
|
parse_mode='HTML',
|
||||||
|
reply_to_message_id=info_message_id,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await self.telegram_bot.send_media_group(
|
await self.telegram_bot.send_media_group(
|
||||||
@@ -142,6 +212,7 @@ async def build_placement_creative(
|
|||||||
media_items=preview.media_items,
|
media_items=preview.media_items,
|
||||||
caption=preview.text,
|
caption=preview.text,
|
||||||
parse_mode='HTML',
|
parse_mode='HTML',
|
||||||
|
reply_to_message_id=info_message_id,
|
||||||
)
|
)
|
||||||
elif keyboard_buttons:
|
elif keyboard_buttons:
|
||||||
await self.telegram_bot.send_message_with_inline_keyboard(
|
await self.telegram_bot.send_message_with_inline_keyboard(
|
||||||
@@ -150,8 +221,11 @@ async def build_placement_creative(
|
|||||||
keyboard_buttons,
|
keyboard_buttons,
|
||||||
parse_mode='HTML',
|
parse_mode='HTML',
|
||||||
disable_preview=True,
|
disable_preview=True,
|
||||||
|
reply_to_message_id=info_message_id,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await self.telegram_bot.send_message(preview.text, chat_id, parse_mode='HTML', disable_preview=True)
|
await self.telegram_bot.send_message(
|
||||||
|
preview.text, chat_id, parse_mode='HTML', disable_preview=True, reply_to_message_id=info_message_id
|
||||||
|
)
|
||||||
|
|
||||||
return preview
|
return preview
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ def _build_placement_details(placement: domain.Placement) -> dto.PlacementDetail
|
|||||||
placement_at=placement.placement_at,
|
placement_at=placement.placement_at,
|
||||||
payment_at=placement.payment_at,
|
payment_at=placement.payment_at,
|
||||||
cost=_build_cost_info(placement.cost_type, placement.cost_value),
|
cost=_build_cost_info(placement.cost_type, placement.cost_value),
|
||||||
cost_before_bargain=placement.cost_before_bargain,
|
cost_before_bargain=_build_cost_info(placement.cost_before_bargain_type, placement.cost_before_bargain),
|
||||||
placement_type=placement.placement_type,
|
placement_type=placement.placement_type,
|
||||||
format=placement.format,
|
format=placement.format,
|
||||||
comment=placement.comment,
|
comment=placement.comment,
|
||||||
@@ -117,8 +117,10 @@ async def create_placements(
|
|||||||
or (details.cost.type if details and details.cost else None),
|
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)
|
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),
|
or (details.cost.value if details and details.cost else None),
|
||||||
cost_before_bargain=(channel_details.cost_before_bargain if channel_details 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 if details 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)
|
||||||
|
or (details.cost_before_bargain.value if details and details.cost_before_bargain else None),
|
||||||
placement_type=details.placement_type if details 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),
|
format=(channel_details.format if channel_details else None) or (details.format if details else None),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -61,7 +61,12 @@ async def update_placement(
|
|||||||
placement.cost_type = input.cost.type
|
placement.cost_type = input.cost.type
|
||||||
placement.cost_value = input.cost.value
|
placement.cost_value = input.cost.value
|
||||||
if 'cost_before_bargain' in fields_set:
|
if 'cost_before_bargain' in fields_set:
|
||||||
placement.cost_before_bargain = input.cost_before_bargain
|
if input.cost_before_bargain is None:
|
||||||
|
placement.cost_before_bargain_type = None
|
||||||
|
placement.cost_before_bargain = None
|
||||||
|
else:
|
||||||
|
placement.cost_before_bargain_type = input.cost_before_bargain.type
|
||||||
|
placement.cost_before_bargain = input.cost_before_bargain.value
|
||||||
if 'placement_type' in fields_set:
|
if 'placement_type' in fields_set:
|
||||||
placement.placement_type = input.placement_type
|
placement.placement_type = input.placement_type
|
||||||
if 'format' in fields_set:
|
if 'format' in fields_set:
|
||||||
|
|||||||
@@ -581,7 +581,7 @@ type PlacementDetails struct {
|
|||||||
PlacementAt *string `json:"placement_at,omitempty"`
|
PlacementAt *string `json:"placement_at,omitempty"`
|
||||||
PaymentAt *string `json:"payment_at,omitempty"`
|
PaymentAt *string `json:"payment_at,omitempty"`
|
||||||
Cost *CostInfo `json:"cost,omitempty"`
|
Cost *CostInfo `json:"cost,omitempty"`
|
||||||
CostBeforeBargain *float64 `json:"cost_before_bargain,omitempty"`
|
CostBeforeBargain *CostInfo `json:"cost_before_bargain,omitempty"`
|
||||||
PlacementType *string `json:"placement_type,omitempty"`
|
PlacementType *string `json:"placement_type,omitempty"`
|
||||||
Format *string `json:"format,omitempty"`
|
Format *string `json:"format,omitempty"`
|
||||||
Comment *string `json:"comment,omitempty"`
|
Comment *string `json:"comment,omitempty"`
|
||||||
|
|||||||
@@ -271,6 +271,8 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
CreativeID: s.CreativeID,
|
CreativeID: s.CreativeID,
|
||||||
CreativeTitle: s.CreativeTitle,
|
CreativeTitle: s.CreativeTitle,
|
||||||
Channels: []PurchaseChannelInput{},
|
Channels: []PurchaseChannelInput{},
|
||||||
|
Duplicates: []string{},
|
||||||
|
ParsingErrors: []ParseError{},
|
||||||
BackState: s.BackState,
|
BackState: s.BackState,
|
||||||
}, bot.EditMessage)
|
}, bot.EditMessage)
|
||||||
}
|
}
|
||||||
|
|||||||
380
tg_bot/screens/channel_parser.go
Normal file
380
tg_bot/screens/channel_parser.go
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
package screens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChannelInput представляет результат парсинга ввода пользователя
|
||||||
|
type ChannelInput struct {
|
||||||
|
Input string // оригинальный ввод пользователя
|
||||||
|
Username string // извлеченный username (если применимо)
|
||||||
|
InviteLink string // извлеченный invite link (если применимо)
|
||||||
|
Source string // источник: telegram, tgstat, telemetr
|
||||||
|
Type string // тип: username, invite, deeplink, stat
|
||||||
|
Valid bool // валидный ли формат
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсеры для разных форматов
|
||||||
|
var (
|
||||||
|
// Username без @: 5-32 символа, начинается с буквы
|
||||||
|
channelUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{4,31}$`)
|
||||||
|
|
||||||
|
// Invite link: t.me/+xxxxx или t.me/joinchat/xxxxx
|
||||||
|
inviteLinkRe = regexp.MustCompile(`^t\.me/\+[a-zA-Z0-9_-]+$`)
|
||||||
|
inviteLinkJoinRe = regexp.MustCompile(`^t\.me/joinchat/[a-zA-Z0-9_-]+$`)
|
||||||
|
|
||||||
|
// TGStat: tgstat.ru/channel_name или tgstat.ru/channel/@username
|
||||||
|
tgstatRe = regexp.MustCompile(`^(?:https?://)?(?:www\.)?tgstat\.ru/(?:channel/)?@?([A-Za-z][A-Za-z0-9_.]{4,31})`)
|
||||||
|
|
||||||
|
// Telemetr: telemetr.io/channel/username или telemetr.io/channel/@username
|
||||||
|
telemetrRe = regexp.MustCompile(`^(?:https?://)?(?:www\.)?telemetr\.io/(?:channel|channels)/@?([A-Za-z][A-Za-z0-9_.]{4,31})`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseChannelInput парсит ввод пользователя и определяет тип канала
|
||||||
|
func ParseChannelInput(input string) ChannelInput {
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
if input == "" {
|
||||||
|
return ChannelInput{Valid: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ChannelInput{Input: input}
|
||||||
|
|
||||||
|
// 1. Проверяем Telegram deeplink (tg://...)
|
||||||
|
if strings.HasPrefix(input, "tg://") {
|
||||||
|
return parseTelegramDeeplink(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Проверяем invite ссылки (t.me/+xxx или t.me/joinchat/xxx)
|
||||||
|
if inviteLinkRe.MatchString(input) || inviteLinkJoinRe.MatchString(input) {
|
||||||
|
result.Type = "invite"
|
||||||
|
result.Source = "telegram"
|
||||||
|
result.InviteLink = normalizeInviteLink(input)
|
||||||
|
result.Valid = true
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Проверяем URL форматы (http://, https://, t.me/, telegram.me/)
|
||||||
|
if isURLFormat(input) {
|
||||||
|
return parseURLFormat(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Проверяем @username
|
||||||
|
if strings.HasPrefix(input, "@") {
|
||||||
|
username := strings.TrimPrefix(input, "@")
|
||||||
|
username = strings.TrimSpace(username)
|
||||||
|
if channelUsernameRe.MatchString(username) {
|
||||||
|
result.Username = username
|
||||||
|
result.Type = "username"
|
||||||
|
result.Source = "telegram"
|
||||||
|
result.Valid = true
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result.Valid = false
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Проверяем username (без @)
|
||||||
|
if channelUsernameRe.MatchString(input) {
|
||||||
|
result.Username = input
|
||||||
|
result.Type = "username"
|
||||||
|
result.Source = "telegram"
|
||||||
|
result.Valid = true
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Ничего не подошло
|
||||||
|
result.Valid = false
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTelegramDeeplink парсит tg:// ссылки
|
||||||
|
func parseTelegramDeeplink(input string) ChannelInput {
|
||||||
|
result := ChannelInput{Input: input, Source: "telegram", Type: "deeplink"}
|
||||||
|
|
||||||
|
// tg://resolve?domain=username&post=123
|
||||||
|
if strings.HasPrefix(input, "tg://resolve?domain=") {
|
||||||
|
parsed, err := url.Parse(input)
|
||||||
|
if err != nil {
|
||||||
|
result.Valid = false
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
query := parsed.Query()
|
||||||
|
domain := query.Get("domain")
|
||||||
|
if domain != "" && channelUsernameRe.MatchString(domain) {
|
||||||
|
result.Username = domain
|
||||||
|
result.Type = "username"
|
||||||
|
result.Valid = true
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tg://join?invite=abcdef
|
||||||
|
if strings.HasPrefix(input, "tg://join?invite=") {
|
||||||
|
parsed, err := url.Parse(input)
|
||||||
|
if err != nil {
|
||||||
|
result.Valid = false
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
query := parsed.Query()
|
||||||
|
invite := query.Get("invite")
|
||||||
|
if invite != "" {
|
||||||
|
result.InviteLink = "t.me/+" + invite
|
||||||
|
result.Type = "invite"
|
||||||
|
result.Valid = true
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Valid = false
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// isURLFormat проверяет, является ли ввод URL или ссылкой
|
||||||
|
func isURLFormat(input string) bool {
|
||||||
|
return strings.HasPrefix(input, "http://") ||
|
||||||
|
strings.HasPrefix(input, "https://") ||
|
||||||
|
strings.HasPrefix(input, "t.me/") ||
|
||||||
|
strings.HasPrefix(input, "telegram.me/") ||
|
||||||
|
strings.Contains(input, "t.me/") ||
|
||||||
|
strings.Contains(input, "telegram.me/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseURLFormat парсит URL-форматы ссылок
|
||||||
|
func parseURLFormat(input string) ChannelInput {
|
||||||
|
result := ChannelInput{Input: input}
|
||||||
|
|
||||||
|
// Нормализуем: убираем протокол
|
||||||
|
normalized := input
|
||||||
|
normalized = strings.TrimPrefix(normalized, "https://")
|
||||||
|
normalized = strings.TrimPrefix(normalized, "http://")
|
||||||
|
normalized = strings.TrimPrefix(normalized, "www.")
|
||||||
|
|
||||||
|
// Проверяем TGStat
|
||||||
|
if matches := tgstatRe.FindStringSubmatch(input); len(matches) > 1 {
|
||||||
|
username := strings.TrimSuffix(matches[1], "_")
|
||||||
|
result.Username = username
|
||||||
|
result.Type = "stat"
|
||||||
|
result.Source = "tgstat"
|
||||||
|
result.Valid = true
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем Telemetr
|
||||||
|
if matches := telemetrRe.FindStringSubmatch(input); len(matches) > 1 {
|
||||||
|
username := strings.TrimSuffix(matches[1], "_")
|
||||||
|
result.Username = username
|
||||||
|
result.Type = "stat"
|
||||||
|
result.Source = "telemetr"
|
||||||
|
result.Valid = true
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем telegram.me или t.me
|
||||||
|
if strings.HasPrefix(normalized, "t.me/") || strings.HasPrefix(normalized, "telegram.me/") {
|
||||||
|
return parseTelegramLink(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Valid = false
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTelegramLink парсит ссылки t.me/... и telegram.me/...
|
||||||
|
func parseTelegramLink(link string) ChannelInput {
|
||||||
|
result := ChannelInput{Source: "telegram"}
|
||||||
|
|
||||||
|
// Убираем t.me/ или telegram.me/
|
||||||
|
path := link
|
||||||
|
if strings.HasPrefix(path, "telegram.me/") {
|
||||||
|
path = strings.TrimPrefix(path, "telegram.me/")
|
||||||
|
} else {
|
||||||
|
path = strings.TrimPrefix(path, "t.me/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем invite link: +xxxxx или joinchat/xxxxx
|
||||||
|
if strings.HasPrefix(path, "+") {
|
||||||
|
inviteHash := strings.TrimPrefix(path, "+")
|
||||||
|
inviteHash = extractBeforeChar(inviteHash, '?', '/')
|
||||||
|
if inviteHash != "" {
|
||||||
|
result.InviteLink = "t.me/+" + inviteHash
|
||||||
|
result.Type = "invite"
|
||||||
|
result.Valid = true
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(path, "joinchat/") {
|
||||||
|
inviteHash := strings.TrimPrefix(path, "joinchat/")
|
||||||
|
inviteHash = extractBeforeChar(inviteHash, '?', '/')
|
||||||
|
if inviteHash != "" {
|
||||||
|
result.InviteLink = "t.me/joinchat/" + inviteHash
|
||||||
|
result.Type = "invite"
|
||||||
|
result.Valid = true
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлекаем username из t.me/username или t.me/username/12345
|
||||||
|
parts := strings.SplitN(path, "/", 2)
|
||||||
|
if len(parts) > 0 {
|
||||||
|
username := parts[0]
|
||||||
|
// Убираем @ если есть
|
||||||
|
username = strings.TrimPrefix(username, "@")
|
||||||
|
|
||||||
|
// Валидируем username
|
||||||
|
if channelUsernameRe.MatchString(username) {
|
||||||
|
result.Username = username
|
||||||
|
result.Type = "username"
|
||||||
|
result.Valid = true
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Valid = false
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeInviteLink нормализует invite ссылку
|
||||||
|
func normalizeInviteLink(link string) string {
|
||||||
|
link = strings.TrimSpace(link)
|
||||||
|
|
||||||
|
// Добавляем протокол если нужно
|
||||||
|
if !strings.HasPrefix(link, "http://") && !strings.HasPrefix(link, "https://") && !strings.HasPrefix(link, "t.me/") {
|
||||||
|
if strings.HasPrefix(link, "t.me/") {
|
||||||
|
// Уже в нужном формате
|
||||||
|
return link
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нормализуем t.me/+xxxxx
|
||||||
|
if strings.HasPrefix(link, "t.me/+") {
|
||||||
|
return link
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нормализуем t.me/joinchat/xxxxx
|
||||||
|
if strings.HasPrefix(link, "t.me/joinchat/") {
|
||||||
|
return link
|
||||||
|
}
|
||||||
|
|
||||||
|
return link
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractBeforeChar извлекает часть строки до первого встреченного символа
|
||||||
|
func extractBeforeChar(s string, chars ...rune) string {
|
||||||
|
for _, c := range chars {
|
||||||
|
if idx := strings.IndexRune(s, c); idx != -1 {
|
||||||
|
return s[:idx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuggestFix предлагает исправление для некорректного ввода
|
||||||
|
func SuggestFix(input string) (string, bool) {
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
if input == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Исправляем опечатки в протоколе
|
||||||
|
if strings.HasPrefix(input, "htp://") {
|
||||||
|
fixed := strings.Replace(input, "htp://", "https://", 1)
|
||||||
|
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||||
|
return fixed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(input, "ttps://") {
|
||||||
|
fixed := strings.Replace(input, "ttps://", "https://", 1)
|
||||||
|
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||||
|
return fixed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(input, "ttp://") {
|
||||||
|
fixed := strings.Replace(input, "ttp://", "http://", 1)
|
||||||
|
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||||
|
return fixed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Исправляем tme/ → t.me/
|
||||||
|
if strings.HasPrefix(input, "tme/") {
|
||||||
|
fixed := strings.Replace(input, "tme/", "t.me/", 1)
|
||||||
|
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||||
|
return fixed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(input, "tme/") {
|
||||||
|
fixed := strings.Replace(input, "tme/", "t.me/", 1)
|
||||||
|
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||||
|
return fixed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Исправляем @@ или @@
|
||||||
|
if strings.HasPrefix(input, "@@") {
|
||||||
|
fixed := strings.TrimPrefix(input, "@")
|
||||||
|
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||||
|
return fixed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Пробуем добавить @ в начало username
|
||||||
|
if !strings.HasPrefix(input, "@") && !isURLFormat(input) {
|
||||||
|
fixed := "@" + input
|
||||||
|
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||||
|
return fixed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Пробуем убрать лишние символы в конце
|
||||||
|
cleaned := input
|
||||||
|
cleaned = strings.TrimSuffix(cleaned, ".")
|
||||||
|
cleaned = strings.TrimSuffix(cleaned, ",")
|
||||||
|
cleaned = strings.TrimSuffix(cleaned, ";")
|
||||||
|
cleaned = strings.TrimSpace(cleaned)
|
||||||
|
if cleaned != input {
|
||||||
|
if parsed := ParseChannelInput(cleaned); parsed.Valid {
|
||||||
|
return cleaned, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Пробуем извлечь username из сложной ссылки
|
||||||
|
if strings.Contains(input, "/") {
|
||||||
|
parts := strings.Split(input, "/")
|
||||||
|
for i := len(parts) - 1; i >= 0; i-- {
|
||||||
|
part := strings.TrimSpace(parts[i])
|
||||||
|
part = strings.TrimPrefix(part, "@")
|
||||||
|
if channelUsernameRe.MatchString(part) {
|
||||||
|
return "@" + part, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatChannelLabel форматирует метку канала для отображения
|
||||||
|
func FormatChannelLabel(input ChannelInput) string {
|
||||||
|
if input.Username != "" {
|
||||||
|
return "@" + input.Username
|
||||||
|
}
|
||||||
|
if input.InviteLink != "" {
|
||||||
|
return formatInviteLabel(input.InviteLink)
|
||||||
|
}
|
||||||
|
return input.Input
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDuplicate проверяет, является ли канал дубликатом (case-insensitive для username)
|
||||||
|
func IsDuplicate(input ChannelInput, channels []PurchaseChannelInput) bool {
|
||||||
|
for _, ch := range channels {
|
||||||
|
// Case-insensitive сравнение username (@MyChannel == @mychannel)
|
||||||
|
if input.Username != "" && ch.Username != "" && strings.EqualFold(ch.Username, input.Username) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if input.InviteLink != "" && ch.InviteLink == input.InviteLink {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -54,7 +54,10 @@ func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if placement.Details.CostBeforeBargain != nil {
|
if placement.Details.CostBeforeBargain != nil {
|
||||||
text += fmt.Sprintf("• <b>До торга:</b> %.0f₽\n", *placement.Details.CostBeforeBargain)
|
text += fmt.Sprintf("• <b>До торга:</b> %s %.0f₽\n",
|
||||||
|
formatCostType(placement.Details.CostBeforeBargain.Type),
|
||||||
|
placement.Details.CostBeforeBargain.Value,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if placement.Details.Format != nil && *placement.Details.Format != "" {
|
if placement.Details.Format != nil && *placement.Details.Format != "" {
|
||||||
text += fmt.Sprintf("• <b>Формат:</b> %s\n", *placement.Details.Format)
|
text += fmt.Sprintf("• <b>Формат:</b> %s\n", *placement.Details.Format)
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ func (s *Placements) Exit() {}
|
|||||||
|
|
||||||
// PlacementsList - экран со списком размещений
|
// PlacementsList - экран со списком размещений
|
||||||
const placementsPerPage = 6
|
const placementsPerPage = 6
|
||||||
const placementsPerRow = 2
|
const placementsPerRow = 1
|
||||||
|
|
||||||
type PlacementsList struct {
|
type PlacementsList struct {
|
||||||
ProjectID string
|
ProjectID string
|
||||||
@@ -152,9 +152,9 @@ func (s *PlacementsList) render(b *bot.Bot, mode bot.RenderMode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Текст кнопки: #N • Канал (с пробелом и разделителем)
|
// Текст кнопки: #N • Канал (с пробелом и разделителем)
|
||||||
buttonText := fmt.Sprintf("#%d • %s", globalNum, channelName)
|
buttonText := fmt.Sprintf("%d • %s", globalNum, channelName)
|
||||||
if dateStr != "" {
|
if dateStr != "" {
|
||||||
buttonText += fmt.Sprintf("\n%s", dateStr)
|
buttonText += fmt.Sprintf(" %s", dateStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
buttons = append(buttons, echotron.InlineKeyboardButton{
|
buttons = append(buttons, echotron.InlineKeyboardButton{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@ package screens
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/NicoNex/echotron/v3"
|
"github.com/NicoNex/echotron/v3"
|
||||||
@@ -33,10 +32,16 @@ type SelectChannelsForPurchase struct {
|
|||||||
Channels []PurchaseChannelInput
|
Channels []PurchaseChannelInput
|
||||||
CurrentPage int
|
CurrentPage int
|
||||||
InvalidUsernames []string
|
InvalidUsernames []string
|
||||||
|
Duplicates []string // дубликаты каналов
|
||||||
|
ParsingErrors []ParseError // ошибки парсинга с предложениями
|
||||||
BackState bot.State
|
BackState bot.State
|
||||||
}
|
}
|
||||||
|
|
||||||
var channelUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{4,31}$`)
|
// ParseError представляет ошибку парсинга с предложением исправления
|
||||||
|
type ParseError struct {
|
||||||
|
Input string
|
||||||
|
Suggestion string
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SelectChannelsForPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
|
func (s *SelectChannelsForPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||||
s.renderChannelSelection(b, mode)
|
s.renderChannelSelection(b, mode)
|
||||||
@@ -118,6 +123,30 @@ func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.
|
|||||||
s.InvalidUsernames = nil
|
s.InvalidUsernames = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Показываем дубликаты
|
||||||
|
if len(s.Duplicates) > 0 {
|
||||||
|
text += fmt.Sprintf("\n\n⏭️ Пропущены (дубликаты): %s", strings.Join(s.Duplicates, ", "))
|
||||||
|
s.Duplicates = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем ошибки с предложениями
|
||||||
|
if len(s.ParsingErrors) > 0 {
|
||||||
|
text += "\n\n❌ Ошибки форматирования:\n"
|
||||||
|
for i, err := range s.ParsingErrors {
|
||||||
|
if i < 3 { // Показываем максимум 3 ошибки
|
||||||
|
if err.Suggestion != "" {
|
||||||
|
text += fmt.Sprintf("• %s → возможно: %s\n", err.Input, err.Suggestion)
|
||||||
|
} else {
|
||||||
|
text += fmt.Sprintf("• %s\n", err.Input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(s.ParsingErrors) > 3 {
|
||||||
|
text += fmt.Sprintf("• ... и еще %d\n", len(s.ParsingErrors)-3)
|
||||||
|
}
|
||||||
|
s.ParsingErrors = nil
|
||||||
|
}
|
||||||
|
|
||||||
// Кнопки навигации
|
// Кнопки навигации
|
||||||
if len(s.Channels) > 0 {
|
if len(s.Channels) > 0 {
|
||||||
buttons = append(buttons, Row(
|
buttons = append(buttons, Row(
|
||||||
@@ -164,18 +193,18 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаем/обновляем каналы в БД через API
|
// Проверяем, что все каналы резолвлены (имеют ChannelID)
|
||||||
jwt := b.Session.JWT
|
var unresolved []string
|
||||||
if jwt == "" {
|
for _, ch := range s.Channels {
|
||||||
log.Error().Msg("JWT is empty in session")
|
if ch.ChannelID == "" {
|
||||||
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
|
label := channelLabel(ch)
|
||||||
return
|
unresolved = append(unresolved, label)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
failed := s.resolveChannels(b, jwt)
|
if len(unresolved) > 0 {
|
||||||
if len(failed) > 0 {
|
text := "❌ Некоторые каналы не были найдены:\n\n"
|
||||||
text := "❌ Не удалось добавить каналы:\n\n"
|
for _, label := range unresolved {
|
||||||
for _, label := range failed {
|
|
||||||
text += fmt.Sprintf("• %s\n", label)
|
text += fmt.Sprintf("• %s\n", label)
|
||||||
}
|
}
|
||||||
text += "\nУдалите их из списка и попробуйте снова"
|
text += "\nУдалите их из списка и попробуйте снова"
|
||||||
@@ -186,6 +215,7 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Каналы уже резолвлены в HandleMessage, переходим к следующему шагу
|
||||||
b.SetState(&PurchaseOptionalDetails{
|
b.SetState(&PurchaseOptionalDetails{
|
||||||
ProjectID: s.ProjectID,
|
ProjectID: s.ProjectID,
|
||||||
ProjectTitle: s.ProjectTitle,
|
ProjectTitle: s.ProjectTitle,
|
||||||
@@ -290,12 +320,25 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверяем JWT
|
||||||
|
jwt := b.Session.JWT
|
||||||
|
if jwt == "" {
|
||||||
|
log.Error().Msg("JWT is empty in session")
|
||||||
|
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разбиваем ввод на токены
|
||||||
tokens := strings.FieldsFunc(raw, func(r rune) bool {
|
tokens := strings.FieldsFunc(raw, func(r rune) bool {
|
||||||
return r == ' ' || r == '\n' || r == '\t' || r == ',' || r == ';'
|
return r == ' ' || r == '\n' || r == '\t' || r == ',' || r == ';'
|
||||||
})
|
})
|
||||||
|
|
||||||
added := 0
|
added := 0
|
||||||
var invalid []string
|
s.Duplicates = nil
|
||||||
|
s.InvalidUsernames = nil
|
||||||
|
s.ParsingErrors = nil
|
||||||
|
|
||||||
|
tokensLoop:
|
||||||
for _, token := range tokens {
|
for _, token := range tokens {
|
||||||
entry := strings.TrimSpace(token)
|
entry := strings.TrimSpace(token)
|
||||||
entry = strings.Trim(entry, ",;")
|
entry = strings.Trim(entry, ",;")
|
||||||
@@ -303,53 +346,144 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var username string
|
// Используем новый парсер
|
||||||
var inviteLink string
|
parsed := ParseChannelInput(entry)
|
||||||
if isInviteLink(entry) {
|
|
||||||
inviteLink = entry
|
if !parsed.Valid {
|
||||||
|
// Пробуем предложить исправление
|
||||||
|
if suggestion, ok := SuggestFix(entry); ok {
|
||||||
|
s.ParsingErrors = append(s.ParsingErrors, ParseError{
|
||||||
|
Input: entry,
|
||||||
|
Suggestion: suggestion,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
username = strings.TrimPrefix(entry, "@")
|
s.InvalidUsernames = append(s.InvalidUsernames, entry)
|
||||||
username = strings.Trim(username, "@")
|
}
|
||||||
if !channelUsernameRe.MatchString(username) {
|
|
||||||
invalid = append(invalid, entry)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверяем дубликаты по username/invite link
|
||||||
|
if IsDuplicate(parsed, s.Channels) {
|
||||||
|
label := FormatChannelLabel(parsed)
|
||||||
|
s.Duplicates = append(s.Duplicates, label)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
exists := false
|
// Сразу резолвим канал через бэкенд
|
||||||
|
channel, err := s.resolveSingleChannel(b, jwt, parsed)
|
||||||
|
if err != nil {
|
||||||
|
// Канал не найден или ошибка
|
||||||
|
s.InvalidUsernames = append(s.InvalidUsernames, entry)
|
||||||
|
log.Error().Err(err).Str("input", entry).Msg("Failed to resolve channel")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем дубликаты по ID канала (после резолва)
|
||||||
for _, ch := range s.Channels {
|
for _, ch := range s.Channels {
|
||||||
if inviteLink != "" && ch.InviteLink == inviteLink {
|
if ch.ChannelID != "" && ch.ChannelID == channel.ID {
|
||||||
exists = true
|
var title, username string
|
||||||
break
|
if channel.Title != nil {
|
||||||
|
title = *channel.Title
|
||||||
}
|
}
|
||||||
if username != "" && ch.Username == username {
|
if channel.Username != nil {
|
||||||
exists = true
|
username = *channel.Username
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
label := channelLabelByID(&channel.ID, &title, &username)
|
||||||
if exists {
|
s.Duplicates = append(s.Duplicates, label)
|
||||||
continue
|
continue tokensLoop // <-- Выход из внешнего цикла, а не внутреннего!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Дополнительная проверка по username (case-insensitive)
|
||||||
|
// На случай, если канал еще не резолвлен или ID отличается
|
||||||
|
if channel.Username != nil && ch.Username != "" {
|
||||||
|
if strings.EqualFold(ch.Username, *channel.Username) {
|
||||||
|
var title string
|
||||||
|
if channel.Title != nil {
|
||||||
|
title = *channel.Title
|
||||||
|
}
|
||||||
|
label := channelLabelByID(&channel.ID, &title, channel.Username)
|
||||||
|
s.Duplicates = append(s.Duplicates, label)
|
||||||
|
continue tokensLoop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем канал с полными данными
|
||||||
|
var title string
|
||||||
|
if channel.Title != nil {
|
||||||
|
title = *channel.Title
|
||||||
|
}
|
||||||
s.Channels = append(s.Channels, PurchaseChannelInput{
|
s.Channels = append(s.Channels, PurchaseChannelInput{
|
||||||
Username: username,
|
ChannelID: channel.ID,
|
||||||
InviteLink: inviteLink,
|
Username: parsed.Username,
|
||||||
|
Title: title,
|
||||||
|
InviteLink: parsed.InviteLink,
|
||||||
})
|
})
|
||||||
added++
|
added++
|
||||||
}
|
}
|
||||||
|
|
||||||
if added == 0 {
|
// Если ничего не добавлено и нет ошибок - не обновляем экран
|
||||||
if len(invalid) == 0 {
|
if added == 0 && len(s.InvalidUsernames) == 0 && len(s.Duplicates) == 0 && len(s.ParsingErrors) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
s.InvalidUsernames = invalid
|
|
||||||
|
|
||||||
// Обновляем экран
|
// Обновляем экран
|
||||||
s.Enter(b, bot.NewMessage)
|
s.Enter(b, bot.NewMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveSingleChannel резолвит один канал через бэкенд
|
||||||
|
func (s *SelectChannelsForPurchase) resolveSingleChannel(b *bot.Bot, jwt string, parsed ChannelInput) (*backend.Channel, error) {
|
||||||
|
var input backend.CreateChannelInput
|
||||||
|
if parsed.InviteLink != "" {
|
||||||
|
link := parsed.InviteLink
|
||||||
|
input = backend.CreateChannelInput{InviteLink: &link}
|
||||||
|
} else {
|
||||||
|
username := parsed.Username
|
||||||
|
input = backend.CreateChannelInput{Username: &username}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := b.Backend.CreateChannels(context.Background(), jwt, backend.CreateChannelsInput{
|
||||||
|
Channels: []backend.CreateChannelInput{input},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Results) == 0 {
|
||||||
|
return nil, fmt.Errorf("no response from backend")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := resp.Results[0]
|
||||||
|
if result.Status == "failed" || result.Channel == nil || result.Channel.ID == "" {
|
||||||
|
errMsg := "channel not found"
|
||||||
|
if result.Error != nil {
|
||||||
|
errMsg = *result.Error
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf(errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если пришел username из ответа, обновляем его
|
||||||
|
if result.Channel.Username != nil && *result.Channel.Username != "" {
|
||||||
|
parsed.Username = *result.Channel.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Channel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// channelLabelByID формирует метку канала по ID
|
||||||
|
func channelLabelByID(id, title, username *string) string {
|
||||||
|
if title != nil && *title != "" {
|
||||||
|
return *title
|
||||||
|
}
|
||||||
|
if username != nil && *username != "" {
|
||||||
|
return "@" + *username
|
||||||
|
}
|
||||||
|
if id != nil && *id != "" {
|
||||||
|
return *id
|
||||||
|
}
|
||||||
|
return "канал"
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SelectChannelsForPurchase) resolveChannels(b *bot.Bot, jwt string) []string {
|
func (s *SelectChannelsForPurchase) resolveChannels(b *bot.Bot, jwt string) []string {
|
||||||
inputs := make([]backend.CreateChannelInput, 0, len(s.Channels))
|
inputs := make([]backend.CreateChannelInput, 0, len(s.Channels))
|
||||||
for _, ch := range s.Channels {
|
for _, ch := range s.Channels {
|
||||||
|
|||||||
Reference in New Issue
Block a user