diff --git a/migrations/models/15_20260201174836_add_cost_before_bargain_type.py b/migrations/models/15_20260201174836_add_cost_before_bargain_type.py
new file mode 100644
index 0000000..08f7d9b
--- /dev/null
+++ b/migrations/models/15_20260201174836_add_cost_before_bargain_type.py
@@ -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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"
+)
diff --git a/src/adapter/telegram_bot.py b/src/adapter/telegram_bot.py
index 1c5498a..d54a222 100644
--- a/src/adapter/telegram_bot.py
+++ b/src/adapter/telegram_bot.py
@@ -17,11 +17,21 @@ log = logging.getLogger(__name__)
class TelegramBot(TelegramBase, TelegramBotWriter):
async def send_message(
- self, text: str, chat_id: int, parse_mode: str | None = None, disable_preview: bool = False
- ) -> None:
- await self.bot.send_message(
- chat_id=chat_id, text=text, parse_mode=parse_mode, disable_web_page_preview=disable_preview
+ self,
+ text: str,
+ chat_id: int,
+ 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:
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]],
parse_mode: str | None = None,
disable_preview: bool = False,
- ) -> None:
+ reply_to_message_id: int | None = None,
+ ) -> int:
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
- await self.bot.send_message(
+ message = await self.bot.send_message(
chat_id=chat_id,
text=text,
reply_markup=keyboard,
parse_mode=parse_mode,
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(
self,
@@ -52,41 +65,47 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
media_file_id: str,
buttons: list[list[InlineKeyboardButton]],
parse_mode: str | None = None,
- ) -> None:
+ reply_to_message_id: int | None = None,
+ ) -> int:
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) if buttons else None
if media_type == 'photo':
- await self.bot.send_photo(
+ message = await self.bot.send_photo(
chat_id=chat_id,
photo=media_file_id,
caption=text,
parse_mode=parse_mode,
reply_markup=keyboard,
+ reply_to_message_id=reply_to_message_id,
)
- return
+ return message.message_id
if media_type == 'video':
- await self.bot.send_video(
+ message = await self.bot.send_video(
chat_id=chat_id,
video=media_file_id,
caption=text,
parse_mode=parse_mode,
reply_markup=keyboard,
+ reply_to_message_id=reply_to_message_id,
)
- return
+ return message.message_id
if media_type == 'animation':
- await self.bot.send_animation(
+ message = await self.bot.send_animation(
chat_id=chat_id,
animation=media_file_id,
caption=text,
parse_mode=parse_mode,
reply_markup=keyboard,
+ reply_to_message_id=reply_to_message_id,
)
- return
- await self.bot.send_message(
+ return message.message_id
+ message = await self.bot.send_message(
chat_id=chat_id,
text=text,
parse_mode=parse_mode,
reply_markup=keyboard,
+ reply_to_message_id=reply_to_message_id,
)
+ return message.message_id
async def send_media_group(
self,
@@ -94,9 +113,10 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
media_items: Sequence[Any],
caption: str | None = None,
parse_mode: str | None = None,
- ) -> None:
+ reply_to_message_id: int | None = None,
+ ) -> int:
if not media_items:
- return
+ raise ValueError('No media items to send')
group: list[InputMediaPhoto | InputMediaVideo] = []
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}')
# 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:
await self.bot.edit_message_text(chat_id=chat_id, message_id=message_id, text=text, reply_markup=None)
diff --git a/src/domain/placement.py b/src/domain/placement.py
index 0ec49f4..ffe4363 100644
--- a/src/domain/placement.py
+++ b/src/domain/placement.py
@@ -89,6 +89,7 @@ class Placement(TimestampedModel):
payment_at = fields.DatetimeField(null=True)
cost_type = fields.CharEnumField(CostType, null=True, max_length=8)
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)
placement_type = fields.CharEnumField(PlacementType, null=True, max_length=16)
format = fields.TextField(null=True)
diff --git a/src/dto/purchase.py b/src/dto/purchase.py
index f260816..86f0671 100644
--- a/src/dto/purchase.py
+++ b/src/dto/purchase.py
@@ -18,11 +18,12 @@ class PlacementDetails(pydantic.BaseModel):
placement_at: datetime.datetime | None = None
payment_at: datetime.datetime | None = None
cost: CostInfo | None = None
- cost_before_bargain: float | None = None
+ cost_before_bargain: CostInfo | None = None
placement_type: PlacementType | None = None
format: str | None = None
comment: str | None = None
creative_id: uuid.UUID | None = None
+ invite_link_type: InviteLinkType | None = None
class PlacementOutput(pydantic.BaseModel):
@@ -102,7 +103,7 @@ class UpdatePlacementInput(pydantic.BaseModel):
placement_at: datetime.datetime | None = None
payment_at: datetime.datetime | None = None
cost: CostInfo | None = None
- cost_before_bargain: float | None = None
+ cost_before_bargain: CostInfo | None = None
placement_type: PlacementType | None = None
format: str | None = None
diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py
index eea8fb3..d663458 100644
--- a/src/usecase/__init__.py
+++ b/src/usecase/__init__.py
@@ -72,8 +72,13 @@ class MediaItem(typing.Protocol):
class TelegramBotWriter(typing.Protocol):
async def send_message(
- self, text: str, chat_id: int, parse_mode: str | None = None, disable_preview: bool = False
- ) -> None: ...
+ self,
+ 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(
self,
@@ -82,7 +87,8 @@ class TelegramBotWriter(typing.Protocol):
buttons: list[list['InlineKeyboardButton']],
parse_mode: str | None = None,
disable_preview: bool = False,
- ) -> None: ...
+ reply_to_message_id: int | None = None,
+ ) -> int: ...
async def send_media_with_inline_keyboard(
self,
@@ -92,7 +98,8 @@ class TelegramBotWriter(typing.Protocol):
media_file_id: str,
buttons: list[list['InlineKeyboardButton']],
parse_mode: str | None = None,
- ) -> None: ...
+ reply_to_message_id: int | None = None,
+ ) -> int: ...
async def send_media_group(
self,
@@ -100,7 +107,8 @@ class TelegramBotWriter(typing.Protocol):
media_items: Sequence[MediaItem],
caption: 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: ...
diff --git a/src/usecase/purchase/build_placement_creative.py b/src/usecase/purchase/build_placement_creative.py
index 8d771ac..131fa5d 100644
--- a/src/usecase/purchase/build_placement_creative.py
+++ b/src/usecase/purchase/build_placement_creative.py
@@ -51,6 +51,73 @@ def _format_channel_name(channel: domain.Channel) -> str:
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'{name}'
+
+ # Try username
+ if channel.username:
+ return f'{name}'
+
+ # 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]]:
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)
chat_id = user.telegram_user.telegram_id
- channel_name = _format_channel_name(placement.channel)
- header = f'Канал: {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)
if preview.media_items:
if len(preview.media_items) == 1:
@@ -135,6 +204,7 @@ async def build_placement_creative(
media_file_id=media_item.media_file_id,
buttons=keyboard_buttons,
parse_mode='HTML',
+ reply_to_message_id=info_message_id,
)
else:
await self.telegram_bot.send_media_group(
@@ -142,6 +212,7 @@ async def build_placement_creative(
media_items=preview.media_items,
caption=preview.text,
parse_mode='HTML',
+ reply_to_message_id=info_message_id,
)
elif keyboard_buttons:
await self.telegram_bot.send_message_with_inline_keyboard(
@@ -150,8 +221,11 @@ async def build_placement_creative(
keyboard_buttons,
parse_mode='HTML',
disable_preview=True,
+ reply_to_message_id=info_message_id,
)
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
diff --git a/src/usecase/purchase/create_placements.py b/src/usecase/purchase/create_placements.py
index 706f73b..052cadd 100644
--- a/src/usecase/purchase/create_placements.py
+++ b/src/usecase/purchase/create_placements.py
@@ -21,7 +21,7 @@ def _build_placement_details(placement: domain.Placement) -> dto.PlacementDetail
placement_at=placement.placement_at,
payment_at=placement.payment_at,
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,
format=placement.format,
comment=placement.comment,
@@ -117,8 +117,10 @@ 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=(channel_details.cost_before_bargain if channel_details else None)
- or (details.cost_before_bargain if 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.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,
format=(channel_details.format if channel_details else None) or (details.format if details else None),
)
diff --git a/src/usecase/purchase/update_placement.py b/src/usecase/purchase/update_placement.py
index 77a9953..08d7c05 100644
--- a/src/usecase/purchase/update_placement.py
+++ b/src/usecase/purchase/update_placement.py
@@ -61,7 +61,12 @@ async def update_placement(
placement.cost_type = input.cost.type
placement.cost_value = input.cost.value
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:
placement.placement_type = input.placement_type
if 'format' in fields_set:
diff --git a/tg_bot/backend/backend_client.go b/tg_bot/backend/backend_client.go
index dfae601..6ce249b 100644
--- a/tg_bot/backend/backend_client.go
+++ b/tg_bot/backend/backend_client.go
@@ -581,7 +581,7 @@ type PlacementDetails struct {
PlacementAt *string `json:"placement_at,omitempty"`
PaymentAt *string `json:"payment_at,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"`
Format *string `json:"format,omitempty"`
Comment *string `json:"comment,omitempty"`
diff --git a/tg_bot/screens/add_purchase.go b/tg_bot/screens/add_purchase.go
index f361ed1..d146e59 100644
--- a/tg_bot/screens/add_purchase.go
+++ b/tg_bot/screens/add_purchase.go
@@ -271,6 +271,8 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
CreativeID: s.CreativeID,
CreativeTitle: s.CreativeTitle,
Channels: []PurchaseChannelInput{},
+ Duplicates: []string{},
+ ParsingErrors: []ParseError{},
BackState: s.BackState,
}, bot.EditMessage)
}
diff --git a/tg_bot/screens/channel_parser.go b/tg_bot/screens/channel_parser.go
new file mode 100644
index 0000000..b7ccca3
--- /dev/null
+++ b/tg_bot/screens/channel_parser.go
@@ -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
+}
diff --git a/tg_bot/screens/placement_details.go b/tg_bot/screens/placement_details.go
index 126635f..06fd172 100644
--- a/tg_bot/screens/placement_details.go
+++ b/tg_bot/screens/placement_details.go
@@ -54,7 +54,10 @@ func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode) {
)
}
if placement.Details.CostBeforeBargain != nil {
- text += fmt.Sprintf("• До торга: %.0f₽\n", *placement.Details.CostBeforeBargain)
+ text += fmt.Sprintf("• До торга: %s %.0f₽\n",
+ formatCostType(placement.Details.CostBeforeBargain.Type),
+ placement.Details.CostBeforeBargain.Value,
+ )
}
if placement.Details.Format != nil && *placement.Details.Format != "" {
text += fmt.Sprintf("• Формат: %s\n", *placement.Details.Format)
diff --git a/tg_bot/screens/placements.go b/tg_bot/screens/placements.go
index d014fad..a67c6e2 100644
--- a/tg_bot/screens/placements.go
+++ b/tg_bot/screens/placements.go
@@ -86,7 +86,7 @@ func (s *Placements) Exit() {}
// PlacementsList - экран со списком размещений
const placementsPerPage = 6
-const placementsPerRow = 2
+const placementsPerRow = 1
type PlacementsList struct {
ProjectID string
@@ -152,9 +152,9 @@ func (s *PlacementsList) render(b *bot.Bot, mode bot.RenderMode) {
}
// Текст кнопки: #N • Канал (с пробелом и разделителем)
- buttonText := fmt.Sprintf("#%d • %s", globalNum, channelName)
+ buttonText := fmt.Sprintf("%d • %s", globalNum, channelName)
if dateStr != "" {
- buttonText += fmt.Sprintf("\n%s", dateStr)
+ buttonText += fmt.Sprintf(" %s", dateStr)
}
buttons = append(buttons, echotron.InlineKeyboardButton{
diff --git a/tg_bot/screens/purchase_optional_details.go b/tg_bot/screens/purchase_optional_details.go
index f1bbb1f..f432a22 100644
--- a/tg_bot/screens/purchase_optional_details.go
+++ b/tg_bot/screens/purchase_optional_details.go
@@ -15,38 +15,48 @@ import (
)
type PurchaseOptionalDetails struct {
- ProjectID string
- ProjectTitle string
- CreativeID string
- CreativeTitle string
- Channels []PurchaseChannelInput
- PlacementDateTime *time.Time
- PaymentDate *time.Time
- CostType string
- CostValue *float64
- CostBeforeBargain *float64
- PurchaseType string
- Format string
- Comment string
- InputMode string
- CurrentParam string
- CurrentChannel string
- ParamPage int
- ReturnMode string
- PlacementMode string
- CostMode string
- CostBeforeMode string
- FormatMode string
- PlacementByChannel map[string]*time.Time
- CostByChannel map[string]CostEntry
- CostBeforeByChannel map[string]*float64
- FormatByChannel map[string]string
- PlacementCopy *time.Time
- CostCopy *CostEntry
- CostBeforeCopy *float64
- FormatCopy *string
- ChannelEditMode string // "edit" или "copy"
- BackState bot.State
+ ProjectID string
+ ProjectTitle string
+ CreativeID string
+ CreativeTitle string
+ Channels []PurchaseChannelInput
+ PlacementDateTime *time.Time
+ PaymentDate *time.Time
+ CostType string
+ CostValue *float64
+ CostBeforeType string
+ CostBeforeBargain *CostEntry
+ PurchaseType string
+ Format string
+ Comment string
+ InputMode string
+ CurrentParam string
+ CurrentChannel string
+ ParamPage int
+ ReturnMode string
+ PlacementMode string
+ PaymentDateMode string
+ CostMode string
+ CostBeforeMode string
+ PurchaseTypeMode string
+ CommentMode string
+ FormatMode string
+ PlacementByChannel map[string]*time.Time
+ PaymentDateByChannel map[string]*time.Time
+ CostByChannel map[string]CostEntry
+ CostBeforeByChannel map[string]CostEntry
+ PurchaseTypeByChannel map[string]string
+ CommentByChannel map[string]string
+ FormatByChannel map[string]string
+ PlacementCopy *time.Time
+ PaymentDateCopy *time.Time
+ CostCopy *CostEntry
+ CostBeforeCopy *CostEntry
+ PurchaseTypeCopy *string
+ CommentCopy *string
+ FormatCopy *string
+ ChannelEditMode string // "edit" или "copy"
+ BackState bot.State
}
type CostEntry struct {
@@ -74,27 +84,28 @@ func (s *PurchaseOptionalDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
text += s.formatOptionalSummary()
var rows [][]echotron.InlineKeyboardButton
- rows = append(rows, Row(
- s.placementButton(),
- s.costButton(),
- ))
- rows = append(rows, Row(
- s.paymentButton(),
- s.typeButton(),
- ))
- rows = append(rows, Row(
- s.costBeforeButton(),
- s.formatButton(),
- ))
- rows = append(rows, Row(
- s.commentButton(),
- ))
- // Кнопка переключения режима (только если несколько каналов)
+ // Кнопка переключения режима (только если несколько каналов) - на первой строке
if len(s.Channels) > 1 {
rows = append(rows, Row(s.globalModeButton()))
}
+ rows = append(rows, Row(
+ s.placementButton(),
+ s.paymentButton(),
+ ))
+ rows = append(rows, Row(
+ s.typeButton(),
+ s.commentButton(),
+ ))
+ rows = append(rows, Row(
+ s.costButton(),
+ s.costBeforeButton(),
+ ))
+ rows = append(rows, Row(
+ s.formatButton(),
+ ))
+
if row := s.costBeforeDeleteRow(); row != nil {
rows = append(rows, row)
}
@@ -126,13 +137,20 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update)
}
case "opt_payment_date":
- b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{
- Title: "Дата оплаты",
- Key: "payment_date",
- IncludeTime: true,
- Selected: s.PaymentDate,
- BackState: s,
- }), bot.EditMessage)
+ // Открываем редактор в текущем режиме
+ if s.PaymentDateMode == "per_channel" && len(s.Channels) > 1 {
+ s.InputMode = "payment_date_channels"
+ s.Enter(b, bot.EditMessage)
+ } else {
+ s.setParamMode("payment_date", "common")
+ b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{
+ Title: "Дата оплаты",
+ Key: "payment_date",
+ IncludeTime: false,
+ Selected: s.PaymentDate,
+ BackState: s,
+ }), bot.EditMessage)
+ }
case "opt_cost":
// Открываем редактор в текущем режиме
@@ -145,8 +163,14 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update)
}
case "opt_type":
- s.InputMode = "type"
- s.Enter(b, bot.EditMessage)
+ // Открываем редактор в текущем режиме
+ if s.PurchaseTypeMode == "per_channel" && len(s.Channels) > 1 {
+ s.InputMode = "purchase_type_channels"
+ s.Enter(b, bot.EditMessage)
+ } else {
+ s.InputMode = "type"
+ s.Enter(b, bot.EditMessage)
+ }
case "opt_format":
// Открываем редактор в текущем режиме
@@ -159,8 +183,14 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update)
}
case "opt_comment":
- s.InputMode = "comment"
- s.Enter(b, bot.EditMessage)
+ // Открываем редактор в текущем режиме
+ if s.CommentMode == "per_channel" && len(s.Channels) > 1 {
+ s.InputMode = "comment_channels"
+ s.Enter(b, bot.EditMessage)
+ } else {
+ s.InputMode = "comment"
+ s.Enter(b, bot.EditMessage)
+ }
case "delete_comment":
s.Comment = ""
@@ -174,6 +204,10 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update)
s.toggleCostType()
s.Enter(b, bot.EditMessage)
+ case "cost_before_type_toggle":
+ s.toggleCostBeforeType()
+ s.Enter(b, bot.EditMessage)
+
case "cost_value":
s.InputMode = "cost_value"
s.Enter(b, bot.EditMessage)
@@ -215,6 +249,8 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update)
CreativeID: s.CreativeID,
CreativeTitle: s.CreativeTitle,
Channels: s.Channels,
+ Duplicates: []string{},
+ ParsingErrors: []ParseError{},
BackState: s.BackState,
}, bot.EditMessage)
case "next":
@@ -256,6 +292,8 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update)
currentMode := s.paramMode(param)
if currentMode == "common" {
s.setParamMode(param, "per_channel")
+ // Копируем значение из общего во все каналы
+ s.copyCommonValueToChannels(param)
} else {
s.setParamMode(param, "common")
}
@@ -311,11 +349,19 @@ func (s *PurchaseOptionalDetails) HandleMessage(b *bot.Bot, u *echotron.Update)
switch s.InputMode {
case "type_custom":
- s.PurchaseType = text
+ if s.CurrentChannel != "" {
+ s.PurchaseTypeByChannel[s.CurrentChannel] = text
+ } else {
+ s.PurchaseType = text
+ }
case "format_custom":
s.setFormatValue(text)
case "comment":
- s.Comment = text
+ if s.CurrentChannel != "" {
+ s.CommentByChannel[s.CurrentChannel] = text
+ } else {
+ s.Comment = text
+ }
case "cost_value":
value, err := strconv.ParseFloat(strings.ReplaceAll(text, ",", "."), 64)
if err != nil {
@@ -358,20 +404,44 @@ func (s *PurchaseOptionalDetails) SetDateTimeSelection(key string, value time.Ti
s.ensureDefaults()
s.PlacementByChannel[username] = &value
}
+ if strings.HasPrefix(key, "payment_date:") {
+ username := strings.TrimPrefix(key, "payment_date:")
+ s.ensureDefaults()
+ s.PaymentDateByChannel[username] = &value
+ }
}
}
func (s *PurchaseOptionalDetails) formatOptionalSummary() string {
- lines := []string{
- fmt.Sprintf("Дата размещения: %s%s", s.formatPlacementSummary(), s.formatPlacementDetails()),
- fmt.Sprintf("Дата оплаты: %s", s.formatDateTime(s.PaymentDate)),
- fmt.Sprintf("Стоимость: %s%s", s.formatCostSummary(), s.formatCostDetails()),
- fmt.Sprintf("Стоимость до торга: %s%s", s.formatCostBeforeSummary(), s.formatCostBeforeDetails()),
- fmt.Sprintf("Тип закупа: %s", s.formatText(s.PurchaseType)),
- fmt.Sprintf("Формат: %s%s", s.formatFormatSummary(), s.formatFormatDetails()),
- fmt.Sprintf("Комментарий: %s", s.formatText(s.Comment)),
+ var lines []string
+
+ if s.hasPlacementValue() {
+ lines = append(lines, fmt.Sprintf("Дата размещения: %s%s", s.formatPlacementSummary(), s.formatPlacementDetails()))
}
- return strings.Join(lines, "\n")
+ if s.hasPaymentDateValue() {
+ lines = append(lines, fmt.Sprintf("Дата оплаты: %s%s", s.formatPaymentDateSummary(), s.formatPaymentDateDetails()))
+ }
+ if s.hasCostValue() {
+ lines = append(lines, fmt.Sprintf("Стоимость: %s%s", s.formatCostSummary(), s.formatCostDetails()))
+ }
+ if s.hasCostBeforeValue() {
+ lines = append(lines, fmt.Sprintf("Стоимость до торга: %s%s", s.formatCostBeforeSummary(), s.formatCostBeforeDetails()))
+ }
+ if s.hasPurchaseTypeValue() {
+ lines = append(lines, fmt.Sprintf("Тип закупа: %s%s", s.formatPurchaseTypeSummary(), s.formatPurchaseTypeDetails()))
+ }
+ if s.hasFormatValue() {
+ lines = append(lines, fmt.Sprintf("Формат: %s%s", s.formatFormatSummary(), s.formatFormatDetails()))
+ }
+ if s.hasCommentValue() {
+ lines = append(lines, fmt.Sprintf("Комментарий: %s%s", s.formatCommentSummary(), s.formatCommentDetails()))
+ }
+
+ if len(lines) == 0 {
+ return "Добавьте параметры ниже"
+ }
+
+ return strings.Join(lines, "\n\n")
}
func (s *PurchaseOptionalDetails) formatDateTime(value *time.Time) string {
@@ -379,28 +449,41 @@ func (s *PurchaseOptionalDetails) formatDateTime(value *time.Time) string {
return "—"
}
local := value.In(ui2.MskLocation)
- return fmt.Sprintf("%s %02d %s %s", ui2.WeekdayName(local.Weekday()), local.Day(), ui2.MonthShort(local.Month()), local.Format("15:04"))
+ text := fmt.Sprintf("%s %02d %s %s", ui2.WeekdayName(local.Weekday()), local.Day(), ui2.MonthShort(local.Month()), local.Format("15:04"))
+ return fmt.Sprintf("%s", text)
+}
+
+func (s *PurchaseOptionalDetails) formatDate(value *time.Time) string {
+ if value == nil {
+ return "—"
+ }
+ local := value.In(ui2.MskLocation)
+ text := fmt.Sprintf("%s %02d %s", ui2.WeekdayName(local.Weekday()), local.Day(), ui2.MonthShort(local.Month()))
+ return fmt.Sprintf("%s", text)
}
func (s *PurchaseOptionalDetails) formatText(value string) string {
if value == "" {
return "—"
}
- return value
+ return fmt.Sprintf("%s", value)
}
func (s *PurchaseOptionalDetails) formatCostValue() string {
if s.CostValue == nil {
return "—"
}
- return fmt.Sprintf("%s %.0f₽", s.costTypeLabel(), *s.CostValue)
+ text := fmt.Sprintf("%s %.0f₽", s.costTypeLabel(), *s.CostValue)
+ return fmt.Sprintf("%s", text)
}
func (s *PurchaseOptionalDetails) formatCostBefore() string {
- if s.CostBeforeBargain == nil {
+ if s.CostBeforeBargain == nil || s.CostBeforeBargain.Value == nil {
return "—"
}
- return fmt.Sprintf("%.0f₽", *s.CostBeforeBargain)
+ label := s.costBeforeTypeLabelForEntry(*s.CostBeforeBargain)
+ text := fmt.Sprintf("%s %.0f₽", label, *s.CostBeforeBargain.Value)
+ return fmt.Sprintf("%s", text)
}
func (s *PurchaseOptionalDetails) costTypeLabel() string {
@@ -410,6 +493,13 @@ func (s *PurchaseOptionalDetails) costTypeLabel() string {
return "Фикс"
}
+func (s *PurchaseOptionalDetails) costBeforeTypeLabel() string {
+ if s.CostBeforeType == "cpm" {
+ return "СРМ"
+ }
+ return "Фикс"
+}
+
func (s *PurchaseOptionalDetails) placementButton() echotron.InlineKeyboardButton {
icon := "+"
if s.hasPlacementValue() {
@@ -423,10 +513,15 @@ func (s *PurchaseOptionalDetails) placementButton() echotron.InlineKeyboardButto
}
func (s *PurchaseOptionalDetails) paymentButton() echotron.InlineKeyboardButton {
- if s.PaymentDate == nil {
- return Button("+ Дата оплаты", "opt_payment_date")
+ icon := "+"
+ if s.hasPaymentDateValue() {
+ icon = "✎"
}
- return Button("✎ Дата оплаты", "opt_payment_date")
+ modeIcon := ""
+ if len(s.Channels) > 1 && s.PaymentDateMode == "per_channel" {
+ modeIcon = " 👥"
+ }
+ return Button(fmt.Sprintf("%s Дата оплаты%s", icon, modeIcon), "opt_payment_date")
}
func (s *PurchaseOptionalDetails) costButton() echotron.InlineKeyboardButton {
@@ -434,11 +529,7 @@ func (s *PurchaseOptionalDetails) costButton() echotron.InlineKeyboardButton {
if s.hasCostValue() {
icon = "✎"
}
- modeIcon := ""
- if len(s.Channels) > 1 && s.CostMode == "per_channel" {
- modeIcon = " 👥"
- }
- return Button(fmt.Sprintf("%s Стоимость%s", icon, modeIcon), "opt_cost")
+ return Button(fmt.Sprintf("%s Стоимость", icon), "opt_cost")
}
func (s *PurchaseOptionalDetails) costBeforeButton() echotron.InlineKeyboardButton {
@@ -446,18 +537,19 @@ func (s *PurchaseOptionalDetails) costBeforeButton() echotron.InlineKeyboardButt
if s.hasCostBeforeValue() {
icon = "✎"
}
- modeIcon := ""
- if len(s.Channels) > 1 && s.CostBeforeMode == "per_channel" {
- modeIcon = " 👥"
- }
- return Button(fmt.Sprintf("%s До торга%s", icon, modeIcon), "cost_before")
+ return Button(fmt.Sprintf("%s До торга", icon), "cost_before")
}
func (s *PurchaseOptionalDetails) typeButton() echotron.InlineKeyboardButton {
- if s.PurchaseType == "" {
- return Button("+ Тип", "opt_type")
+ icon := "+"
+ if s.PurchaseType != "" {
+ icon = "✎"
}
- return Button("✎ Тип", "opt_type")
+ modeIcon := ""
+ if len(s.Channels) > 1 && s.PurchaseTypeMode == "per_channel" {
+ modeIcon = " 👥"
+ }
+ return Button(fmt.Sprintf("%s Тип%s", icon, modeIcon), "opt_type")
}
func (s *PurchaseOptionalDetails) formatButton() echotron.InlineKeyboardButton {
@@ -473,14 +565,19 @@ func (s *PurchaseOptionalDetails) formatButton() echotron.InlineKeyboardButton {
}
func (s *PurchaseOptionalDetails) commentButton() echotron.InlineKeyboardButton {
- if s.Comment == "" {
- return Button("+ Комментарий", "opt_comment")
+ icon := "+"
+ if s.Comment != "" {
+ icon = "✎"
}
- return Button("✎ Комментарий", "opt_comment")
+ modeIcon := ""
+ if len(s.Channels) > 1 && s.CommentMode == "per_channel" {
+ modeIcon = " 👥"
+ }
+ return Button(fmt.Sprintf("%s Комментарий%s", icon, modeIcon), "opt_comment")
}
func (s *PurchaseOptionalDetails) globalModeButton() echotron.InlineKeyboardButton {
- return Button("⚙ Общие / По каналам", "toggle_global_mode")
+ return Button("⚙️ Режимы параметров", "toggle_global_mode")
}
func (s *PurchaseOptionalDetails) commentDeleteRow() []echotron.InlineKeyboardButton {
@@ -541,6 +638,19 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM
if s.CurrentChannel != "" {
text += fmt.Sprintf("\n\nКанал: %s", channelLabelByKey(s.Channels, s.CurrentChannel))
}
+
+ typeLabel := s.costBeforeTypeLabelForCurrent()
+ keyboard := Keyboard(
+ Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_before_type_toggle")),
+ Row(Button("← Назад", s.backAction())),
+ )
+
+ if mode == bot.EditMessage {
+ b.Edit(text, keyboard)
+ } else {
+ b.SendNew(text, keyboard)
+ }
+ return
case "format_select":
text = "Страница ввода формата размещения\n\n"
if s.CurrentChannel != "" {
@@ -609,34 +719,43 @@ func (s *PurchaseOptionalDetails) renderModeSelect(b *bot.Bot, mode bot.RenderMo
// Дата размещения
placementLabel := "Дата размещения: "
if s.PlacementMode == "per_channel" {
- placementLabel += "для каждого"
+ placementLabel += "👥"
} else {
placementLabel += "общий"
}
rows = append(rows, Row(Button(placementLabel, "toggle_param_mode:placement")))
- // Стоимость
- costLabel := "Стоимость: "
- if s.CostMode == "per_channel" {
- costLabel += "для каждого"
+ // Дата оплаты
+ paymentDateLabel := "Дата оплаты: "
+ if s.PaymentDateMode == "per_channel" {
+ paymentDateLabel += "👥"
} else {
- costLabel += "общий"
+ paymentDateLabel += "общий"
}
- rows = append(rows, Row(Button(costLabel, "toggle_param_mode:cost")))
+ rows = append(rows, Row(Button(paymentDateLabel, "toggle_param_mode:payment_date")))
- // До торга
- costBeforeLabel := "До торга: "
- if s.CostBeforeMode == "per_channel" {
- costBeforeLabel += "для каждого"
+ // Тип закупа
+ purchaseTypeLabel := "Тип закупа: "
+ if s.PurchaseTypeMode == "per_channel" {
+ purchaseTypeLabel += "👥"
} else {
- costBeforeLabel += "общий"
+ purchaseTypeLabel += "общий"
}
- rows = append(rows, Row(Button(costBeforeLabel, "toggle_param_mode:cost_before")))
+ rows = append(rows, Row(Button(purchaseTypeLabel, "toggle_param_mode:purchase_type")))
+
+ // Комментарий
+ commentLabel := "Комментарий: "
+ if s.CommentMode == "per_channel" {
+ commentLabel += "👥"
+ } else {
+ commentLabel += "общий"
+ }
+ rows = append(rows, Row(Button(commentLabel, "toggle_param_mode:comment")))
// Формат
formatLabel := "Формат: "
if s.FormatMode == "per_channel" {
- formatLabel += "для каждого"
+ formatLabel += "👥"
} else {
formatLabel += "общий"
}
@@ -652,12 +771,21 @@ func (s *PurchaseOptionalDetails) renderParamScreens(b *bot.Bot, mode bot.Render
case "placement_channels":
s.renderParamChannels(b, mode, "placement")
return true
+ case "payment_date_channels":
+ s.renderParamChannels(b, mode, "payment_date")
+ return true
case "cost_channels":
s.renderParamChannels(b, mode, "cost")
return true
case "cost_before_channels":
s.renderParamChannels(b, mode, "cost_before")
return true
+ case "purchase_type_channels":
+ s.renderParamChannels(b, mode, "purchase_type")
+ return true
+ case "comment_channels":
+ s.renderParamChannels(b, mode, "comment")
+ return true
case "format_channels":
s.renderParamChannels(b, mode, "format")
return true
@@ -770,13 +898,68 @@ func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.Rende
}
func (s *PurchaseOptionalDetails) renderParamChannelSummary(param string) string {
- var lines []string
- for i, ch := range s.Channels {
+ // Собираем строки и вычисляем максимальную длину названия канала
+ type channelLine struct {
+ label string
+ value string
+ }
+ var lines []channelLine
+ maxLabelLen := 0
+
+ for _, ch := range s.Channels {
+ label := channelLabel(ch)
+ labelLen := len([]rune(label)) // Считаем руны для Unicode
+ if labelLen > maxLabelLen {
+ maxLabelLen = labelLen
+ }
channelKey := channelKey(ch)
value := s.formatParamValue(param, channelKey)
- lines = append(lines, fmt.Sprintf("%d. %s — %s", i+1, channelLabel(ch), value))
+ lines = append(lines, channelLine{label: label, value: value})
}
- return strings.Join(lines, "\n")
+
+ // Логирование для отладки (INFO уровень)
+ log.Info().
+ Str("param", param).
+ Int("maxLabelLen", maxLabelLen).
+ Int("channelsCount", len(lines)).
+ Msg("🔍 renderParamChannelSummary")
+
+ // Форматируем: маркер + название + паддинг + значение
+ // Паддинг вычисляем так, чтобы все значения начинались с одной позиции
+ const (
+ marker = "· " // Маркер пункта
+ valueIndent = 2 // Отступ после самого длинного названия
+ )
+
+ var result []string
+ for i, line := range lines {
+ labelLen := len([]rune(line.label))
+ // Паддинг = (макс.длина - тек.длина) + отступ после названия
+ paddingLen := maxLabelLen - labelLen + valueIndent
+ padding := strings.Repeat(" ", paddingLen)
+
+ formattedLine := fmt.Sprintf("%s%s%s%s", marker, line.label, padding, line.value)
+ result = append(result, formattedLine)
+
+ // Логируем каждую строку (INFO уровень)
+ log.Info().
+ Int("index", i).
+ Str("label", line.label).
+ Int("labelLen", labelLen).
+ Int("paddingLen", paddingLen).
+ Str("formattedLine", formattedLine).
+ Msg("📝 Channel line")
+ }
+
+ finalResult := strings.Join(result, "\n")
+
+ // Логируем итоговый результат
+ log.Info().
+ Str("param", param).
+ Str("result", finalResult).
+ Msg("✅ Final summary")
+
+ return finalResult
}
func (s *PurchaseOptionalDetails) handleParamCallback(b *bot.Bot, data string) bool {
@@ -882,6 +1065,26 @@ func (s *PurchaseOptionalDetails) openCommonEditor(b *bot.Bot, param string) {
Selected: s.PlacementDateTime,
BackState: s,
}), bot.EditMessage)
+ case "payment_date":
+ s.InputMode = ""
+ s.ReturnMode = ""
+ b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{
+ Title: "Дата оплаты",
+ Key: "payment_date",
+ IncludeTime: false,
+ Selected: s.PaymentDate,
+ BackState: s,
+ }), bot.EditMessage)
+ case "purchase_type":
+ s.InputMode = "type"
+ s.ReturnMode = ""
+ s.CurrentChannel = ""
+ s.Enter(b, bot.EditMessage)
+ case "comment":
+ s.InputMode = "comment"
+ s.ReturnMode = ""
+ s.CurrentChannel = ""
+ s.Enter(b, bot.EditMessage)
case "cost":
s.InputMode = "cost_value"
s.ReturnMode = ""
@@ -914,6 +1117,25 @@ func (s *PurchaseOptionalDetails) openChannelEditor(b *bot.Bot, param, channelKe
Selected: s.PlacementByChannel[channelKey],
BackState: s,
}), bot.EditMessage)
+ case "payment_date":
+ s.InputMode = "payment_date_channels"
+ b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{
+ Title: "Дата оплаты",
+ Key: "payment_date:" + channelKey,
+ IncludeTime: false,
+ Selected: s.PaymentDateByChannel[channelKey],
+ BackState: s,
+ }), bot.EditMessage)
+ case "purchase_type":
+ s.InputMode = "type"
+ s.ReturnMode = "purchase_type_channels"
+ s.CurrentChannel = channelKey
+ s.Enter(b, bot.EditMessage)
+ case "comment":
+ s.InputMode = "comment"
+ s.ReturnMode = "comment_channels"
+ s.CurrentChannel = channelKey
+ s.Enter(b, bot.EditMessage)
case "cost":
s.InputMode = "cost_value"
s.ReturnMode = "cost_channels"
@@ -939,6 +1161,8 @@ func (s *PurchaseOptionalDetails) copyParamValue(param, channelKey string) {
switch param {
case "placement":
s.PlacementCopy = s.PlacementByChannel[channelKey]
+ case "payment_date":
+ s.PaymentDateCopy = s.PaymentDateByChannel[channelKey]
case "cost":
entry := s.CostByChannel[channelKey]
copied := entry
@@ -950,7 +1174,29 @@ func (s *PurchaseOptionalDetails) copyParamValue(param, channelKey string) {
}
s.CostCopy = &copied
case "cost_before":
- s.CostBeforeCopy = s.CostBeforeByChannel[channelKey]
+ entry := s.CostBeforeByChannel[channelKey]
+ copied := entry
+ if entry.Value == nil {
+ copied.Value = nil
+ } else {
+ value := *entry.Value
+ copied.Value = &value
+ }
+ s.CostBeforeCopy = &copied
+ case "purchase_type":
+ if value, ok := s.PurchaseTypeByChannel[channelKey]; ok {
+ copied := value
+ s.PurchaseTypeCopy = &copied
+ } else {
+ s.PurchaseTypeCopy = nil
+ }
+ case "comment":
+ if value, ok := s.CommentByChannel[channelKey]; ok {
+ copied := value
+ s.CommentCopy = &copied
+ } else {
+ s.CommentCopy = nil
+ }
case "format":
if value, ok := s.FormatByChannel[channelKey]; ok {
copied := value
@@ -961,6 +1207,77 @@ func (s *PurchaseOptionalDetails) copyParamValue(param, channelKey string) {
}
}
+func (s *PurchaseOptionalDetails) copyCommonValueToChannels(param string) {
+ switch param {
+ case "placement":
+ if s.PlacementDateTime == nil {
+ return
+ }
+ for _, ch := range s.Channels {
+ key := channelKey(ch)
+ value := s.PlacementDateTime
+ s.PlacementByChannel[key] = value
+ }
+ case "payment_date":
+ if s.PaymentDate == nil {
+ return
+ }
+ for _, ch := range s.Channels {
+ key := channelKey(ch)
+ value := s.PaymentDate
+ s.PaymentDateByChannel[key] = value
+ }
+ case "cost":
+ if s.CostValue == nil {
+ return
+ }
+ for _, ch := range s.Channels {
+ key := channelKey(ch)
+ entry := CostEntry{
+ Type: s.CostType,
+ Value: s.CostValue,
+ }
+ s.CostByChannel[key] = entry
+ }
+ case "cost_before":
+ if s.CostBeforeBargain == nil || s.CostBeforeBargain.Value == nil {
+ return
+ }
+ for _, ch := range s.Channels {
+ key := channelKey(ch)
+ entry := CostEntry{
+ Type: s.CostBeforeBargain.Type,
+ Value: s.CostBeforeBargain.Value,
+ }
+ s.CostBeforeByChannel[key] = entry
+ }
+ case "purchase_type":
+ if s.PurchaseType == "" {
+ return
+ }
+ for _, ch := range s.Channels {
+ key := channelKey(ch)
+ s.PurchaseTypeByChannel[key] = s.PurchaseType
+ }
+ case "comment":
+ if s.Comment == "" {
+ return
+ }
+ for _, ch := range s.Channels {
+ key := channelKey(ch)
+ s.CommentByChannel[key] = s.Comment
+ }
+ case "format":
+ if s.Format == "" {
+ return
+ }
+ for _, ch := range s.Channels {
+ key := channelKey(ch)
+ s.FormatByChannel[key] = s.Format
+ }
+ }
+}
+
func (s *PurchaseOptionalDetails) pasteParamValue(param, channelKey string) {
switch param {
case "placement":
@@ -970,6 +1287,13 @@ func (s *PurchaseOptionalDetails) pasteParamValue(param, channelKey string) {
}
value := s.PlacementCopy.In(ui2.MskLocation)
s.PlacementByChannel[channelKey] = &value
+ case "payment_date":
+ if s.PaymentDateCopy == nil {
+ s.PaymentDateByChannel[channelKey] = nil
+ return
+ }
+ value := s.PaymentDateCopy.In(ui2.MskLocation)
+ s.PaymentDateByChannel[channelKey] = &value
case "cost":
if s.CostCopy == nil {
delete(s.CostByChannel, channelKey)
@@ -983,11 +1307,27 @@ func (s *PurchaseOptionalDetails) pasteParamValue(param, channelKey string) {
s.CostByChannel[channelKey] = copied
case "cost_before":
if s.CostBeforeCopy == nil {
- s.CostBeforeByChannel[channelKey] = nil
+ delete(s.CostBeforeByChannel, channelKey)
return
}
- value := *s.CostBeforeCopy
- s.CostBeforeByChannel[channelKey] = &value
+ copied := *s.CostBeforeCopy
+ if copied.Value != nil {
+ value := *copied.Value
+ copied.Value = &value
+ }
+ s.CostBeforeByChannel[channelKey] = copied
+ case "purchase_type":
+ if s.PurchaseTypeCopy == nil {
+ delete(s.PurchaseTypeByChannel, channelKey)
+ return
+ }
+ s.PurchaseTypeByChannel[channelKey] = *s.PurchaseTypeCopy
+ case "comment":
+ if s.CommentCopy == nil {
+ delete(s.CommentByChannel, channelKey)
+ return
+ }
+ s.CommentByChannel[channelKey] = *s.CommentCopy
case "format":
if s.FormatCopy == nil {
delete(s.FormatByChannel, channelKey)
@@ -1003,6 +1343,10 @@ func (s *PurchaseOptionalDetails) applyParamToAll(param string) {
for _, ch := range s.Channels {
s.pasteParamValue(param, channelKey(ch))
}
+ case "payment_date":
+ for _, ch := range s.Channels {
+ s.pasteParamValue(param, channelKey(ch))
+ }
case "cost":
for _, ch := range s.Channels {
s.pasteParamValue(param, channelKey(ch))
@@ -1011,6 +1355,14 @@ func (s *PurchaseOptionalDetails) applyParamToAll(param string) {
for _, ch := range s.Channels {
s.pasteParamValue(param, channelKey(ch))
}
+ case "purchase_type":
+ for _, ch := range s.Channels {
+ s.pasteParamValue(param, channelKey(ch))
+ }
+ case "comment":
+ for _, ch := range s.Channels {
+ s.pasteParamValue(param, channelKey(ch))
+ }
case "format":
for _, ch := range s.Channels {
s.pasteParamValue(param, channelKey(ch))
@@ -1022,10 +1374,16 @@ func (s *PurchaseOptionalDetails) clearParamValue(param, channelKey string) {
switch param {
case "placement":
s.PlacementByChannel[channelKey] = nil
+ case "payment_date":
+ s.PaymentDateByChannel[channelKey] = nil
case "cost":
delete(s.CostByChannel, channelKey)
case "cost_before":
- s.CostBeforeByChannel[channelKey] = nil
+ delete(s.CostBeforeByChannel, channelKey)
+ case "purchase_type":
+ delete(s.PurchaseTypeByChannel, channelKey)
+ case "comment":
+ delete(s.CommentByChannel, channelKey)
case "format":
delete(s.FormatByChannel, channelKey)
}
@@ -1035,10 +1393,16 @@ func (s *PurchaseOptionalDetails) paramTitle(param string) string {
switch param {
case "placement":
return "Дата и время размещения"
+ case "payment_date":
+ return "Дата оплаты"
case "cost":
return "Стоимость"
case "cost_before":
return "Стоимость до торга"
+ case "purchase_type":
+ return "Тип закупа"
+ case "comment":
+ return "Комментарий"
case "format":
return "Формат"
default:
@@ -1053,16 +1417,31 @@ func (s *PurchaseOptionalDetails) paramMode(param string) string {
return "common"
}
return s.PlacementMode
+ case "payment_date":
+ if s.PaymentDateMode == "" {
+ return "common"
+ }
+ return s.PaymentDateMode
case "cost":
if s.CostMode == "" {
- return "common"
+ return "per_channel"
}
return s.CostMode
case "cost_before":
if s.CostBeforeMode == "" {
- return "common"
+ return "per_channel"
}
return s.CostBeforeMode
+ case "purchase_type":
+ if s.PurchaseTypeMode == "" {
+ return "common"
+ }
+ return s.PurchaseTypeMode
+ case "comment":
+ if s.CommentMode == "" {
+ return "common"
+ }
+ return s.CommentMode
case "format":
if s.FormatMode == "" {
return "common"
@@ -1077,10 +1456,16 @@ func (s *PurchaseOptionalDetails) setParamMode(param, mode string) {
switch param {
case "placement":
s.PlacementMode = mode
+ case "payment_date":
+ s.PaymentDateMode = mode
case "cost":
s.CostMode = mode
case "cost_before":
s.CostBeforeMode = mode
+ case "purchase_type":
+ s.PurchaseTypeMode = mode
+ case "comment":
+ s.CommentMode = mode
case "format":
s.FormatMode = mode
}
@@ -1090,11 +1475,20 @@ func (s *PurchaseOptionalDetails) ensureDefaults() {
if s.PlacementMode == "" {
s.PlacementMode = "common"
}
+ if s.PaymentDateMode == "" {
+ s.PaymentDateMode = "common"
+ }
if s.CostMode == "" {
- s.CostMode = "common"
+ s.CostMode = "per_channel"
}
if s.CostBeforeMode == "" {
- s.CostBeforeMode = "common"
+ s.CostBeforeMode = "per_channel"
+ }
+ if s.PurchaseTypeMode == "" {
+ s.PurchaseTypeMode = "common"
+ }
+ if s.CommentMode == "" {
+ s.CommentMode = "common"
}
if s.FormatMode == "" {
s.FormatMode = "common"
@@ -1102,11 +1496,20 @@ func (s *PurchaseOptionalDetails) ensureDefaults() {
if s.PlacementByChannel == nil {
s.PlacementByChannel = make(map[string]*time.Time)
}
+ if s.PaymentDateByChannel == nil {
+ s.PaymentDateByChannel = make(map[string]*time.Time)
+ }
if s.CostByChannel == nil {
s.CostByChannel = make(map[string]CostEntry)
}
if s.CostBeforeByChannel == nil {
- s.CostBeforeByChannel = make(map[string]*float64)
+ s.CostBeforeByChannel = make(map[string]CostEntry)
+ }
+ if s.PurchaseTypeByChannel == nil {
+ s.PurchaseTypeByChannel = make(map[string]string)
+ }
+ if s.CommentByChannel == nil {
+ s.CommentByChannel = make(map[string]string)
}
if s.FormatByChannel == nil {
s.FormatByChannel = make(map[string]string)
@@ -1127,6 +1530,11 @@ func (s *PurchaseOptionalDetails) syncChannelMaps() {
delete(s.PlacementByChannel, key)
}
}
+ for key := range s.PaymentDateByChannel {
+ if _, ok := valid[key]; !ok {
+ delete(s.PaymentDateByChannel, key)
+ }
+ }
for key := range s.CostByChannel {
if _, ok := valid[key]; !ok {
delete(s.CostByChannel, key)
@@ -1137,6 +1545,16 @@ func (s *PurchaseOptionalDetails) syncChannelMaps() {
delete(s.CostBeforeByChannel, key)
}
}
+ for key := range s.PurchaseTypeByChannel {
+ if _, ok := valid[key]; !ok {
+ delete(s.PurchaseTypeByChannel, key)
+ }
+ }
+ for key := range s.CommentByChannel {
+ if _, ok := valid[key]; !ok {
+ delete(s.CommentByChannel, key)
+ }
+ }
for key := range s.FormatByChannel {
if _, ok := valid[key]; !ok {
delete(s.FormatByChannel, key)
@@ -1146,7 +1564,7 @@ func (s *PurchaseOptionalDetails) syncChannelMaps() {
func (s *PurchaseOptionalDetails) formatPlacementSummary() string {
if s.PlacementMode == "per_channel" && len(s.Channels) > 1 {
- return "для каждого"
+ return "👥"
}
return s.formatDateTime(s.PlacementDateTime)
}
@@ -1158,9 +1576,23 @@ func (s *PurchaseOptionalDetails) formatPlacementDetails() string {
return "\n" + s.renderParamChannelSummary("placement")
}
+func (s *PurchaseOptionalDetails) formatPaymentDateSummary() string {
+ if s.PaymentDateMode == "per_channel" && len(s.Channels) > 1 {
+ return "👥"
+ }
+ return s.formatDate(s.PaymentDate)
+}
+
+func (s *PurchaseOptionalDetails) formatPaymentDateDetails() string {
+ if s.PaymentDateMode != "per_channel" || len(s.Channels) <= 1 {
+ return ""
+ }
+ return "\n" + s.renderParamChannelSummary("payment_date")
+}
+
func (s *PurchaseOptionalDetails) formatCostSummary() string {
if s.CostMode == "per_channel" && len(s.Channels) > 1 {
- return "для каждого"
+ return "👥"
}
return s.formatCostValue()
}
@@ -1174,7 +1606,7 @@ func (s *PurchaseOptionalDetails) formatCostDetails() string {
func (s *PurchaseOptionalDetails) formatCostBeforeSummary() string {
if s.CostBeforeMode == "per_channel" && len(s.Channels) > 1 {
- return "для каждого"
+ return "👥"
}
return s.formatCostBefore()
}
@@ -1186,9 +1618,37 @@ func (s *PurchaseOptionalDetails) formatCostBeforeDetails() string {
return "\n" + s.renderParamChannelSummary("cost_before")
}
+func (s *PurchaseOptionalDetails) formatPurchaseTypeSummary() string {
+ if s.PurchaseTypeMode == "per_channel" && len(s.Channels) > 1 {
+ return "👥"
+ }
+ return s.formatText(s.PurchaseType)
+}
+
+func (s *PurchaseOptionalDetails) formatPurchaseTypeDetails() string {
+ if s.PurchaseTypeMode != "per_channel" || len(s.Channels) <= 1 {
+ return ""
+ }
+ return "\n" + s.renderParamChannelSummary("purchase_type")
+}
+
+func (s *PurchaseOptionalDetails) formatCommentSummary() string {
+ if s.CommentMode == "per_channel" && len(s.Channels) > 1 {
+ return "👥"
+ }
+ return s.formatText(s.Comment)
+}
+
+func (s *PurchaseOptionalDetails) formatCommentDetails() string {
+ if s.CommentMode != "per_channel" || len(s.Channels) <= 1 {
+ return ""
+ }
+ return "\n" + s.renderParamChannelSummary("comment")
+}
+
func (s *PurchaseOptionalDetails) formatFormatSummary() string {
if s.FormatMode == "per_channel" && len(s.Channels) > 1 {
- return "для каждого"
+ return "👥"
}
return s.formatText(s.Format)
}
@@ -1205,13 +1665,26 @@ func (s *PurchaseOptionalDetails) formatParamValue(param, channelKey string) str
case "placement":
value := s.PlacementByChannel[channelKey]
return s.formatDateTime(value)
+ case "payment_date":
+ value := s.PaymentDateByChannel[channelKey]
+ return s.formatDate(value)
case "cost":
return s.formatCostForChannel(channelKey)
case "cost_before":
return s.formatCostBeforeForChannel(channelKey)
+ case "purchase_type":
+ if value, ok := s.PurchaseTypeByChannel[channelKey]; ok && value != "" {
+ return fmt.Sprintf("%s", value)
+ }
+ return "—"
+ case "comment":
+ if value, ok := s.CommentByChannel[channelKey]; ok && value != "" {
+ return fmt.Sprintf("%s", value)
+ }
+ return "—"
case "format":
if value, ok := s.FormatByChannel[channelKey]; ok && value != "" {
- return value
+ return fmt.Sprintf("%s", value)
}
return "—"
default:
@@ -1225,25 +1698,34 @@ func (s *PurchaseOptionalDetails) formatCostForChannel(channelKey string) string
return "—"
}
label := s.costTypeLabelForEntry(entry)
- return fmt.Sprintf("%s %.0f₽", label, *entry.Value)
+ text := fmt.Sprintf("%s %.0f₽", label, *entry.Value)
+ return fmt.Sprintf("%s", text)
}
func (s *PurchaseOptionalDetails) formatCostBeforeForChannel(channelKey string) string {
- value, ok := s.CostBeforeByChannel[channelKey]
- if !ok || value == nil {
+ entry, ok := s.CostBeforeByChannel[channelKey]
+ if !ok || entry.Value == nil {
return "—"
}
- return fmt.Sprintf("%.0f₽", *value)
+ label := s.costBeforeTypeLabelForEntry(entry)
+ text := fmt.Sprintf("%s %.0f₽", label, *entry.Value)
+ return fmt.Sprintf("%s", text)
}
func (s *PurchaseOptionalDetails) hasCopyBuffer(param string) bool {
switch param {
case "placement":
return s.PlacementCopy != nil
+ case "payment_date":
+ return s.PaymentDateCopy != nil
case "cost":
return s.CostCopy != nil
case "cost_before":
return s.CostBeforeCopy != nil
+ case "purchase_type":
+ return s.PurchaseTypeCopy != nil
+ case "comment":
+ return s.CommentCopy != nil
case "format":
return s.FormatCopy != nil
default:
@@ -1256,12 +1738,21 @@ func (s *PurchaseOptionalDetails) hasChannelValue(param, channelKey string) bool
case "placement":
value, ok := s.PlacementByChannel[channelKey]
return ok && value != nil
+ case "payment_date":
+ value, ok := s.PaymentDateByChannel[channelKey]
+ return ok && value != nil
case "cost":
entry, ok := s.CostByChannel[channelKey]
return ok && entry.Value != nil
case "cost_before":
- value, ok := s.CostBeforeByChannel[channelKey]
- return ok && value != nil
+ entry, ok := s.CostBeforeByChannel[channelKey]
+ return ok && entry.Value != nil
+ case "purchase_type":
+ value, ok := s.PurchaseTypeByChannel[channelKey]
+ return ok && value != ""
+ case "comment":
+ value, ok := s.CommentByChannel[channelKey]
+ return ok && value != ""
case "format":
value, ok := s.FormatByChannel[channelKey]
return ok && value != ""
@@ -1282,6 +1773,18 @@ func (s *PurchaseOptionalDetails) hasPlacementValue() bool {
return s.PlacementDateTime != nil
}
+func (s *PurchaseOptionalDetails) hasPaymentDateValue() bool {
+ if s.PaymentDateMode == "per_channel" && len(s.Channels) > 1 {
+ for _, value := range s.PaymentDateByChannel {
+ if value != nil {
+ return true
+ }
+ }
+ return false
+ }
+ return s.PaymentDate != nil
+}
+
func (s *PurchaseOptionalDetails) hasCostValue() bool {
if s.CostMode == "per_channel" && len(s.Channels) > 1 {
for _, entry := range s.CostByChannel {
@@ -1296,14 +1799,14 @@ func (s *PurchaseOptionalDetails) hasCostValue() bool {
func (s *PurchaseOptionalDetails) hasCostBeforeValue() bool {
if s.CostBeforeMode == "per_channel" && len(s.Channels) > 1 {
- for _, value := range s.CostBeforeByChannel {
- if value != nil {
+ for _, entry := range s.CostBeforeByChannel {
+ if entry.Value != nil {
return true
}
}
return false
}
- return s.CostBeforeBargain != nil
+ return s.CostBeforeBargain != nil && s.CostBeforeBargain.Value != nil
}
func (s *PurchaseOptionalDetails) hasFormatValue() bool {
@@ -1318,6 +1821,30 @@ func (s *PurchaseOptionalDetails) hasFormatValue() bool {
return s.Format != ""
}
+func (s *PurchaseOptionalDetails) hasPurchaseTypeValue() bool {
+ if s.PurchaseTypeMode == "per_channel" && len(s.Channels) > 1 {
+ for _, value := range s.PurchaseTypeByChannel {
+ if value != "" {
+ return true
+ }
+ }
+ return false
+ }
+ return s.PurchaseType != ""
+}
+
+func (s *PurchaseOptionalDetails) hasCommentValue() bool {
+ if s.CommentMode == "per_channel" && len(s.Channels) > 1 {
+ for _, value := range s.CommentByChannel {
+ if value != "" {
+ return true
+ }
+ }
+ return false
+ }
+ return s.Comment != ""
+}
+
func (s *PurchaseOptionalDetails) costTypeLabelForEntry(entry CostEntry) string {
if entry.Type == "cpm" {
return "СРМ"
@@ -1325,6 +1852,13 @@ func (s *PurchaseOptionalDetails) costTypeLabelForEntry(entry CostEntry) string
return "Фикс"
}
+func (s *PurchaseOptionalDetails) costBeforeTypeLabelForEntry(entry CostEntry) string {
+ if entry.Type == "cpm" {
+ return "СРМ"
+ }
+ return "Фикс"
+}
+
func (s *PurchaseOptionalDetails) costTypeLabelForCurrent() string {
if s.CurrentChannel == "" {
return s.costTypeLabel()
@@ -1336,6 +1870,17 @@ func (s *PurchaseOptionalDetails) costTypeLabelForCurrent() string {
return s.costTypeLabelForEntry(entry)
}
+func (s *PurchaseOptionalDetails) costBeforeTypeLabelForCurrent() string {
+ if s.CurrentChannel == "" {
+ return s.costBeforeTypeLabel()
+ }
+ entry, ok := s.CostBeforeByChannel[s.CurrentChannel]
+ if !ok || entry.Type == "" {
+ return s.costBeforeTypeLabel()
+ }
+ return s.costBeforeTypeLabelForEntry(entry)
+}
+
func (s *PurchaseOptionalDetails) toggleCostType() {
if s.CurrentChannel == "" {
if s.costTypeLabel() == "СРМ" {
@@ -1357,6 +1902,30 @@ func (s *PurchaseOptionalDetails) toggleCostType() {
s.CostByChannel[s.CurrentChannel] = entry
}
+func (s *PurchaseOptionalDetails) toggleCostBeforeType() {
+ if s.CurrentChannel == "" {
+ if s.CostBeforeBargain == nil {
+ s.CostBeforeBargain = &CostEntry{}
+ }
+ if s.costBeforeTypeLabel() == "СРМ" {
+ s.CostBeforeBargain.Type = "fixed"
+ } else {
+ s.CostBeforeBargain.Type = "cpm"
+ }
+ return
+ }
+ entry := s.CostBeforeByChannel[s.CurrentChannel]
+ if entry.Type == "" {
+ entry.Type = s.CostBeforeType
+ }
+ if s.costBeforeTypeLabelForEntry(entry) == "СРМ" {
+ entry.Type = "fixed"
+ } else {
+ entry.Type = "cpm"
+ }
+ s.CostBeforeByChannel[s.CurrentChannel] = entry
+}
+
func (s *PurchaseOptionalDetails) setCostValue(value float64) {
if s.CurrentChannel == "" {
s.CostValue = &value
@@ -1372,10 +1941,21 @@ func (s *PurchaseOptionalDetails) setCostValue(value float64) {
func (s *PurchaseOptionalDetails) setCostBeforeValue(value float64) {
if s.CurrentChannel == "" {
- s.CostBeforeBargain = &value
+ if s.CostBeforeBargain == nil {
+ s.CostBeforeBargain = &CostEntry{}
+ }
+ s.CostBeforeBargain.Value = &value
+ if s.CostBeforeBargain.Type == "" {
+ s.CostBeforeBargain.Type = s.CostBeforeType
+ }
return
}
- s.CostBeforeByChannel[s.CurrentChannel] = &value
+ entry := s.CostBeforeByChannel[s.CurrentChannel]
+ entry.Value = &value
+ if entry.Type == "" {
+ entry.Type = s.CostBeforeType
+ }
+ s.CostBeforeByChannel[s.CurrentChannel] = entry
}
func (s *PurchaseOptionalDetails) setFormatValue(value string) {
@@ -1472,16 +2052,28 @@ func (s *PurchaseOptionalDetails) buildPlacementDetails(placementType *string) *
details.PlacementAt = &formatted
}
}
- if s.PaymentDate != nil {
- formatted := s.PaymentDate.Format(time.RFC3339)
- details.PaymentAt = &formatted
+ if s.PaymentDateMode != "per_channel" || len(s.Channels) <= 1 {
+ if s.PaymentDate != nil {
+ formatted := s.PaymentDate.Format(time.RFC3339)
+ details.PaymentAt = &formatted
+ }
}
if s.CostMode != "per_channel" || len(s.Channels) <= 1 {
details.Cost = s.buildCostInfo(s.CostType, s.CostValue)
}
if s.CostBeforeMode != "per_channel" || len(s.Channels) <= 1 {
- if s.CostBeforeBargain != nil {
- details.CostBeforeBargain = s.CostBeforeBargain
+ if s.CostBeforeBargain != nil && s.CostBeforeBargain.Value != nil {
+ details.CostBeforeBargain = s.buildCostInfo(s.CostBeforeBargain.Type, s.CostBeforeBargain.Value)
+ }
+ }
+ if s.PurchaseTypeMode != "per_channel" || len(s.Channels) <= 1 {
+ if s.PurchaseType != "" {
+ details.PlacementType = &s.PurchaseType
+ }
+ }
+ if s.CommentMode != "per_channel" || len(s.Channels) <= 1 {
+ if s.Comment != "" {
+ details.Comment = &s.Comment
}
}
if s.FormatMode != "per_channel" || len(s.Channels) <= 1 {
@@ -1489,10 +2081,7 @@ func (s *PurchaseOptionalDetails) buildPlacementDetails(placementType *string) *
details.Format = &s.Format
}
}
- if s.Comment != "" {
- details.Comment = &s.Comment
- }
- if placementType != nil {
+ if placementType != nil && (s.PurchaseTypeMode == "per_channel" || len(s.Channels) <= 1) {
details.PlacementType = placementType
}
@@ -1511,13 +2100,29 @@ func (s *PurchaseOptionalDetails) buildChannelDetails(channelKey string) *backen
details.PlacementAt = &formatted
}
}
+ if s.PaymentDateMode == "per_channel" {
+ if value, ok := s.PaymentDateByChannel[channelKey]; ok && value != nil {
+ formatted := value.Format(time.RFC3339)
+ details.PaymentAt = &formatted
+ }
+ }
if s.CostMode == "per_channel" {
entry := s.CostByChannel[channelKey]
details.Cost = s.buildCostInfo(entry.Type, entry.Value)
}
if s.CostBeforeMode == "per_channel" {
- if value, ok := s.CostBeforeByChannel[channelKey]; ok && value != nil {
- details.CostBeforeBargain = value
+ if entry, ok := s.CostBeforeByChannel[channelKey]; ok && entry.Value != nil {
+ details.CostBeforeBargain = s.buildCostInfo(entry.Type, entry.Value)
+ }
+ }
+ if s.PurchaseTypeMode == "per_channel" {
+ if value, ok := s.PurchaseTypeByChannel[channelKey]; ok && value != "" {
+ details.PlacementType = &value
+ }
+ }
+ if s.CommentMode == "per_channel" {
+ if value, ok := s.CommentByChannel[channelKey]; ok && value != "" {
+ details.Comment = &value
}
}
if s.FormatMode == "per_channel" {
diff --git a/tg_bot/screens/select_channels_for_purchase.go b/tg_bot/screens/select_channels_for_purchase.go
index cad15bc..f683d59 100644
--- a/tg_bot/screens/select_channels_for_purchase.go
+++ b/tg_bot/screens/select_channels_for_purchase.go
@@ -3,7 +3,6 @@ package screens
import (
"context"
"fmt"
- "regexp"
"strings"
"github.com/NicoNex/echotron/v3"
@@ -33,10 +32,16 @@ type SelectChannelsForPurchase struct {
Channels []PurchaseChannelInput
CurrentPage int
InvalidUsernames []string
+ Duplicates []string // дубликаты каналов
+ ParsingErrors []ParseError // ошибки парсинга с предложениями
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) {
s.renderChannelSelection(b, mode)
@@ -118,6 +123,30 @@ func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.
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 {
buttons = append(buttons, Row(
@@ -164,18 +193,18 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
return
}
- // Создаем/обновляем каналы в БД через API
- jwt := b.Session.JWT
- if jwt == "" {
- log.Error().Msg("JWT is empty in session")
- b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
- return
+ // Проверяем, что все каналы резолвлены (имеют ChannelID)
+ var unresolved []string
+ for _, ch := range s.Channels {
+ if ch.ChannelID == "" {
+ label := channelLabel(ch)
+ unresolved = append(unresolved, label)
+ }
}
- failed := s.resolveChannels(b, jwt)
- if len(failed) > 0 {
- text := "❌ Не удалось добавить каналы:\n\n"
- for _, label := range failed {
+ if len(unresolved) > 0 {
+ text := "❌ Некоторые каналы не были найдены:\n\n"
+ for _, label := range unresolved {
text += fmt.Sprintf("• %s\n", label)
}
text += "\nУдалите их из списка и попробуйте снова"
@@ -186,6 +215,7 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
return
}
+ // Каналы уже резолвлены в HandleMessage, переходим к следующему шагу
b.SetState(&PurchaseOptionalDetails{
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
@@ -290,12 +320,25 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update
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 {
return r == ' ' || r == '\n' || r == '\t' || r == ',' || r == ';'
})
added := 0
- var invalid []string
+ s.Duplicates = nil
+ s.InvalidUsernames = nil
+ s.ParsingErrors = nil
+
+tokensLoop:
for _, token := range tokens {
entry := strings.TrimSpace(token)
entry = strings.Trim(entry, ",;")
@@ -303,53 +346,144 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update
continue
}
- var username string
- var inviteLink string
- if isInviteLink(entry) {
- inviteLink = entry
- } else {
- username = strings.TrimPrefix(entry, "@")
- username = strings.Trim(username, "@")
- if !channelUsernameRe.MatchString(username) {
- invalid = append(invalid, entry)
- continue
- }
- }
+ // Используем новый парсер
+ parsed := ParseChannelInput(entry)
- exists := false
- for _, ch := range s.Channels {
- if inviteLink != "" && ch.InviteLink == inviteLink {
- exists = true
- break
+ if !parsed.Valid {
+ // Пробуем предложить исправление
+ if suggestion, ok := SuggestFix(entry); ok {
+ s.ParsingErrors = append(s.ParsingErrors, ParseError{
+ Input: entry,
+ Suggestion: suggestion,
+ })
+ } else {
+ s.InvalidUsernames = append(s.InvalidUsernames, entry)
}
- if username != "" && ch.Username == username {
- exists = true
- break
- }
- }
- if exists {
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{
- Username: username,
- InviteLink: inviteLink,
+ ChannelID: channel.ID,
+ Username: parsed.Username,
+ Title: title,
+ InviteLink: parsed.InviteLink,
})
added++
}
- if added == 0 {
- if len(invalid) == 0 {
- return
- }
+ // Если ничего не добавлено и нет ошибок - не обновляем экран
+ if added == 0 && len(s.InvalidUsernames) == 0 && len(s.Duplicates) == 0 && len(s.ParsingErrors) == 0 {
+ return
}
- s.InvalidUsernames = invalid
-
// Обновляем экран
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 {
inputs := make([]backend.CreateChannelInput, 0, len(s.Channels))
for _, ch := range s.Channels {