From 4b9e5bf4dfad099c587bf570642f11a847f5b0f3 Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Wed, 18 Feb 2026 20:47:19 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adapter/postgres.py | 2 +- src/domain/creative.py | 9 +--- .../purchase/build_placement_creative.py | 2 +- tg_bot/backend/backend_client.go | 1 + tg_bot/bot/bot.go | 4 +- tg_bot/main.go | 2 +- tg_bot/screens/add_project.go | 45 ++++++++----------- tg_bot/screens/creative_editor.go | 3 +- tg_bot/screens/placement_details.go | 13 +++--- tg_bot/screens/placements.go | 26 +++++------ tg_bot/screens/purchase_optional_details.go | 21 ++++++--- tg_bot/screens/ui/date_time_picker.go | 34 +++++++++++--- 12 files changed, 92 insertions(+), 70 deletions(-) diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index a23e8c0..b29e675 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -436,7 +436,7 @@ class Postgres(DatabaseBase): return ( await query.prefetch_related('project', 'project__channel', 'channel', 'creative') - .order_by('-placement_at', '-created_at') + .order_by('-created_at') .all() ) diff --git a/src/domain/creative.py b/src/domain/creative.py index 677935b..71af5c7 100644 --- a/src/domain/creative.py +++ b/src/domain/creative.py @@ -80,11 +80,6 @@ _INVITE_LINK_HTML = re.compile( ) _INVITE_LINK_PLAIN = re.compile(r'https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+', re.IGNORECASE) _INVITE_LINK_REPLACED = re.compile(r'(.*?)', re.IGNORECASE | re.DOTALL) -_HTML_TAG = re.compile(r'<[^>]+>') - - -def _strip_html(text: str) -> str: - return _HTML_TAG.sub('', text).strip() def replace_invite_link_with_tag(text: str) -> str: @@ -105,7 +100,7 @@ def replace_invite_link_with_tag(text: str) -> str: # Normalize already replaced links (preserve anchor text) def _normalize_replaced(match: re.Match[str]) -> str: - inner = _strip_html(match.group(1)) + inner = match.group(1).strip() if inner == "" or _INVITE_LINK_PLAIN.search(inner): return '' return f'{inner}' @@ -114,7 +109,7 @@ def replace_invite_link_with_tag(text: str) -> str: # Replace HTML links with tracking tags (preserve anchor text) def _replace_html(match: re.Match[str]) -> str: - inner = _strip_html(match.group(1)) + inner = match.group(1).strip() if _INVITE_LINK_PLAIN.fullmatch(inner): return '' return f'{inner}' diff --git a/src/usecase/purchase/build_placement_creative.py b/src/usecase/purchase/build_placement_creative.py index 687f6a8..e29a4ae 100644 --- a/src/usecase/purchase/build_placement_creative.py +++ b/src/usecase/purchase/build_placement_creative.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) -_INVITE_LINK_TAG = re.compile(r'([^<]*)', re.IGNORECASE) +_INVITE_LINK_TAG = re.compile(r'(.*?)', re.IGNORECASE | re.DOTALL) _INVITE_LINK_PLACEHOLDER = '{{invite_link}}' diff --git a/tg_bot/backend/backend_client.go b/tg_bot/backend/backend_client.go index db2fd83..f438be1 100644 --- a/tg_bot/backend/backend_client.go +++ b/tg_bot/backend/backend_client.go @@ -615,6 +615,7 @@ type PlacementOutput struct { ShortID string `json:"short_id"` Details *PlacementDetails `json:"details,omitempty"` PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"` + CreatedAt string `json:"created_at"` } type PlacementPostOutput struct { diff --git a/tg_bot/bot/bot.go b/tg_bot/bot/bot.go index 4965fc9..47d3332 100644 --- a/tg_bot/bot/bot.go +++ b/tg_bot/bot/bot.go @@ -21,9 +21,9 @@ type exec struct { var botUsername string -func SetBotUsername(username string) { botUsername = username } +func SetUsername(username string) { botUsername = username } -func BotUsername() string { return botUsername } +func Username() string { return botUsername } type lastRender struct { textHash string diff --git a/tg_bot/main.go b/tg_bot/main.go index e35674e..dca5d9c 100644 --- a/tg_bot/main.go +++ b/tg_bot/main.go @@ -37,7 +37,7 @@ func main() { if me, err := api.GetMe(); err != nil { log.Error().Err(err).Msg("Failed to get bot profile") } else if me.Result != nil { - bot.SetBotUsername(me.Result.Username) + bot.SetUsername(me.Result.Username) log.Info().Str("username", me.Result.Username).Msg("Telegram bot authorized") } diff --git a/tg_bot/screens/add_project.go b/tg_bot/screens/add_project.go index 70b05d7..a0630bc 100644 --- a/tg_bot/screens/add_project.go +++ b/tg_bot/screens/add_project.go @@ -2,47 +2,40 @@ package screens import ( "fmt" + "strings" "github.com/NicoNex/echotron/v3" "github.com/TelegramExchange/tgex-backend/tg_bot/bot" ) -const addProjectInstructionText = ` -Как добавить канал в проект - -Инструкция: - -1. Добавьте бота в ваш канал как администратора - -2. Дайте боту следующие права: - • Удаление сообщений - • Приглашение пользователей - -3. После добавления бота: - • Если у вас 1 рабочее пространство — канал добавится автоматически - • Если у вас несколько рабочих пространств — вы получите уведомление с кнопкой выбора - -4. Готово! Канал появится в списке проектов -` - type AddProject struct { BackState bot.State } +const addProjectInstructionText = ` +Как добавить канал в проект + +Для подключения канала к боту, назначьте @%s администратором канала, выдав следующие права: + +➔ Управление сообщениями +➔ Добавление участников + +После добавления бота: +• Если у вас 1 рабочее пространство — канал добавится автоматически +• Если у вас несколько рабочих пространств — вы получите уведомление с кнопкой выбора +` + func (s *AddProject) Enter(b *bot.Bot, mode bot.RenderMode) { - botUsername := bot.BotUsername() - if botUsername == "" { - botUsername = "chat_cruiser_bot" - } + botUsername := strings.TrimPrefix(bot.Username(), "@") addBotURL := fmt.Sprintf("https://t.me/%s?startchannel&admin=invite_users+post_messages+edit_messages+delete_messages", botUsername) buttons := [][]echotron.InlineKeyboardButton{ - Row(URLButton("Добавить", addBotURL)), + Row(URLButton("Назначить администратором", addBotURL)), Row(Button("← Назад", "back")), } keyboard := Keyboard(buttons...) - b.Render(addProjectInstructionText, keyboard, mode) + b.Render(fmt.Sprintf(addProjectInstructionText, botUsername), keyboard, mode) } func (s *AddProject) HandleCallback(b *bot.Bot, u *echotron.Update) { @@ -58,8 +51,8 @@ func (s *AddProject) HandleCallback(b *bot.Bot, u *echotron.Update) { } } -func (s *AddProject) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } +func (s *AddProject) HandleMessage(_ *bot.Bot, _ *echotron.Update) {} -func (s *AddProject) Handle(_ *bot.Bot, _ *echotron.Update) { return } +func (s *AddProject) Handle(_ *bot.Bot, _ *echotron.Update) {} func (s *AddProject) Exit() {} diff --git a/tg_bot/screens/creative_editor.go b/tg_bot/screens/creative_editor.go index f700618..642e737 100644 --- a/tg_bot/screens/creative_editor.go +++ b/tg_bot/screens/creative_editor.go @@ -65,7 +65,6 @@ const buttonURLPlaceholder = "https://example.com" var inviteLinkPattern = regexp.MustCompile(`https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+`) var inviteLinkTagPattern = regexp.MustCompile(`\s*(.*?)\s*`) -var htmlTagPattern = regexp.MustCompile(`<[^>]+>`) const ( inputModeText = "text" @@ -193,7 +192,7 @@ func (e *CreativeEditorFields) GetCreativeText() string { sub := inviteLinkTagPattern.FindStringSubmatch(match) inner := "" if len(sub) > 1 { - inner = strings.TrimSpace(htmlTagPattern.ReplaceAllString(sub[1], "")) + inner = strings.TrimSpace(sub[1]) } if inner == "" { inner = inviteLinkPreviewURL diff --git a/tg_bot/screens/placement_details.go b/tg_bot/screens/placement_details.go index bda0877..a2f57fa 100644 --- a/tg_bot/screens/placement_details.go +++ b/tg_bot/screens/placement_details.go @@ -9,6 +9,7 @@ import ( "github.com/NicoNex/echotron/v3" "github.com/TelegramExchange/tgex-backend/tg_bot/backend" "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui" ) type PlacementDetails struct { @@ -247,16 +248,16 @@ func formatDateTime(value string) string { // Try parsing with timezone if parsed, err = time.Parse(time.RFC3339, value); err == nil { - return formatCompactDateTime(parsed) + return formatCompactDateTime(parsed.In(ui.MskLocation)) } if parsed, err = time.Parse(time.RFC3339Nano, value); err == nil { - return formatCompactDateTime(parsed) + return formatCompactDateTime(parsed.In(ui.MskLocation)) } - if parsed, err = time.Parse("2006-01-02T15:04:05", value); err == nil { - return formatCompactDateTime(parsed) + if parsed, err = time.ParseInLocation("2006-01-02T15:04:05", value, ui.MskLocation); err == nil { + return formatCompactDateTime(parsed.In(ui.MskLocation)) } - if parsed, err = time.Parse("2006-01-02", value); err == nil { - return formatCompactDateTime(parsed) + if parsed, err = time.ParseInLocation("2006-01-02", value, ui.MskLocation); err == nil { + return formatCompactDateTime(parsed.In(ui.MskLocation)) } return value } diff --git a/tg_bot/screens/placements.go b/tg_bot/screens/placements.go index d60ceac..db1e9ac 100644 --- a/tg_bot/screens/placements.go +++ b/tg_bot/screens/placements.go @@ -133,7 +133,8 @@ func (s *PlacementsList) render(b *bot.Bot, mode bot.RenderMode) { // Формируем кнопки для каждого размещения buttons := make([]echotron.InlineKeyboardButton, 0, len(page.Items)) for i, placement := range page.Items { - // Обратный глобальный номер (последнее = #1, первое = #N) + // Нумерация по порядку создания: первое размещение = #1 (на последней странице), + // последнее размещение = #N (вверху). globalNum := page.Total - (s.CurrentPage * placementsPerPage) - i // Название канала @@ -150,10 +151,10 @@ func (s *PlacementsList) render(b *bot.Bot, mode bot.RenderMode) { dateStr = formatDate(*placement.Details.PlacementAt) } - // Текст кнопки: #N • Канал (с пробелом и разделителем) - buttonText := fmt.Sprintf("%d • %s", globalNum, channelName) + // Текст кнопки: #N • Канал • DD.MM + buttonText := fmt.Sprintf("#%d • %s", globalNum, channelName) if dateStr != "" { - buttonText += fmt.Sprintf(" %s", dateStr) + buttonText += fmt.Sprintf(" • %s", dateStr) } buttons = append(buttons, echotron.InlineKeyboardButton{ @@ -225,22 +226,21 @@ func (s *PlacementsList) Handle(_ *bot.Bot, _ *echotron.Update) { return } func (s *PlacementsList) Exit() {} -// formatDate парсит ISO дату и форматирует как "27 янв" +// formatDate парсит ISO дату и форматирует как "27.01" func formatDate(isoDate string) string { // Пробуем парсить RFC3339 t, err := time.Parse(time.RFC3339, isoDate) if err != nil { // Пробуем другие форматы - t, err = time.Parse("2006-01-02T15:04:05", isoDate) + t, err = time.ParseInLocation("2006-01-02T15:04:05", isoDate, ui.MskLocation) if err != nil { - return "" + // Иногда бэкенд может вернуть только дату без времени + t, err = time.ParseInLocation("2006-01-02", isoDate, ui.MskLocation) + if err != nil { + return "" + } } } - // Русские названия месяцев - months := []string{"янв", "фев", "мар", "апр", "мая", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"} - day := t.Day() - month := months[t.Month()-1] - - return fmt.Sprintf("%d %s", day, month) + return t.In(ui.MskLocation).Format("02.01") } diff --git a/tg_bot/screens/purchase_optional_details.go b/tg_bot/screens/purchase_optional_details.go index a6156ed..40e511a 100644 --- a/tg_bot/screens/purchase_optional_details.go +++ b/tg_bot/screens/purchase_optional_details.go @@ -147,6 +147,7 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) Title: "Дата оплаты", Key: "payment_date", IncludeTime: false, + AllowPast: true, Selected: s.PaymentDate, BackState: s, }), bot.EditMessage) @@ -508,7 +509,11 @@ func (s *PurchaseOptionalDetails) costTypeLabel() string { } func (s *PurchaseOptionalDetails) costBeforeTypeLabel() string { - if s.CostBeforeType == "cpm" { + costType := s.CostBeforeType + if costType == "" && s.CostBeforeBargain != nil { + costType = s.CostBeforeBargain.Type + } + if costType == "cpm" { return "СРМ" } return "Фикс" @@ -1144,6 +1149,7 @@ func (s *PurchaseOptionalDetails) openCommonEditor(b *bot.Bot, param string) { Title: "Дата и время размещения", Key: "placement_datetime", IncludeTime: true, + AllowPast: true, Selected: s.PlacementDateTime, BackState: s, }), bot.EditMessage) @@ -1154,6 +1160,7 @@ func (s *PurchaseOptionalDetails) openCommonEditor(b *bot.Bot, param string) { Title: "Дата оплаты", Key: "payment_date", IncludeTime: false, + AllowPast: true, Selected: s.PaymentDate, BackState: s, }), bot.EditMessage) @@ -1201,6 +1208,7 @@ func (s *PurchaseOptionalDetails) openChannelEditor(b *bot.Bot, param, channelKe Title: "Дата и время размещения", Key: "placement_datetime:" + channelKey, IncludeTime: true, + AllowPast: true, Selected: s.PlacementByChannel[channelKey], BackState: s, }), bot.EditMessage) @@ -1210,6 +1218,7 @@ func (s *PurchaseOptionalDetails) openChannelEditor(b *bot.Bot, param, channelKe Title: "Дата оплаты", Key: "payment_date:" + channelKey, IncludeTime: false, + AllowPast: true, Selected: s.PaymentDateByChannel[channelKey], BackState: s, }), bot.EditMessage) @@ -2093,13 +2102,13 @@ func (s *PurchaseOptionalDetails) toggleCostType() { func (s *PurchaseOptionalDetails) toggleCostBeforeType() { if s.CurrentChannel == "" { - if s.CostBeforeBargain == nil { - s.CostBeforeBargain = &CostEntry{} - } if s.costBeforeTypeLabel() == "СРМ" { - s.CostBeforeBargain.Type = "fixed" + s.CostBeforeType = "fixed" } else { - s.CostBeforeBargain.Type = "cpm" + s.CostBeforeType = "cpm" + } + if s.CostBeforeBargain != nil { + s.CostBeforeBargain.Type = s.CostBeforeType } return } diff --git a/tg_bot/screens/ui/date_time_picker.go b/tg_bot/screens/ui/date_time_picker.go index 067cb65..82d250f 100644 --- a/tg_bot/screens/ui/date_time_picker.go +++ b/tg_bot/screens/ui/date_time_picker.go @@ -24,6 +24,7 @@ type DateTimePickerConfig struct { Title string Key string IncludeTime bool + AllowPast bool Selected *time.Time BackState bot.State } @@ -36,6 +37,7 @@ type DateTimePicker struct { Title string Key string IncludeTime bool + AllowPast bool BackState bot.State view string @@ -63,6 +65,7 @@ func NewDateTimePicker(cfg DateTimePickerConfig) *DateTimePicker { Title: cfg.Title, Key: cfg.Key, IncludeTime: cfg.IncludeTime, + AllowPast: cfg.AllowPast, BackState: cfg.BackState, view: pickerViewCalendar, year: year, @@ -208,7 +211,7 @@ func (s *DateTimePicker) calendarKeyboard() echotron.InlineKeyboardMarkup { for day := 1; day <= daysInMonth; day++ { date := time.Date(s.year, s.month, day, 0, 0, 0, 0, MskLocation) - if date.Before(todayDate) { + if !s.AllowPast && date.Before(todayDate) { row = append(row, echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"}) } else { label := fmt.Sprintf("%d", day) @@ -462,6 +465,9 @@ func (s *DateTimePicker) minuteButton(minute int) echotron.InlineKeyboardButton } func (s *DateTimePicker) isHourDisabled(hour int) bool { + if s.AllowPast { + return false + } if s.selectedDate == nil { return false } @@ -475,6 +481,9 @@ func (s *DateTimePicker) isHourDisabled(hour int) bool { } func (s *DateTimePicker) isMinuteDisabled(minute int) bool { + if s.AllowPast { + return false + } if s.selectedDate == nil || s.selectedHour == nil { return false } @@ -491,6 +500,16 @@ func (s *DateTimePicker) isMinuteDisabled(minute int) bool { } func (s *DateTimePicker) prevMonth() { + if s.AllowPast { + if s.month == time.January { + s.month = time.December + s.year-- + } else { + s.month-- + } + return + } + now := time.Now().In(MskLocation) if s.year == now.Year() && s.month == now.Month() { return @@ -513,6 +532,10 @@ func (s *DateTimePicker) nextMonth() { } func (s *DateTimePicker) monthPrevButton() echotron.InlineKeyboardButton { + if s.AllowPast { + return echotron.InlineKeyboardButton{Text: "←", CallbackData: dtpCallbackPrefix + "month_prev"} + } + now := time.Now().In(MskLocation) if s.year == now.Year() && s.month == now.Month() { return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"} @@ -554,7 +577,7 @@ func (s *DateTimePicker) HandleMessage(b *bot.Bot, u *echotron.Update) { input := strings.TrimSpace(u.Message.Text) switch s.view { case pickerViewCalendar: - value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute) + value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute, s.AllowPast) if !hasDate { s.Enter(b, bot.NewMessage) return @@ -573,7 +596,7 @@ func (s *DateTimePicker) HandleMessage(b *bot.Bot, u *echotron.Update) { } s.Enter(b, bot.NewMessage) case pickerViewTimeCombined: - value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute) + value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute, s.AllowPast) if !hasTime { s.Enter(b, bot.NewMessage) return @@ -587,7 +610,7 @@ func (s *DateTimePicker) HandleMessage(b *bot.Bot, u *echotron.Update) { s.selectedMinute = &minute s.Enter(b, bot.NewMessage) default: - value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute) + value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute, s.AllowPast) if !hasDate && !hasTime { s.Enter(b, bot.NewMessage) return @@ -611,6 +634,7 @@ func parseDateTimeInput( currentDate *time.Time, currentHour *int, currentMinute *int, + allowPast bool, ) (time.Time, bool, bool) { text := strings.ToLower(strings.TrimSpace(input)) text = strings.ReplaceAll(text, ",", " ") @@ -637,7 +661,7 @@ func parseDateTimeInput( } value := time.Date(date.Year(), date.Month(), date.Day(), hour, minute, 0, 0, MskLocation) - if value.Before(now) { + if !allowPast && value.Before(now) { return time.Time{}, false, false } return value, dateOk, timeOk