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