правки

This commit is contained in:
Artem Tsyrulnikov
2026-02-18 20:47:19 +03:00
parent e48cd7bccc
commit 4b9e5bf4df
12 changed files with 92 additions and 70 deletions

View File

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

View File

@@ -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'<a\s+class=["\']tg-link["\']>(.*?)</a>', 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 '<a class="tg-link"></a>'
return f'<a class="tg-link">{inner}</a>'
@@ -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 '<a class="tg-link"></a>'
return f'<a class="tg-link">{inner}</a>'

View File

@@ -15,7 +15,7 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__)
_INVITE_LINK_TAG = re.compile(r'<a\s+class=["\']tg-link["\']>([^<]*)</a>', re.IGNORECASE)
_INVITE_LINK_TAG = re.compile(r'<a\s+class=["\']tg-link["\']>(.*?)</a>', re.IGNORECASE | re.DOTALL)
_INVITE_LINK_PLACEHOLDER = '{{invite_link}}'

View File

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

View File

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

View File

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

View File

@@ -2,47 +2,40 @@ package screens
import (
"fmt"
"strings"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
)
const addProjectInstructionText = `
<b>Как добавить канал в проект</b>
<b>Инструкция:</b>
<b>1.</b> Добавьте бота в ваш канал как администратора
<b>2.</b> Дайте боту следующие права:
• Удаление сообщений
• Приглашение пользователей
<b>3.</b> После добавления бота:
• Если у вас <b>1 рабочее пространство</b> — канал добавится автоматически
• Если у вас <b>несколько рабочих пространств</b> — вы получите уведомление с кнопкой выбора
<b>4.</b> Готово! Канал появится в списке проектов
`
type AddProject struct {
BackState bot.State
}
const addProjectInstructionText = `
<b>Как добавить канал в проект</b>
Для подключения канала к боту, назначьте @%s администратором канала, выдав следующие права:
➔ Управление сообщениями
➔ Добавление участников
После добавления бота:
• Если у вас <b>1 рабочее пространство</b> — канал добавится автоматически
• Если у вас <b>несколько рабочих пространств</b> — вы получите уведомление с кнопкой выбора
`
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() {}

View File

@@ -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(`<a\s+class="tg-link">\s*(.*?)\s*</a>`)
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

View File

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

View File

@@ -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 {
// Иногда бэкенд может вернуть только дату без времени
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")
}

View File

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

View File

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