правки

This commit is contained in:
Artem Tsyrulnikov
2026-02-01 17:57:46 +03:00
parent a92f33c22f
commit 4e35c73a66
15 changed files with 1573 additions and 231 deletions

View File

@@ -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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"
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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: ...

View File

@@ -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

View File

@@ -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),
) )

View File

@@ -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:

View File

@@ -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"`

View File

@@ -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)
} }

View 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
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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
} else {
username = strings.TrimPrefix(entry, "@")
username = strings.Trim(username, "@")
if !channelUsernameRe.MatchString(username) {
invalid = append(invalid, entry)
continue
}
}
exists := false if !parsed.Valid {
for _, ch := range s.Channels { // Пробуем предложить исправление
if inviteLink != "" && ch.InviteLink == inviteLink { if suggestion, ok := SuggestFix(entry); ok {
exists = true s.ParsingErrors = append(s.ParsingErrors, ParseError{
break Input: entry,
Suggestion: suggestion,
})
} else {
s.InvalidUsernames = append(s.InvalidUsernames, entry)
} }
if username != "" && ch.Username == username {
exists = true
break
}
}
if exists {
continue continue
} }
// Проверяем дубликаты по username/invite link
if IsDuplicate(parsed, s.Channels) {
label := FormatChannelLabel(parsed)
s.Duplicates = append(s.Duplicates, label)
continue
}
// Сразу резолвим канал через бэкенд
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 {
if ch.ChannelID != "" && ch.ChannelID == channel.ID {
var title, username string
if channel.Title != nil {
title = *channel.Title
}
if channel.Username != nil {
username = *channel.Username
}
label := channelLabelByID(&channel.ID, &title, &username)
s.Duplicates = append(s.Duplicates, label)
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 {