This commit is contained in:
Artem Tsyrulnikov
2026-01-18 13:59:47 +03:00
parent d8202e1141
commit 62e8f98429
62 changed files with 156 additions and 190 deletions

View File

@@ -0,0 +1,829 @@
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("<b>%s</b>\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("<b>%s</b>", 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]
}

View File

@@ -0,0 +1,304 @@
package ui
import (
"fmt"
"sort"
"strings"
"github.com/NicoNex/echotron/v3"
)
type htmlTag struct {
open string
close string
}
var simpleHTMLTags = map[echotron.MessageEntityType]htmlTag{
echotron.BoldEntity: {open: "<b>", close: "</b>"},
echotron.ItalicEntity: {open: "<i>", close: "</i>"},
echotron.UnderlineEntity: {open: "<u>", close: "</u>"},
echotron.StrikethroughEntity: {open: "<s>", close: "</s>"},
echotron.CodeEntity: {open: "<code>", close: "</code>"},
echotron.PreEntity: {open: "<pre>", close: "</pre>"},
}
// FormatMessageHTML converts Telegram entities to HTML and escapes the rest.
// Invite links are normalized into <a class="tg-link"> placeholders for previews.
func FormatMessageHTML(message *echotron.Message) string {
if message == nil {
return ""
}
// Telegram sends formatting as entities, so we rebuild the HTML from offsets.
text := message.Text
entities := message.Entities
if text == "" {
text = message.Caption
entities = message.CaptionEntities
}
if text == "" {
return ""
}
if len(entities) == 0 {
return EscapeHTML(text)
}
type tagSpan struct {
open string
close string
start int
end int
len int
}
runes := []rune(text)
positions := make([]int, len(runes)+1)
utf16Count := 0
for i, r := range runes {
positions[i] = utf16Count
if r > 0xFFFF {
utf16Count += 2
} else {
utf16Count++
}
}
positions[len(runes)] = utf16Count
utf16ToRuneIndex := func(utf16Index int) (int, bool) {
i := sort.Search(len(positions), func(i int) bool { return positions[i] >= utf16Index })
if i < len(positions) && positions[i] == utf16Index {
return i, true
}
return 0, false
}
toRuneRange := func(offset, length int) (int, int, bool) {
start, ok := utf16ToRuneIndex(offset)
if !ok {
return 0, 0, false
}
end, ok := utf16ToRuneIndex(offset + length)
if !ok || end > len(runes) || end < start {
return 0, 0, false
}
return start, end, true
}
// URL ranges are used to prevent nested tags inside links.
urlRanges := make([][2]int, 0, len(entities))
for _, entity := range entities {
if entity == nil {
continue
}
if entity.Type != echotron.UrlEntity && entity.Type != echotron.TextLinkEntity {
continue
}
start, end, ok := toRuneRange(entity.Offset, entity.Length)
if !ok {
continue
}
urlRanges = append(urlRanges, [2]int{start, end})
if entity.Type == echotron.UrlEntity {
_ = string(runes[start:end])
}
}
isInsideURL := func(offset, length int) bool {
for _, urlRange := range urlRanges {
if offset > urlRange[0] && offset+length <= urlRange[1] {
return true
}
if offset < urlRange[1] && offset+length > urlRange[0] && (offset != urlRange[0] || length != urlRange[1]-urlRange[0]) {
return true
}
}
return false
}
var spans []tagSpan
for _, entity := range entities {
if entity == nil {
continue
}
start, end, ok := toRuneRange(entity.Offset, entity.Length)
if !ok {
continue
}
if entity.Type != echotron.UrlEntity && entity.Type != echotron.TextLinkEntity && isInsideURL(start, end-start) {
continue
}
var openTag, closeTag string
if tag, ok := simpleHTMLTags[entity.Type]; ok {
openTag, closeTag = tag.open, tag.close
} else if entity.Type == echotron.UrlEntity {
entityText := string(runes[start:end])
if isInviteLink(entityText) {
openTag = `<a class="tg-link">`
closeTag = "</a>"
} else {
openTag = fmt.Sprintf("<a href=\"%s\">", entityText)
closeTag = "</a>"
}
} else if entity.Type == echotron.TextLinkEntity {
if entity.URL != "" {
if isInviteLink(entity.URL) {
openTag = `<a class="tg-link">`
closeTag = "</a>"
} else {
openTag = fmt.Sprintf("<a href=\"%s\">", entity.URL)
closeTag = "</a>"
}
}
}
if openTag != "" && closeTag != "" {
spans = append(spans, tagSpan{
open: openTag,
close: closeTag,
start: start,
end: end,
len: end - start,
})
}
}
opens := make(map[int][]tagSpan)
closes := make(map[int][]tagSpan)
for _, span := range spans {
opens[span.start] = append(opens[span.start], span)
closes[span.end] = append(closes[span.end], span)
}
// Build the final text with tags inserted and HTML escaped.
var result strings.Builder
for i := 0; i <= len(runes); i++ {
if closing, ok := closes[i]; ok {
sort.Slice(closing, func(a, b int) bool { return closing[a].len < closing[b].len })
for _, span := range closing {
result.WriteString(span.close)
}
}
if opening, ok := opens[i]; ok {
sort.Slice(opening, func(a, b int) bool { return opening[a].len > opening[b].len })
for _, span := range opening {
result.WriteString(span.open)
}
}
if i < len(runes) {
switch runes[i] {
case '<':
result.WriteString("&lt;")
case '>':
result.WriteString("&gt;")
case '&':
result.WriteString("&amp;")
default:
result.WriteRune(runes[i])
}
}
}
return normalizeHTMLTags(result.String())
}
// isInviteLink detects Telegram invite links used in creatives.
func isInviteLink(url string) bool {
return strings.Contains(url, "t.me/+") || strings.Contains(url, "t.me/joinchat/")
}
func normalizeHTMLTags(input string) string {
if input == "" {
return input
}
allowed := map[string]bool{
"a": true,
"b": true,
"i": true,
"u": true,
"s": true,
"code": true,
"pre": true,
}
var out strings.Builder
out.Grow(len(input))
stack := make([]string, 0, 8)
for i := 0; i < len(input); {
if input[i] != '<' {
out.WriteByte(input[i])
i++
continue
}
end := strings.IndexByte(input[i:], '>')
if end == -1 {
out.WriteByte(input[i])
i++
continue
}
end += i
tag := input[i+1 : end]
if tag == "" {
out.WriteString(input[i : end+1])
i = end + 1
continue
}
isClosing := tag[0] == '/'
tagName := tag
if isClosing {
tagName = tag[1:]
}
if space := strings.IndexByte(tagName, ' '); space != -1 {
tagName = tagName[:space]
}
tagName = strings.TrimSpace(tagName)
if !allowed[tagName] {
out.WriteString(input[i : end+1])
i = end + 1
continue
}
if isClosing {
if len(stack) > 0 && stack[len(stack)-1] == tagName {
stack = stack[:len(stack)-1]
out.WriteString(input[i : end+1])
}
} else {
stack = append(stack, tagName)
out.WriteString(input[i : end+1])
}
i = end + 1
}
for i := len(stack) - 1; i >= 0; i-- {
out.WriteString("</")
out.WriteString(stack[i])
out.WriteString(">")
}
return out.String()
}
// EscapeHTML escapes HTML special characters for safe output.
func EscapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
// SanitizeHTML normalizes tag nesting to prevent invalid HTML in Telegram parse mode.
func SanitizeHTML(input string) string {
return normalizeHTMLTags(input)
}

View File

@@ -0,0 +1,175 @@
package ui
import (
"fmt"
"github.com/NicoNex/echotron/v3"
)
// PaginationConfig конфигурация для построения навигационного ряда пагинации
type PaginationConfig struct {
CurrentPage int
TotalPages int // Для API пагинации (из page.Pages)
TotalItems int // Для локальной пагинации (len(array))
ItemsPerPage int // Для расчета TotalPages из TotalItems
PrevCallback string // По умолчанию "prev"
NextCallback string // По умолчанию "next"
MiddleButtons []echotron.InlineKeyboardButton // Опциональные кнопки в центре (например, " Добавить")
}
// BuildNavigationRow создает навигационный ряд с динамической шириной.
// Показывает ряд только если страниц > 1.
// Возвращает nil если пагинация не нужна.
func BuildNavigationRow(config PaginationConfig) []echotron.InlineKeyboardButton {
// Вычисляем TotalPages из TotalItems если не задан
totalPages := config.TotalPages
if totalPages == 0 && config.TotalItems > 0 && config.ItemsPerPage > 0 {
totalPages = CalculatePages(config.TotalItems, config.ItemsPerPage)
}
// Устанавливаем значения по умолчанию для callbacks
prevCallback := config.PrevCallback
if prevCallback == "" {
prevCallback = "prev"
}
nextCallback := config.NextCallback
if nextCallback == "" {
nextCallback = "next"
}
var row []echotron.InlineKeyboardButton
// Левая стрелка (только если есть предыдущая страница)
if totalPages > 1 && config.CurrentPage > 0 {
row = append(row, echotron.InlineKeyboardButton{
Text: "←",
CallbackData: prevCallback,
})
}
// Средние кнопки (если есть)
if len(config.MiddleButtons) > 0 {
row = append(row, config.MiddleButtons...)
}
// Правая стрелка (только если есть следующая страница)
if totalPages > 1 && config.CurrentPage+1 < totalPages {
row = append(row, echotron.InlineKeyboardButton{
Text: "→",
CallbackData: nextCallback,
})
}
if len(row) == 0 {
return nil
}
return row
}
// CalculatePages вычисляет количество страниц из общего количества элементов
func CalculatePages(totalItems, itemsPerPage int) int {
if itemsPerPage <= 0 {
return 0
}
return (totalItems + itemsPerPage - 1) / itemsPerPage
}
// GetPageBounds возвращает start/end индексы для текущей страницы.
// Автоматически сбрасывает на первую страницу если currentPage выходит за границы.
func GetPageBounds(currentPage, itemsPerPage, totalItems int) (start, end int) {
start = currentPage * itemsPerPage
if start >= totalItems {
start = 0
}
end = start + itemsPerPage
if end > totalItems {
end = totalItems
}
return start, end
}
// FormatPageInfo форматирует текст индикатора страницы для заголовка
func FormatPageInfo(currentPage, totalPages int) string {
if totalPages <= 1 {
return ""
}
return fmt.Sprintf(" <i>(стр. %d/%d)</i>", currentPage+1, totalPages)
}
// ElementLayoutConfig конфигурация для автоматической раскладки элементов с пагинацией
type ElementLayoutConfig struct {
CurrentPage int // Текущая страница (0-indexed)
ItemsPerPage int // Элементов на странице
ItemsPerRow int // Элементов в одном ряду
EmptyButton *echotron.InlineKeyboardButton // Кнопка-заполнитель (опционально, по умолчанию " ")
}
// BuildElementRows автоматически раскладывает элементы с пагинацией.
// Принимает все элементы, возвращает ряды для текущей страницы с правильной раскладкой.
// Автоматически заполняет пустыми кнопками если страниц > 1 (для консистентности высоты).
// Возвращает 2D массив кнопок готовый к добавлению в клавиатуру.
//
// Пример использования:
//
// config := ui.ElementLayoutConfig{
// CurrentPage: 0,
// ItemsPerPage: 6,
// ItemsPerRow: 2,
// }
// rows := ui.BuildElementRows(config, allButtons)
// keyboard = append(keyboard, rows...)
func BuildElementRows(config ElementLayoutConfig, allItems []echotron.InlineKeyboardButton) [][]echotron.InlineKeyboardButton {
if len(allItems) == 0 {
return nil
}
// Параметры по умолчанию
if config.ItemsPerPage <= 0 {
config.ItemsPerPage = 6
}
if config.ItemsPerRow <= 0 {
config.ItemsPerRow = 2
}
// Кнопка-заполнитель по умолчанию
emptyButton := echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"}
if config.EmptyButton != nil {
emptyButton = *config.EmptyButton
}
// Вычисляем границы страницы
totalPages := CalculatePages(len(allItems), config.ItemsPerPage)
start, end := GetPageBounds(config.CurrentPage, config.ItemsPerPage, len(allItems))
// Получаем элементы текущей страницы
pageItems := allItems[start:end]
// Если страниц >= 2, заполняем до ItemsPerPage пустыми кнопками
// Это обеспечивает одинаковую высоту клавиатуры на всех страницах
if totalPages >= 2 {
for len(pageItems) < config.ItemsPerPage {
pageItems = append(pageItems, emptyButton)
}
}
// Раскладываем элементы по рядам
var rows [][]echotron.InlineKeyboardButton
for i := 0; i < len(pageItems); i += config.ItemsPerRow {
var row []echotron.InlineKeyboardButton
// Добавляем элементы в ряд
for j := 0; j < config.ItemsPerRow && i+j < len(pageItems); j++ {
row = append(row, pageItems[i+j])
}
// Если в ряду меньше элементов чем ItemsPerRow, заполняем пустыми
for len(row) < config.ItemsPerRow {
row = append(row, emptyButton)
}
rows = append(rows, row)
}
return rows
}