правки

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 ( return (
await query.prefetch_related('project', 'project__channel', 'channel', 'creative') await query.prefetch_related('project', 'project__channel', 'channel', 'creative')
.order_by('-placement_at', '-created_at') .order_by('-created_at')
.all() .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_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) _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: 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) # Normalize already replaced links (preserve anchor text)
def _normalize_replaced(match: re.Match[str]) -> str: 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): if inner == "" or _INVITE_LINK_PLAIN.search(inner):
return '<a class="tg-link"></a>' return '<a class="tg-link"></a>'
return f'<a class="tg-link">{inner}</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) # Replace HTML links with tracking tags (preserve anchor text)
def _replace_html(match: re.Match[str]) -> str: 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): if _INVITE_LINK_PLAIN.fullmatch(inner):
return '<a class="tg-link"></a>' return '<a class="tg-link"></a>'
return f'<a class="tg-link">{inner}</a>' return f'<a class="tg-link">{inner}</a>'

View File

@@ -15,7 +15,7 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__) 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}}' _INVITE_LINK_PLACEHOLDER = '{{invite_link}}'

View File

@@ -615,6 +615,7 @@ type PlacementOutput struct {
ShortID string `json:"short_id"` ShortID string `json:"short_id"`
Details *PlacementDetails `json:"details,omitempty"` Details *PlacementDetails `json:"details,omitempty"`
PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"` PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"`
CreatedAt string `json:"created_at"`
} }
type PlacementPostOutput struct { type PlacementPostOutput struct {

View File

@@ -21,9 +21,9 @@ type exec struct {
var botUsername string 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 { type lastRender struct {
textHash string textHash string

View File

@@ -37,7 +37,7 @@ func main() {
if me, err := api.GetMe(); err != nil { if me, err := api.GetMe(); err != nil {
log.Error().Err(err).Msg("Failed to get bot profile") log.Error().Err(err).Msg("Failed to get bot profile")
} else if me.Result != nil { } 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") log.Info().Str("username", me.Result.Username).Msg("Telegram bot authorized")
} }

View File

@@ -2,47 +2,40 @@ package screens
import ( import (
"fmt" "fmt"
"strings"
"github.com/NicoNex/echotron/v3" "github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot" "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 { type AddProject struct {
BackState bot.State BackState bot.State
} }
const addProjectInstructionText = `
<b>Как добавить канал в проект</b>
Для подключения канала к боту, назначьте @%s администратором канала, выдав следующие права:
➔ Управление сообщениями
➔ Добавление участников
После добавления бота:
• Если у вас <b>1 рабочее пространство</b> — канал добавится автоматически
• Если у вас <b>несколько рабочих пространств</b> — вы получите уведомление с кнопкой выбора
`
func (s *AddProject) Enter(b *bot.Bot, mode bot.RenderMode) { func (s *AddProject) Enter(b *bot.Bot, mode bot.RenderMode) {
botUsername := bot.BotUsername() botUsername := strings.TrimPrefix(bot.Username(), "@")
if botUsername == "" {
botUsername = "chat_cruiser_bot"
}
addBotURL := fmt.Sprintf("https://t.me/%s?startchannel&admin=invite_users+post_messages+edit_messages+delete_messages", botUsername) addBotURL := fmt.Sprintf("https://t.me/%s?startchannel&admin=invite_users+post_messages+edit_messages+delete_messages", botUsername)
buttons := [][]echotron.InlineKeyboardButton{ buttons := [][]echotron.InlineKeyboardButton{
Row(URLButton("Добавить", addBotURL)), Row(URLButton("Назначить администратором", addBotURL)),
Row(Button("← Назад", "back")), Row(Button("← Назад", "back")),
} }
keyboard := Keyboard(buttons...) 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) { 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() {} 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 inviteLinkPattern = regexp.MustCompile(`https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+`)
var inviteLinkTagPattern = regexp.MustCompile(`<a\s+class="tg-link">\s*(.*?)\s*</a>`) var inviteLinkTagPattern = regexp.MustCompile(`<a\s+class="tg-link">\s*(.*?)\s*</a>`)
var htmlTagPattern = regexp.MustCompile(`<[^>]+>`)
const ( const (
inputModeText = "text" inputModeText = "text"
@@ -193,7 +192,7 @@ func (e *CreativeEditorFields) GetCreativeText() string {
sub := inviteLinkTagPattern.FindStringSubmatch(match) sub := inviteLinkTagPattern.FindStringSubmatch(match)
inner := "" inner := ""
if len(sub) > 1 { if len(sub) > 1 {
inner = strings.TrimSpace(htmlTagPattern.ReplaceAllString(sub[1], "")) inner = strings.TrimSpace(sub[1])
} }
if inner == "" { if inner == "" {
inner = inviteLinkPreviewURL inner = inviteLinkPreviewURL

View File

@@ -9,6 +9,7 @@ import (
"github.com/NicoNex/echotron/v3" "github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/backend" "github.com/TelegramExchange/tgex-backend/tg_bot/backend"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot" "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
) )
type PlacementDetails struct { type PlacementDetails struct {
@@ -247,16 +248,16 @@ func formatDateTime(value string) string {
// Try parsing with timezone // Try parsing with timezone
if parsed, err = time.Parse(time.RFC3339, value); err == nil { 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 { 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 { if parsed, err = time.ParseInLocation("2006-01-02T15:04:05", value, ui.MskLocation); err == nil {
return formatCompactDateTime(parsed) return formatCompactDateTime(parsed.In(ui.MskLocation))
} }
if parsed, err = time.Parse("2006-01-02", value); err == nil { if parsed, err = time.ParseInLocation("2006-01-02", value, ui.MskLocation); err == nil {
return formatCompactDateTime(parsed) return formatCompactDateTime(parsed.In(ui.MskLocation))
} }
return value 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)) buttons := make([]echotron.InlineKeyboardButton, 0, len(page.Items))
for i, placement := range page.Items { for i, placement := range page.Items {
// Обратный глобальный номер (последнее = #1, первое = #N) // Нумерация по порядку создания: первое размещение = #1 (на последней странице),
// последнее размещение = #N (вверху).
globalNum := page.Total - (s.CurrentPage * placementsPerPage) - i 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) dateStr = formatDate(*placement.Details.PlacementAt)
} }
// Текст кнопки: #N • Канал (с пробелом и разделителем) // Текст кнопки: #N • Канал • DD.MM
buttonText := fmt.Sprintf("%d • %s", globalNum, channelName) buttonText := fmt.Sprintf("#%d • %s", globalNum, channelName)
if dateStr != "" { if dateStr != "" {
buttonText += fmt.Sprintf(" %s", dateStr) buttonText += fmt.Sprintf(" %s", dateStr)
} }
buttons = append(buttons, echotron.InlineKeyboardButton{ buttons = append(buttons, echotron.InlineKeyboardButton{
@@ -225,22 +226,21 @@ func (s *PlacementsList) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *PlacementsList) Exit() {} func (s *PlacementsList) Exit() {}
// formatDate парсит ISO дату и форматирует как "27 янв" // formatDate парсит ISO дату и форматирует как "27.01"
func formatDate(isoDate string) string { func formatDate(isoDate string) string {
// Пробуем парсить RFC3339 // Пробуем парсить RFC3339
t, err := time.Parse(time.RFC3339, isoDate) t, err := time.Parse(time.RFC3339, isoDate)
if err != nil { 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 { if err != nil {
return "" return ""
} }
} }
}
// Русские названия месяцев return t.In(ui.MskLocation).Format("02.01")
months := []string{"янв", "фев", "мар", "апр", "мая", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"}
day := t.Day()
month := months[t.Month()-1]
return fmt.Sprintf("%d %s", day, month)
} }

View File

@@ -147,6 +147,7 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update)
Title: "Дата оплаты", Title: "Дата оплаты",
Key: "payment_date", Key: "payment_date",
IncludeTime: false, IncludeTime: false,
AllowPast: true,
Selected: s.PaymentDate, Selected: s.PaymentDate,
BackState: s, BackState: s,
}), bot.EditMessage) }), bot.EditMessage)
@@ -508,7 +509,11 @@ func (s *PurchaseOptionalDetails) costTypeLabel() string {
} }
func (s *PurchaseOptionalDetails) costBeforeTypeLabel() 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 "СРМ"
} }
return "Фикс" return "Фикс"
@@ -1144,6 +1149,7 @@ func (s *PurchaseOptionalDetails) openCommonEditor(b *bot.Bot, param string) {
Title: "Дата и время размещения", Title: "Дата и время размещения",
Key: "placement_datetime", Key: "placement_datetime",
IncludeTime: true, IncludeTime: true,
AllowPast: true,
Selected: s.PlacementDateTime, Selected: s.PlacementDateTime,
BackState: s, BackState: s,
}), bot.EditMessage) }), bot.EditMessage)
@@ -1154,6 +1160,7 @@ func (s *PurchaseOptionalDetails) openCommonEditor(b *bot.Bot, param string) {
Title: "Дата оплаты", Title: "Дата оплаты",
Key: "payment_date", Key: "payment_date",
IncludeTime: false, IncludeTime: false,
AllowPast: true,
Selected: s.PaymentDate, Selected: s.PaymentDate,
BackState: s, BackState: s,
}), bot.EditMessage) }), bot.EditMessage)
@@ -1201,6 +1208,7 @@ func (s *PurchaseOptionalDetails) openChannelEditor(b *bot.Bot, param, channelKe
Title: "Дата и время размещения", Title: "Дата и время размещения",
Key: "placement_datetime:" + channelKey, Key: "placement_datetime:" + channelKey,
IncludeTime: true, IncludeTime: true,
AllowPast: true,
Selected: s.PlacementByChannel[channelKey], Selected: s.PlacementByChannel[channelKey],
BackState: s, BackState: s,
}), bot.EditMessage) }), bot.EditMessage)
@@ -1210,6 +1218,7 @@ func (s *PurchaseOptionalDetails) openChannelEditor(b *bot.Bot, param, channelKe
Title: "Дата оплаты", Title: "Дата оплаты",
Key: "payment_date:" + channelKey, Key: "payment_date:" + channelKey,
IncludeTime: false, IncludeTime: false,
AllowPast: true,
Selected: s.PaymentDateByChannel[channelKey], Selected: s.PaymentDateByChannel[channelKey],
BackState: s, BackState: s,
}), bot.EditMessage) }), bot.EditMessage)
@@ -2093,13 +2102,13 @@ func (s *PurchaseOptionalDetails) toggleCostType() {
func (s *PurchaseOptionalDetails) toggleCostBeforeType() { func (s *PurchaseOptionalDetails) toggleCostBeforeType() {
if s.CurrentChannel == "" { if s.CurrentChannel == "" {
if s.CostBeforeBargain == nil {
s.CostBeforeBargain = &CostEntry{}
}
if s.costBeforeTypeLabel() == "СРМ" { if s.costBeforeTypeLabel() == "СРМ" {
s.CostBeforeBargain.Type = "fixed" s.CostBeforeType = "fixed"
} else { } else {
s.CostBeforeBargain.Type = "cpm" s.CostBeforeType = "cpm"
}
if s.CostBeforeBargain != nil {
s.CostBeforeBargain.Type = s.CostBeforeType
} }
return return
} }

View File

@@ -24,6 +24,7 @@ type DateTimePickerConfig struct {
Title string Title string
Key string Key string
IncludeTime bool IncludeTime bool
AllowPast bool
Selected *time.Time Selected *time.Time
BackState bot.State BackState bot.State
} }
@@ -36,6 +37,7 @@ type DateTimePicker struct {
Title string Title string
Key string Key string
IncludeTime bool IncludeTime bool
AllowPast bool
BackState bot.State BackState bot.State
view string view string
@@ -63,6 +65,7 @@ func NewDateTimePicker(cfg DateTimePickerConfig) *DateTimePicker {
Title: cfg.Title, Title: cfg.Title,
Key: cfg.Key, Key: cfg.Key,
IncludeTime: cfg.IncludeTime, IncludeTime: cfg.IncludeTime,
AllowPast: cfg.AllowPast,
BackState: cfg.BackState, BackState: cfg.BackState,
view: pickerViewCalendar, view: pickerViewCalendar,
year: year, year: year,
@@ -208,7 +211,7 @@ func (s *DateTimePicker) calendarKeyboard() echotron.InlineKeyboardMarkup {
for day := 1; day <= daysInMonth; day++ { for day := 1; day <= daysInMonth; day++ {
date := time.Date(s.year, s.month, day, 0, 0, 0, 0, MskLocation) 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"}) row = append(row, echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"})
} else { } else {
label := fmt.Sprintf("%d", day) label := fmt.Sprintf("%d", day)
@@ -462,6 +465,9 @@ func (s *DateTimePicker) minuteButton(minute int) echotron.InlineKeyboardButton
} }
func (s *DateTimePicker) isHourDisabled(hour int) bool { func (s *DateTimePicker) isHourDisabled(hour int) bool {
if s.AllowPast {
return false
}
if s.selectedDate == nil { if s.selectedDate == nil {
return false return false
} }
@@ -475,6 +481,9 @@ func (s *DateTimePicker) isHourDisabled(hour int) bool {
} }
func (s *DateTimePicker) isMinuteDisabled(minute int) bool { func (s *DateTimePicker) isMinuteDisabled(minute int) bool {
if s.AllowPast {
return false
}
if s.selectedDate == nil || s.selectedHour == nil { if s.selectedDate == nil || s.selectedHour == nil {
return false return false
} }
@@ -491,6 +500,16 @@ func (s *DateTimePicker) isMinuteDisabled(minute int) bool {
} }
func (s *DateTimePicker) prevMonth() { 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) now := time.Now().In(MskLocation)
if s.year == now.Year() && s.month == now.Month() { if s.year == now.Year() && s.month == now.Month() {
return return
@@ -513,6 +532,10 @@ func (s *DateTimePicker) nextMonth() {
} }
func (s *DateTimePicker) monthPrevButton() echotron.InlineKeyboardButton { func (s *DateTimePicker) monthPrevButton() echotron.InlineKeyboardButton {
if s.AllowPast {
return echotron.InlineKeyboardButton{Text: "←", CallbackData: dtpCallbackPrefix + "month_prev"}
}
now := time.Now().In(MskLocation) now := time.Now().In(MskLocation)
if s.year == now.Year() && s.month == now.Month() { if s.year == now.Year() && s.month == now.Month() {
return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"} 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) input := strings.TrimSpace(u.Message.Text)
switch s.view { switch s.view {
case pickerViewCalendar: 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 { if !hasDate {
s.Enter(b, bot.NewMessage) s.Enter(b, bot.NewMessage)
return return
@@ -573,7 +596,7 @@ func (s *DateTimePicker) HandleMessage(b *bot.Bot, u *echotron.Update) {
} }
s.Enter(b, bot.NewMessage) s.Enter(b, bot.NewMessage)
case pickerViewTimeCombined: 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 { if !hasTime {
s.Enter(b, bot.NewMessage) s.Enter(b, bot.NewMessage)
return return
@@ -587,7 +610,7 @@ func (s *DateTimePicker) HandleMessage(b *bot.Bot, u *echotron.Update) {
s.selectedMinute = &minute s.selectedMinute = &minute
s.Enter(b, bot.NewMessage) s.Enter(b, bot.NewMessage)
default: 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 { if !hasDate && !hasTime {
s.Enter(b, bot.NewMessage) s.Enter(b, bot.NewMessage)
return return
@@ -611,6 +634,7 @@ func parseDateTimeInput(
currentDate *time.Time, currentDate *time.Time,
currentHour *int, currentHour *int,
currentMinute *int, currentMinute *int,
allowPast bool,
) (time.Time, bool, bool) { ) (time.Time, bool, bool) {
text := strings.ToLower(strings.TrimSpace(input)) text := strings.ToLower(strings.TrimSpace(input))
text = strings.ReplaceAll(text, ",", " ") text = strings.ReplaceAll(text, ",", " ")
@@ -637,7 +661,7 @@ func parseDateTimeInput(
} }
value := time.Date(date.Year(), date.Month(), date.Day(), hour, minute, 0, 0, MskLocation) 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 time.Time{}, false, false
} }
return value, dateOk, timeOk return value, dateOk, timeOk