package ui import ( "fmt" "regexp" "strings" "time" "github.com/NicoNex/echotron/v3" "github.com/TelegramExchange/tgex-backend/tg_bot/bot" ) const ( pickerViewCalendar = "calendar" pickerViewMonths = "months" pickerViewTimeCombined = "time_combined" pickerViewConfirm = "confirm" dtpCallbackPrefix = "dtp:" ) var MskLocation = time.FixedZone("MSK", 3*60*60) type DateTimePickerConfig struct { Title string Key string IncludeTime bool Selected *time.Time BackState bot.State } type DateTimeSelectionTarget interface { SetDateTimeSelection(key string, value time.Time) } type DateTimePicker struct { Title string Key string IncludeTime bool BackState bot.State view string year int month time.Month selectedDate *time.Time selectedHour *int selectedMinute *int } func NewDateTimePicker(cfg DateTimePickerConfig) *DateTimePicker { now := time.Now().In(MskLocation) year := now.Year() month := now.Month() var selected *time.Time if cfg.Selected != nil { value := cfg.Selected.In(MskLocation) selected = &value year = value.Year() month = value.Month() } return &DateTimePicker{ Title: cfg.Title, Key: cfg.Key, IncludeTime: cfg.IncludeTime, BackState: cfg.BackState, view: pickerViewCalendar, year: year, month: month, selectedDate: selected, } } func (s *DateTimePicker) Enter(b *bot.Bot, mode bot.RenderMode) { text := fmt.Sprintf("%s\n\n", s.Title) text += fmt.Sprintf("%s\n\n", s.formatSelectedDateTime()) switch s.view { case pickerViewCalendar: text += s.renderCalendarTitle() b.Render(text, s.calendarKeyboard(), mode) case pickerViewMonths: text += "Выберите месяц\n\n" b.Render(text, s.monthsKeyboard(), mode) case pickerViewTimeCombined: b.Render(text, s.timeCombinedKeyboard(), mode) case pickerViewConfirm: text += s.renderSelectedDateTime() b.Render(text, s.confirmKeyboard(), mode) } } func (s *DateTimePicker) HandleCallback(b *bot.Bot, u *echotron.Update) { if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { return } data := u.CallbackQuery.Data if data == "back" { s.handleBack(b) return } if data == "cancel" { if s.BackState != nil { b.SetState(s.BackState, bot.EditMessage) } return } if !strings.HasPrefix(data, dtpCallbackPrefix) { s.Enter(b, bot.EditMessage) return } payload := strings.TrimPrefix(data, dtpCallbackPrefix) switch { case payload == "month_prev": s.prevMonth() case payload == "month_next": s.nextMonth() case payload == "open_months": s.view = pickerViewMonths case payload == "year_prev": s.year-- case payload == "year_next": s.year++ case strings.HasPrefix(payload, "month:"): month := parseInt(strings.TrimPrefix(payload, "month:")) if month >= 1 && month <= 12 { s.month = time.Month(month) s.view = pickerViewCalendar } case strings.HasPrefix(payload, "day:"): s.handleDaySelect(payload) case strings.HasPrefix(payload, "hour:"): s.handleHourSelect(payload) case strings.HasPrefix(payload, "min:"): s.handleMinuteSelect(payload) case payload == "confirm": s.confirmSelection(b) return } s.Enter(b, bot.EditMessage) } func (s *DateTimePicker) handleBack(b *bot.Bot) { switch s.view { case pickerViewMonths: s.view = pickerViewCalendar case pickerViewTimeCombined: s.view = pickerViewCalendar case pickerViewConfirm: if s.IncludeTime { s.view = pickerViewTimeCombined } else { s.view = pickerViewCalendar } default: if s.BackState != nil { b.SetState(s.BackState, bot.EditMessage) return } } s.Enter(b, bot.EditMessage) } func (s *DateTimePicker) renderCalendarTitle() string { if s.year == time.Now().In(MskLocation).Year() { return "\n" } return fmt.Sprintf("%d\n\n", s.year) } func (s *DateTimePicker) calendarKeyboard() echotron.InlineKeyboardMarkup { var rows [][]echotron.InlineKeyboardButton rows = append(rows, []echotron.InlineKeyboardButton{ s.monthPrevButton(), {Text: monthName(s.month), CallbackData: dtpCallbackPrefix + "open_months"}, {Text: "→", CallbackData: dtpCallbackPrefix + "month_next"}, }) rows = append(rows, []echotron.InlineKeyboardButton{ {Text: "Пн", CallbackData: "empty"}, {Text: "Вт", CallbackData: "empty"}, {Text: "Ср", CallbackData: "empty"}, {Text: "Чт", CallbackData: "empty"}, {Text: "Пт", CallbackData: "empty"}, {Text: "Сб", CallbackData: "empty"}, {Text: "Вс", CallbackData: "empty"}, }) firstOfMonth := time.Date(s.year, s.month, 1, 0, 0, 0, 0, MskLocation) weekday := int(firstOfMonth.Weekday()) if weekday == 0 { weekday = 7 } daysInMonth := daysInMonth(s.year, s.month) today := time.Now().In(MskLocation) todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation) var row []echotron.InlineKeyboardButton for i := 1; i < weekday; i++ { row = append(row, echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"}) } for day := 1; day <= daysInMonth; day++ { date := time.Date(s.year, s.month, day, 0, 0, 0, 0, MskLocation) if date.Before(todayDate) { row = append(row, echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"}) } else { label := fmt.Sprintf("%d", day) row = append(row, echotron.InlineKeyboardButton{ Text: label, CallbackData: fmt.Sprintf("%sday:%04d-%02d-%02d", dtpCallbackPrefix, s.year, int(s.month), day), }) } if len(row) == 7 { rows = append(rows, row) row = nil } } if len(row) > 0 { for len(row) < 7 { row = append(row, echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"}) } rows = append(rows, row) } rows = append(rows, []echotron.InlineKeyboardButton{ {Text: "← Назад", CallbackData: "back"}, {Text: "Отмена", CallbackData: "cancel"}, }) return echotron.InlineKeyboardMarkup{InlineKeyboard: rows} } func (s *DateTimePicker) monthsKeyboard() echotron.InlineKeyboardMarkup { var rows [][]echotron.InlineKeyboardButton months := []string{"Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"} for i := 0; i < 12; i += 3 { rows = append(rows, []echotron.InlineKeyboardButton{ {Text: months[i], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+1)}, {Text: months[i+1], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+2)}, {Text: months[i+2], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+3)}, }) } rows = append(rows, []echotron.InlineKeyboardButton{ {Text: "← Назад", CallbackData: "back"}, }) return echotron.InlineKeyboardMarkup{InlineKeyboard: rows} } func (s *DateTimePicker) timeCombinedKeyboard() echotron.InlineKeyboardMarkup { var rows [][]echotron.InlineKeyboardButton rows = append(rows, []echotron.InlineKeyboardButton{{Text: "Часы", CallbackData: "empty"}}) for i := 0; i < 24; i += 6 { rows = append(rows, []echotron.InlineKeyboardButton{ s.hourButton(i), s.hourButton(i + 1), s.hourButton(i + 2), s.hourButton(i + 3), s.hourButton(i + 4), s.hourButton(i + 5), }) } rows = append(rows, []echotron.InlineKeyboardButton{{Text: "Минуты", CallbackData: "empty"}}) minutes := []int{0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55} for i := 0; i < len(minutes); i += 6 { rows = append(rows, []echotron.InlineKeyboardButton{ s.minuteButton(minutes[i]), s.minuteButton(minutes[i+1]), s.minuteButton(minutes[i+2]), s.minuteButton(minutes[i+3]), s.minuteButton(minutes[i+4]), s.minuteButton(minutes[i+5]), }) } rows = append(rows, []echotron.InlineKeyboardButton{ {Text: "← Назад", CallbackData: "back"}, {Text: "Готово", CallbackData: dtpCallbackPrefix + "confirm"}, }) return echotron.InlineKeyboardMarkup{InlineKeyboard: rows} } func (s *DateTimePicker) confirmKeyboard() echotron.InlineKeyboardMarkup { return echotron.InlineKeyboardMarkup{ InlineKeyboard: [][]echotron.InlineKeyboardButton{ { {Text: "← Назад", CallbackData: "back"}, {Text: "Готово", CallbackData: dtpCallbackPrefix + "confirm"}, }, }, } } func (s *DateTimePicker) renderSelectedDateTime() string { if s.selectedDate == nil { return "—" } hour := 0 minute := 0 if s.selectedHour != nil { hour = *s.selectedHour } if s.selectedMinute != nil { minute = *s.selectedMinute } value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, MskLocation) return fmt.Sprintf("%s", formatCompactDateTime(value)) } func (s *DateTimePicker) formatTimePreview(showPlaceholders bool) string { hour := "__" minute := "__" if s.selectedHour != nil { hour = fmt.Sprintf("%02d", *s.selectedHour) } else if !showPlaceholders { hour = "00" } if s.selectedMinute != nil { minute = fmt.Sprintf("%02d", *s.selectedMinute) } else if !showPlaceholders { minute = "00" } return fmt.Sprintf("%s:%s", hour, minute) } func (s *DateTimePicker) formatSelectedDateTime() string { displayDate, ok := s.displayDate() if !ok { return "—" } timeLabel := "--:--" if s.selectedHour != nil && s.selectedMinute != nil { timeLabel = fmt.Sprintf("%02d:%02d", *s.selectedHour, *s.selectedMinute) } weekday := WeekdayName(displayDate.Weekday()) month := MonthShort(displayDate.Month()) return fmt.Sprintf("%s %02d %s %s %dг.", weekday, displayDate.Day(), month, timeLabel, displayDate.Year()) } func (s *DateTimePicker) displayDate() (time.Time, bool) { if s.selectedDate == nil { if s.view == pickerViewCalendar || s.view == pickerViewMonths { return time.Date(s.year, s.month, 1, 0, 0, 0, 0, MskLocation), true } return time.Time{}, false } if (s.view == pickerViewCalendar || s.view == pickerViewMonths) && (s.selectedDate.Year() != s.year || s.selectedDate.Month() != s.month) { day := s.selectedDate.Day() maxDay := daysInMonth(s.year, s.month) if day > maxDay { day = maxDay } return time.Date(s.year, s.month, day, 0, 0, 0, 0, MskLocation), true } return s.selectedDate.In(MskLocation), true } func (s *DateTimePicker) handleDaySelect(payload string) { datePart := strings.TrimPrefix(payload, "day:") parsed, err := time.ParseInLocation("2006-01-02", datePart, MskLocation) if err != nil { return } s.selectedDate = &parsed if s.IncludeTime { s.view = pickerViewTimeCombined s.selectedHour = nil s.selectedMinute = nil } else { s.view = pickerViewConfirm } } func (s *DateTimePicker) handleHourSelect(payload string) { hour := parseInt(strings.TrimPrefix(payload, "hour:")) if hour < 0 || hour > 23 { return } if s.isHourDisabled(hour) { return } s.selectedHour = &hour } func (s *DateTimePicker) handleMinuteSelect(payload string) { minute := parseInt(strings.TrimPrefix(payload, "min:")) if minute < 0 || minute > 59 { return } if s.isMinuteDisabled(minute) { return } s.selectedMinute = &minute } func (s *DateTimePicker) confirmSelection(b *bot.Bot) { if s.selectedDate == nil { s.Enter(b, bot.EditMessage) return } if s.IncludeTime && (s.selectedHour == nil || s.selectedMinute == nil) { s.view = pickerViewTimeCombined s.Enter(b, bot.EditMessage) return } hour := 0 minute := 0 if s.selectedHour != nil { hour = *s.selectedHour } if s.selectedMinute != nil { minute = *s.selectedMinute } value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, MskLocation) if target, ok := s.BackState.(DateTimeSelectionTarget); ok { target.SetDateTimeSelection(s.Key, value) } if s.BackState != nil { b.SetState(s.BackState, bot.EditMessage) } } func (s *DateTimePicker) hourButton(hour int) echotron.InlineKeyboardButton { label := fmt.Sprintf("%02d", hour) if s.isHourDisabled(hour) { return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"} } if s.selectedHour != nil && *s.selectedHour == hour { label = "● " + label } return echotron.InlineKeyboardButton{ Text: label, CallbackData: fmt.Sprintf("%shour:%02d", dtpCallbackPrefix, hour), } } func (s *DateTimePicker) minuteButton(minute int) echotron.InlineKeyboardButton { label := fmt.Sprintf("%02d", minute) if s.isMinuteDisabled(minute) { return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"} } if s.selectedMinute != nil && *s.selectedMinute == minute { label = "● " + label } return echotron.InlineKeyboardButton{ Text: label, CallbackData: fmt.Sprintf("%smin:%02d", dtpCallbackPrefix, minute), } } func (s *DateTimePicker) isHourDisabled(hour int) bool { if s.selectedDate == nil { return false } today := time.Now().In(MskLocation) date := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), 0, 0, 0, 0, MskLocation) todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation) if date.After(todayDate) { return false } return hour < today.Hour() } func (s *DateTimePicker) isMinuteDisabled(minute int) bool { if s.selectedDate == nil || s.selectedHour == nil { return false } today := time.Now().In(MskLocation) date := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), 0, 0, 0, 0, MskLocation) todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation) if date.After(todayDate) { return false } if *s.selectedHour > today.Hour() { return false } return minute < today.Minute() } func (s *DateTimePicker) prevMonth() { now := time.Now().In(MskLocation) if s.year == now.Year() && s.month == now.Month() { return } if s.month == time.January { s.month = time.December s.year-- } else { s.month-- } } func (s *DateTimePicker) nextMonth() { if s.month == time.December { s.month = time.January s.year++ } else { s.month++ } } func (s *DateTimePicker) monthPrevButton() echotron.InlineKeyboardButton { now := time.Now().In(MskLocation) if s.year == now.Year() && s.month == now.Month() { return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"} } return echotron.InlineKeyboardButton{Text: "←", CallbackData: dtpCallbackPrefix + "month_prev"} } func daysInMonth(year int, month time.Month) int { return time.Date(year, month+1, 0, 0, 0, 0, 0, MskLocation).Day() } func monthName(month time.Month) string { names := []string{ "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь", } if int(month) < 1 || int(month) > len(names) { return "" } return names[int(month)-1] } func parseInt(value string) int { result := 0 for _, r := range value { if r < '0' || r > '9' { return 0 } result = result*10 + int(r-'0') } return result } func (s *DateTimePicker) HandleMessage(b *bot.Bot, u *echotron.Update) { if u.Message == nil || strings.TrimSpace(u.Message.Text) == "" { return } input := strings.TrimSpace(u.Message.Text) switch s.view { case pickerViewCalendar: value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute) if !hasDate { s.Enter(b, bot.NewMessage) return } s.selectedDate = &value if hasTime { hour := value.Hour() minute := value.Minute() s.selectedHour = &hour s.selectedMinute = &minute } if s.IncludeTime { s.view = pickerViewTimeCombined } else { s.view = pickerViewConfirm } s.Enter(b, bot.NewMessage) case pickerViewTimeCombined: value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute) if !hasTime { s.Enter(b, bot.NewMessage) return } if hasDate { s.selectedDate = &value } hour := value.Hour() minute := value.Minute() s.selectedHour = &hour s.selectedMinute = &minute s.Enter(b, bot.NewMessage) default: value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute) if !hasDate && !hasTime { s.Enter(b, bot.NewMessage) return } s.selectedDate = &value hour := value.Hour() minute := value.Minute() s.selectedHour = &hour s.selectedMinute = &minute s.view = pickerViewConfirm s.Enter(b, bot.NewMessage) } } func (s *DateTimePicker) Handle(_ *bot.Bot, _ *echotron.Update) { return } func (s *DateTimePicker) Exit() {} func parseDateTimeInput( input string, currentDate *time.Time, currentHour *int, currentMinute *int, ) (time.Time, bool, bool) { text := strings.ToLower(strings.TrimSpace(input)) text = strings.ReplaceAll(text, ",", " ") text = strings.ReplaceAll(text, " ", " ") now := time.Now().In(MskLocation) baseDate := now if currentDate != nil { baseDate = currentDate.In(MskLocation) } date, dateOk := parseDate(text, baseDate) hour, minute, timeOk := parseTime(text, currentHour, currentMinute) if !dateOk && !timeOk { return time.Time{}, false, false } if !dateOk { date = time.Date(baseDate.Year(), baseDate.Month(), baseDate.Day(), 0, 0, 0, 0, MskLocation) } if !timeOk { hour = 0 minute = 0 } value := time.Date(date.Year(), date.Month(), date.Day(), hour, minute, 0, 0, MskLocation) if value.Before(now) { return time.Time{}, false, false } return value, dateOk, timeOk } func parseDate(text string, baseDate time.Time) (time.Time, bool) { if strings.Contains(text, "сегодня") { return baseDate, true } if strings.Contains(text, "завтра") { return baseDate.AddDate(0, 0, 1), true } if strings.Contains(text, "послезавтра") { return baseDate.AddDate(0, 0, 2), true } if date := parseNumericDate(text); !date.IsZero() { return date, true } if date := parseWordDate(text); !date.IsZero() { return date, true } return time.Time{}, false } func parseNumericDate(text string) time.Time { reYMD := regexp.MustCompile(`\b(\d{4})[./-](\d{1,2})[./-](\d{1,2})\b`) if match := reYMD.FindStringSubmatch(text); len(match) == 4 { year := parseInt(match[1]) month := parseInt(match[2]) day := parseInt(match[3]) return safeDate(year, month, day) } reDMY := regexp.MustCompile(`\b(\d{1,2})[./-](\d{1,2})(?:[./-](\d{2,4}))?\b`) if match := reDMY.FindStringSubmatch(text); len(match) >= 3 { day := parseInt(match[1]) month := parseInt(match[2]) year := time.Now().In(MskLocation).Year() if len(match) == 4 && match[3] != "" { year = parseInt(match[3]) if year < 100 { year += 2000 } } return safeDate(year, month, day) } return time.Time{} } func parseWordDate(text string) time.Time { monthMap := map[string]time.Month{ "янв": 1, "январ": 1, "января": 1, "фев": 2, "феврал": 2, "февраля": 2, "мар": 3, "марта": 3, "апр": 4, "апрел": 4, "апреля": 4, "май": 5, "мая": 5, "июн": 6, "июня": 6, "июл": 7, "июля": 7, "авг": 8, "август": 8, "августа": 8, "сен": 9, "сент": 9, "сентябр": 9, "сентября": 9, "окт": 10, "октябр": 10, "октября": 10, "ноя": 11, "ноябр": 11, "ноября": 11, "дек": 12, "декабр": 12, "декабря": 12, } re := regexp.MustCompile(`\b(\d{1,2})\s+([a-zа-яё]+)(?:\s+(\d{2,4}))?\b`) match := re.FindStringSubmatch(text) if len(match) == 0 { return time.Time{} } day := parseInt(match[1]) monthToken := match[2] var month time.Month for key, value := range monthMap { if strings.HasPrefix(monthToken, key) { month = value break } } if month == 0 { return time.Time{} } year := time.Now().In(MskLocation).Year() if len(match) == 4 && match[3] != "" { year = parseInt(match[3]) if year < 100 { year += 2000 } } return safeDate(year, int(month), day) } func parseTime(text string, currentHour *int, currentMinute *int) (int, int, bool) { re := regexp.MustCompile(`\b(\d{1,2})[:.](\d{2})\b`) if match := re.FindStringSubmatch(text); len(match) == 3 { hour := parseInt(match[1]) minute := parseInt(match[2]) if hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 { return hour, minute, true } } reCompact := regexp.MustCompile(`\b(\d{1,2})\s+(\d{2})\b`) if match := reCompact.FindStringSubmatch(text); len(match) == 3 { hour := parseInt(match[1]) minute := parseInt(match[2]) if hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 { return hour, minute, true } } reHour := regexp.MustCompile(`\b(\d{1,2})\s*(?:ч|час|часов)\b`) if match := reHour.FindStringSubmatch(text); len(match) == 2 { hour := parseInt(match[1]) if hour >= 0 && hour <= 23 { minute := 0 if currentMinute != nil { minute = *currentMinute } return hour, minute, true } } reJustHour := regexp.MustCompile(`\b(\d{1,2})\b`) if match := reJustHour.FindStringSubmatch(text); len(match) == 2 && strings.Contains(text, "в") { hour := parseInt(match[1]) if hour >= 0 && hour <= 23 { minute := 0 if currentMinute != nil { minute = *currentMinute } return hour, minute, true } } if currentHour != nil && currentMinute != nil { return *currentHour, *currentMinute, true } return 0, 0, false } func safeDate(year, month, day int) time.Time { if month < 1 || month > 12 { return time.Time{} } if day < 1 || day > 31 { return time.Time{} } return time.Date(year, time.Month(month), day, 0, 0, 0, 0, MskLocation) } func WeekdayName(day time.Weekday) string { switch day { case time.Monday: return "Пн" case time.Tuesday: return "Вт" case time.Wednesday: return "Ср" case time.Thursday: return "Чт" case time.Friday: return "Пт" case time.Saturday: return "Сб" case time.Sunday: return "Вс" default: return "" } } func formatCompactDateTime(value time.Time) string { weekday := WeekdayName(value.Weekday()) month := MonthShort(value.Month()) return fmt.Sprintf("%s %02d %s %s", weekday, value.Day(), month, value.Format("15:04")) } func MonthShort(month time.Month) string { months := []string{ "янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек", } if int(month) < 1 || int(month) > len(months) { return "" } return months[int(month)-1] }