package ui import ( "fmt" "regexp" "strings" "time" "github.com/NicoNex/echotron/v3" "github.com/TelegramExchange/tgex-backend/tg_bot/bot" "github.com/olebedev/when" "github.com/olebedev/when/rules/common" "github.com/olebedev/when/rules/ru" ) 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 AllowPast 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 AllowPast 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, AllowPast: cfg.AllowPast, 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, b) return // confirmSelection уже делает Enter 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 !s.AllowPast && 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, b *bot.Bot) { 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 s.Enter(b, bot.EditMessage) } else { // Сразу подтверждаем выбор для даты без времени s.confirmSelection(b) } } 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.AllowPast { return false } 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.AllowPast { return false } 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() { 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 } 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 { 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"} } 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, s.AllowPast) 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, s.AllowPast) 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, s.AllowPast) 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() {} var whenParser *when.Parser func init() { whenParser = when.New(nil) whenParser.Add(ru.All...) whenParser.Add(common.All...) } var ( timeIndicator = regexp.MustCompile(`\d{1,2}[:.]\d{2}|утр|вечер|днём|ночь|час|минут|полдень|полночь`) dateIndicator = regexp.MustCompile(`\d{1,2}[./-]\d|сегодня|завтра|вчера|послезавтра|понедельн|вторник|сред[уыа]|четверг|пятниц|суббот|воскресен|янв|фев|мар|апр|ма[йя]|июн|июл|авг|сен|окт|ноя|дек|через.*дн|через.*недел|через.*месяц|через.*год`) ) func detectTime(matched string) bool { return timeIndicator.MatchString(matched) } func detectDate(matched string) bool { return dateIndicator.MatchString(matched) } func parseDateTimeInput( input string, _ *time.Time, _ *int, _ *int, allowPast bool, ) (time.Time, bool, bool) { now := time.Now().In(MskLocation) r, err := whenParser.Parse(input, now) if err != nil || r == nil { return time.Time{}, false, false } result := r.Time.In(MskLocation) matched := strings.ToLower(r.Text) hasDate := detectDate(matched) hasTime := detectTime(matched) if !allowPast && result.Before(now) { return time.Time{}, false, false } return result, hasDate, hasTime } 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] }