639 lines
21 KiB
Go
639 lines
21 KiB
Go
package screens
|
||
|
||
import (
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
|
||
"example.com/m/bot"
|
||
"github.com/NicoNex/echotron/v3"
|
||
"github.com/rs/zerolog/log"
|
||
)
|
||
|
||
// InlineButton представляет кнопку с URL
|
||
type InlineButton struct {
|
||
Text string
|
||
URL string
|
||
}
|
||
|
||
// CreativeEditorFields содержит общие поля и методы для создания/редактирования креатива
|
||
type CreativeEditorFields struct {
|
||
// Данные креатива
|
||
Name *string
|
||
Text *string
|
||
Buttons []InlineButton
|
||
|
||
// Медиа
|
||
MediaType string // "photo", "video", "animation"
|
||
MediaFileID string // File ID из Telegram
|
||
MediaChanged bool
|
||
|
||
// Состояние для добавления кнопки
|
||
WaitingForButtonText bool
|
||
WaitingForButtonURL bool
|
||
CurrentButtonText *string
|
||
WaitingForMedia bool
|
||
PendingButtonType string
|
||
|
||
// ID сообщения с креативом (которое постоянно обновляется)
|
||
CreativeMessageID *int
|
||
// ID сообщения с панелью управления
|
||
ControlPanelMessageID *int
|
||
|
||
// Предыдущее состояние для определения изменения типа сообщения
|
||
previousHasMedia bool
|
||
}
|
||
|
||
const inviteLinkPlaceholder = "{{invite_link}}"
|
||
const inviteLinkPreviewURL = "https://t.me/+nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра
|
||
|
||
// GetCreativeText возвращает текст для превью креатива
|
||
func (e *CreativeEditorFields) GetCreativeText() string {
|
||
var result string
|
||
|
||
if e.Text != nil && *e.Text != "" {
|
||
text := strings.TrimSpace(*e.Text)
|
||
|
||
// Проверяем что текст не пустой
|
||
if text == "" {
|
||
log.Info().Str("text", *e.Text).Msg("GetCreativeText: text is empty, using default")
|
||
return result + `<i>💭 Я твой креатив
|
||
|
||
У меня пока нет текста, добавь его ниже.
|
||
|
||
</i>`
|
||
}
|
||
|
||
// Заменяем пригласительные ссылки с классом tg-link на preview-ссылку
|
||
// Находим все вхождения <a class="tg-link">...</a> и заменяем на кликабельную ссылку
|
||
replacementCount := 0
|
||
for strings.Contains(text, `<a class="tg-link">`) {
|
||
startIdx := strings.Index(text, `<a class="tg-link">`)
|
||
if startIdx == -1 {
|
||
break
|
||
}
|
||
endIdx := strings.Index(text[startIdx:], "</a>")
|
||
if endIdx == -1 {
|
||
log.Warn().Msg("Found <a class=\"tg-link\"> but no closing </a>")
|
||
break
|
||
}
|
||
endIdx += startIdx
|
||
|
||
// Извлекаем текст между тегами
|
||
linkText := text[startIdx+len(`<a class="tg-link">`) : endIdx]
|
||
log.Info().
|
||
Int("replacement_num", replacementCount).
|
||
Str("linkText_before_sanitize", linkText).
|
||
Msg("Processing tg-link")
|
||
|
||
// ВАЖНО: Удаляем все HTML-теги из текста ссылки для предотвращения неправильной вложенности
|
||
// Это нужно для старых креативов с неправильным HTML
|
||
tagsRemoved := 0
|
||
for {
|
||
openTag := strings.Index(linkText, "<")
|
||
if openTag == -1 {
|
||
break
|
||
}
|
||
closeTag := strings.Index(linkText[openTag:], ">")
|
||
if closeTag == -1 {
|
||
log.Warn().Str("linkText", linkText).Msg("Found < but no closing >")
|
||
break
|
||
}
|
||
// Удаляем тег
|
||
removedTag := linkText[openTag : openTag+closeTag+1]
|
||
linkText = linkText[:openTag] + linkText[openTag+closeTag+1:]
|
||
tagsRemoved++
|
||
log.Info().
|
||
Str("removed_tag", removedTag).
|
||
Str("linkText_after", linkText).
|
||
Int("tags_removed_total", tagsRemoved).
|
||
Msg("Removed HTML tag from link text")
|
||
}
|
||
|
||
linkText = strings.TrimSpace(linkText)
|
||
|
||
// Если текст ссылки не пустой, добавляем атрибут href с примером ссылки
|
||
// Если пустой, показываем просто текст примера ссылки (без тега)
|
||
var replacement string
|
||
if linkText != "" {
|
||
// Есть текст - добавляем href атрибут к тегу
|
||
replacement = fmt.Sprintf(`<a class="tg-link" href="%s">%s</a>`, inviteLinkPreviewURL, linkText)
|
||
} else {
|
||
// Пусто - показываем просто текст примера ссылки
|
||
replacement = fmt.Sprintf(`%s`, inviteLinkPreviewURL)
|
||
}
|
||
|
||
log.Info().
|
||
Str("linkText_after_sanitize", linkText).
|
||
Int("tags_removed", tagsRemoved).
|
||
Str("replacement", replacement).
|
||
Msg("Sanitized link text")
|
||
|
||
text = text[:startIdx] + replacement + text[endIdx+len("</a>"):]
|
||
replacementCount++
|
||
}
|
||
|
||
log.Info().
|
||
Int("replacements_made", replacementCount).
|
||
Str("final_text", text).
|
||
Msg("Finished processing tg-links")
|
||
|
||
// Также заменяем плейсхолдер {{invite_link}} на preview URL (как в кнопках)
|
||
text = strings.ReplaceAll(text, inviteLinkPlaceholder, inviteLinkPreviewURL)
|
||
|
||
log.Info().Str("text", *e.Text).Msg("GetCreativeText: returning creative text")
|
||
return result + text
|
||
}
|
||
log.Info().Bool("text_is_nil", e.Text == nil).Msg("GetCreativeText: returning default text")
|
||
return result + `<i>💭 Я твой креатив
|
||
|
||
У меня пока нет текста, добавь его ниже.
|
||
|
||
</i>`
|
||
}
|
||
|
||
// GetCreativeButtons возвращает кнопки для превью креатива
|
||
func (e *CreativeEditorFields) GetCreativeButtons() [][]echotron.InlineKeyboardButton {
|
||
var buttons [][]echotron.InlineKeyboardButton
|
||
|
||
for _, btn := range e.Buttons {
|
||
url := btn.URL
|
||
if url == inviteLinkPlaceholder {
|
||
url = inviteLinkPreviewURL
|
||
}
|
||
buttons = append(buttons, []echotron.InlineKeyboardButton{
|
||
{
|
||
Text: btn.Text,
|
||
URL: url,
|
||
},
|
||
})
|
||
}
|
||
|
||
return buttons
|
||
}
|
||
|
||
// SendCreativePreview отправляет превью креатива
|
||
func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
|
||
creativeText := e.GetCreativeText()
|
||
creativeButtons := e.GetCreativeButtons()
|
||
|
||
// Создаем клавиатуру с пустым массивом (не nil!)
|
||
keyboard := echotron.InlineKeyboardMarkup{
|
||
InlineKeyboard: creativeButtons,
|
||
}
|
||
if len(creativeButtons) == 0 {
|
||
keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{}
|
||
}
|
||
|
||
var msgID int
|
||
|
||
// Если есть медиа, отправляем с медиа
|
||
if e.MediaFileID != "" {
|
||
var res echotron.APIResponseMessage
|
||
var err error
|
||
|
||
switch e.MediaType {
|
||
case "photo":
|
||
res, err = b.SendPhoto(
|
||
echotron.NewInputFileID(e.MediaFileID),
|
||
b.ChatID,
|
||
&echotron.PhotoOptions{
|
||
Caption: creativeText,
|
||
ParseMode: echotron.HTML,
|
||
ReplyMarkup: keyboard,
|
||
},
|
||
)
|
||
case "video":
|
||
res, err = b.SendVideo(
|
||
echotron.NewInputFileID(e.MediaFileID),
|
||
b.ChatID,
|
||
&echotron.VideoOptions{
|
||
Caption: creativeText,
|
||
ParseMode: echotron.HTML,
|
||
ReplyMarkup: keyboard,
|
||
},
|
||
)
|
||
case "animation":
|
||
res, err = b.SendAnimation(
|
||
echotron.NewInputFileID(e.MediaFileID),
|
||
b.ChatID,
|
||
&echotron.AnimationOptions{
|
||
Caption: creativeText,
|
||
ParseMode: echotron.HTML,
|
||
ReplyMarkup: keyboard,
|
||
},
|
||
)
|
||
}
|
||
|
||
if err != nil {
|
||
log.Error().Err(err).Str("media_type", e.MediaType).Msg("Failed to send media")
|
||
// Fallback to text message - используем прямой SendMessage
|
||
res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{
|
||
ReplyMarkup: keyboard,
|
||
ParseMode: echotron.HTML,
|
||
LinkPreviewOptions: echotron.LinkPreviewOptions{
|
||
IsDisabled: true,
|
||
},
|
||
})
|
||
if err == nil && res.Result != nil {
|
||
msgID = res.Result.ID
|
||
}
|
||
} else if res.Result != nil {
|
||
msgID = res.Result.ID
|
||
}
|
||
} else {
|
||
// Отправляем обычное текстовое сообщение напрямую (не через SendNew)
|
||
// чтобы не затронуть LastMessageID и не затронуть cleanup
|
||
res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{
|
||
ReplyMarkup: keyboard,
|
||
ParseMode: echotron.HTML,
|
||
LinkPreviewOptions: echotron.LinkPreviewOptions{
|
||
IsDisabled: true,
|
||
},
|
||
})
|
||
if err != nil {
|
||
log.Error().Err(err).Msg("Failed to send creative preview")
|
||
return
|
||
}
|
||
if res.Result != nil {
|
||
msgID = res.Result.ID
|
||
}
|
||
}
|
||
|
||
// ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
|
||
e.CreativeMessageID = &msgID
|
||
e.previousHasMedia = e.MediaFileID != ""
|
||
log.Info().Int("creative_msg_id", *e.CreativeMessageID).Bool("has_media", e.MediaFileID != "").Msg("Creative preview sent")
|
||
}
|
||
|
||
// UpdateCreativePreview обновляет превью креатива
|
||
func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
|
||
if e.CreativeMessageID == nil {
|
||
log.Warn().Msg("UpdateCreativePreview: CreativeMessageID is nil")
|
||
// Если превью еще не создано, создаем его
|
||
e.SendCreativePreview(b)
|
||
return
|
||
}
|
||
|
||
currentHasMedia := e.MediaFileID != ""
|
||
|
||
log.Info().
|
||
Int("creative_msg_id", *e.CreativeMessageID).
|
||
Bool("has_media", currentHasMedia).
|
||
Bool("previous_has_media", e.previousHasMedia).
|
||
Str("media_type", e.MediaType).
|
||
Msg("Updating creative preview")
|
||
|
||
// Если тип сообщения изменился (текст ↔ медиа), нужно пересоздать оба сообщения
|
||
if currentHasMedia != e.previousHasMedia {
|
||
log.Info().Msg("Message type changed, recreating both messages")
|
||
|
||
// Удаляем оба сообщения
|
||
if e.CreativeMessageID != nil {
|
||
b.DeleteMessage(b.ChatID, *e.CreativeMessageID)
|
||
e.CreativeMessageID = nil
|
||
}
|
||
if e.ControlPanelMessageID != nil {
|
||
b.DeleteMessage(b.ChatID, *e.ControlPanelMessageID)
|
||
e.ControlPanelMessageID = nil
|
||
}
|
||
|
||
// Создаем превью заново (панель будет создана через ShowManagementPanel после этого)
|
||
e.SendCreativePreview(b)
|
||
return
|
||
}
|
||
|
||
// Тип не изменился - просто редактируем на месте
|
||
creativeText := e.GetCreativeText()
|
||
creativeButtons := e.GetCreativeButtons()
|
||
|
||
keyboard := echotron.InlineKeyboardMarkup{
|
||
InlineKeyboard: creativeButtons,
|
||
}
|
||
if len(creativeButtons) == 0 {
|
||
keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{}
|
||
}
|
||
|
||
msgID := echotron.NewMessageID(b.ChatID, *e.CreativeMessageID)
|
||
|
||
// Если есть медиа - редактируем caption
|
||
if e.MediaFileID != "" {
|
||
_, err := b.EditMessageCaption(
|
||
msgID,
|
||
&echotron.MessageCaptionOptions{
|
||
Caption: creativeText,
|
||
ParseMode: echotron.HTML,
|
||
ReplyMarkup: keyboard,
|
||
},
|
||
)
|
||
if err != nil {
|
||
log.Error().Err(err).Msg("Failed to edit media caption")
|
||
} else {
|
||
e.previousHasMedia = currentHasMedia
|
||
}
|
||
} else {
|
||
// Текстовое сообщение - редактируем текст
|
||
_, err := b.EditMessageText(
|
||
creativeText,
|
||
msgID,
|
||
&echotron.MessageTextOptions{
|
||
ParseMode: echotron.HTML,
|
||
ReplyMarkup: keyboard,
|
||
LinkPreviewOptions: echotron.LinkPreviewOptions{
|
||
IsDisabled: true,
|
||
},
|
||
},
|
||
)
|
||
if err != nil {
|
||
log.Error().Err(err).Msg("Failed to edit message text")
|
||
} else {
|
||
e.previousHasMedia = currentHasMedia
|
||
}
|
||
}
|
||
}
|
||
|
||
// ShowManagementPanel показывает панель управления креативом
|
||
// confirmButtonText - текст кнопки подтверждения (например "✓ Сохранить")
|
||
// confirmCallback - callback для кнопки подтверждения
|
||
// cancelCallback - callback для кнопки отмены
|
||
// extraButtons - дополнительные кнопки которые будут добавлены перед финальными кнопками
|
||
func (e *CreativeEditorFields) ShowManagementPanel(b *bot.Bot, confirmButtonText, confirmCallback, cancelCallback string, extraButtons ...[]echotron.InlineKeyboardButton) {
|
||
text := `<b>👆Превью креатива выше</b> — так он будет выглядеть при отправке.
|
||
|
||
Выберите, что изменить:`
|
||
|
||
var buttons [][]echotron.InlineKeyboardButton
|
||
|
||
// Блок редактирования контента
|
||
buttons = append(buttons, bot.Row(
|
||
bot.Button("✎ Текст", "edit_text"),
|
||
bot.Button("+ Медиа", "add_media"),
|
||
))
|
||
// Добавление кнопки сразу под основным блоком
|
||
buttons = append(buttons, bot.Row(bot.Button("+ Добавить кнопку", "add_button")))
|
||
|
||
// Кнопка удаления медиа (если медиа добавлено)
|
||
if e.MediaFileID != "" {
|
||
buttons = append(buttons, bot.Row(
|
||
bot.Button("⌫ Убрать медиа", "delete_media"),
|
||
))
|
||
}
|
||
|
||
// Кнопки удаления (если есть кнопки)
|
||
if len(e.Buttons) > 0 {
|
||
var row []echotron.InlineKeyboardButton
|
||
for i, btn := range e.Buttons {
|
||
row = append(row, bot.Button(
|
||
fmt.Sprintf(`⌫ Убрать «%s»`, btn.Text),
|
||
fmt.Sprintf("delete_button:%d", i),
|
||
))
|
||
|
||
if len(row) == 2 {
|
||
buttons = append(buttons, row)
|
||
row = nil
|
||
}
|
||
}
|
||
|
||
if len(row) > 0 {
|
||
buttons = append(buttons, row)
|
||
}
|
||
}
|
||
|
||
// Дополнительные кнопки (например "Удалить креатив")
|
||
for _, extraRow := range extraButtons {
|
||
buttons = append(buttons, extraRow)
|
||
}
|
||
|
||
// Финальные кнопки
|
||
if e.Text != nil {
|
||
buttons = append(buttons, bot.Row(
|
||
bot.Button("← Назад", cancelCallback),
|
||
bot.Button(confirmButtonText, confirmCallback),
|
||
))
|
||
} else {
|
||
buttons = append(buttons, bot.Row(
|
||
bot.Button("← Назад", cancelCallback),
|
||
))
|
||
}
|
||
|
||
e.ShowControlPanel(b, text, buttons)
|
||
}
|
||
|
||
// ShowControlPanel редактирует панель управления с произвольным текстом и кнопками
|
||
func (e *CreativeEditorFields) ShowControlPanel(b *bot.Bot, text string, buttons [][]echotron.InlineKeyboardButton) {
|
||
if e.ControlPanelMessageID == nil {
|
||
// Отправляем новую панель
|
||
keyboard := bot.Keyboard(buttons...)
|
||
b.SendNew(text, keyboard)
|
||
// ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
|
||
controlPanelMsgID := b.LastMessageID
|
||
e.ControlPanelMessageID = &controlPanelMsgID
|
||
log.Info().Int("control_panel_msg_id", *e.ControlPanelMessageID).Msg("Control panel sent")
|
||
} else {
|
||
// Редактируем существующую панель
|
||
log.Info().Int("control_panel_msg_id", *e.ControlPanelMessageID).Msg("Editing control panel")
|
||
|
||
// Временно заменяем LastMessageID на ID панели управления
|
||
oldLastMessageID := b.LastMessageID
|
||
b.LastMessageID = *e.ControlPanelMessageID
|
||
|
||
keyboard := bot.Keyboard(buttons...)
|
||
b.Edit(text, keyboard)
|
||
|
||
// Восстанавливаем оригинальный LastMessageID
|
||
b.LastMessageID = oldLastMessageID
|
||
log.Info().Msg("Control panel updated")
|
||
}
|
||
}
|
||
|
||
// GetFormattedText извлекает форматированный текст из сообщения с entities
|
||
func (e *CreativeEditorFields) GetFormattedText(message *echotron.Message) string {
|
||
// Определяем откуда брать текст и entities
|
||
var text string
|
||
var entities []*echotron.MessageEntity
|
||
|
||
if message.Text != "" {
|
||
text = message.Text
|
||
entities = message.Entities
|
||
} else if message.Caption != "" {
|
||
text = message.Caption
|
||
entities = message.CaptionEntities
|
||
}
|
||
|
||
if text == "" {
|
||
return ""
|
||
}
|
||
|
||
// Если нет entities - возвращаем текст как есть
|
||
if len(entities) == 0 {
|
||
return text
|
||
}
|
||
|
||
type tagSpan struct {
|
||
open string
|
||
close string
|
||
start int
|
||
end int
|
||
len int
|
||
}
|
||
|
||
// Преобразуем текст в руны для правильной работы с UTF-8
|
||
runes := []rune(text)
|
||
|
||
// Сначала находим все url entities, чтобы исключить перекрывающиеся форматирования
|
||
urlRanges := make([][2]int, 0)
|
||
// Также отмечаем диапазоны, которые нужно полностью исключить из текста (простые url ссылки)
|
||
skipRanges := make(map[[2]int]bool)
|
||
|
||
for _, entity := range entities {
|
||
if entity.Type == "url" || entity.Type == "text_link" {
|
||
urlRanges = append(urlRanges, [2]int{entity.Offset, entity.Offset + entity.Length})
|
||
// Для простых url (не text_link) с пригласительными ссылками - пропускаем текст URL
|
||
// Создаем пустой <a class="tg-link"></a>, а сам URL не сохраняем
|
||
if entity.Type == "url" {
|
||
entityText := ""
|
||
if entity.Offset+entity.Length <= len(runes) {
|
||
entityText = string(runes[entity.Offset : entity.Offset+entity.Length])
|
||
}
|
||
if strings.Contains(entityText, "t.me/+") || strings.Contains(entityText, "t.me/joinchat/") {
|
||
skipRanges[[2]int{entity.Offset, entity.Offset + entity.Length}] = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Проверяем, находится ли entity внутри URL
|
||
isInsideURL := func(offset, length int) bool {
|
||
for _, urlRange := range urlRanges {
|
||
// Если entity полностью внутри URL (но не сам URL)
|
||
if offset > urlRange[0] && offset+length <= urlRange[1] {
|
||
return true
|
||
}
|
||
// Если entity пересекается с URL
|
||
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 {
|
||
entityText := ""
|
||
if entity.Offset+entity.Length <= len(runes) {
|
||
entityText = string(runes[entity.Offset : entity.Offset+entity.Length])
|
||
}
|
||
|
||
// Пропускаем форматирование (bold, italic, etc), если оно находится внутри URL
|
||
if entity.Type != "url" && entity.Type != "text_link" && isInsideURL(entity.Offset, entity.Length) {
|
||
continue
|
||
}
|
||
|
||
var openTag, closeTag string
|
||
switch entity.Type {
|
||
case "bold":
|
||
openTag, closeTag = "<b>", "</b>"
|
||
case "italic":
|
||
openTag, closeTag = "<i>", "</i>"
|
||
case "underline":
|
||
openTag, closeTag = "<u>", "</u>"
|
||
case "strikethrough":
|
||
openTag, closeTag = "<s>", "</s>"
|
||
case "code":
|
||
openTag, closeTag = "<code>", "</code>"
|
||
case "pre":
|
||
openTag, closeTag = "<pre>", "</pre>"
|
||
case "url":
|
||
// Конвертируем простые URL в HTML-ссылки
|
||
// Для пригласительных ссылок (t.me/+ или t.me/joinchat/) используем пустой тег
|
||
if strings.Contains(entityText, "t.me/+") || strings.Contains(entityText, "t.me/joinchat/") {
|
||
// Пригласительная ссылка - создаем пустой <a class="tg-link"></a>
|
||
// Текст ссылки будет пропущен при рендеринге (см. skipRanges)
|
||
openTag = `<a class="tg-link">`
|
||
closeTag = "</a>"
|
||
} else {
|
||
// Обычная ссылка - оставляем как есть (ссылка на себя)
|
||
openTag = fmt.Sprintf("<a href=\"%s\">", entityText)
|
||
closeTag = "</a>"
|
||
}
|
||
case "text_link":
|
||
if entity.URL != "" {
|
||
// Проверяем, является ли URL пригласительной ссылкой
|
||
if strings.Contains(entity.URL, "t.me/+") || strings.Contains(entity.URL, "t.me/joinchat/") {
|
||
openTag = `<a class="tg-link">`
|
||
closeTag = "</a>"
|
||
} else {
|
||
openTag = fmt.Sprintf("<a href=\"%s\">", entity.URL)
|
||
closeTag = "</a>"
|
||
}
|
||
}
|
||
default:
|
||
_ = entityText
|
||
}
|
||
|
||
if openTag != "" && closeTag != "" {
|
||
spans = append(spans, tagSpan{
|
||
open: openTag,
|
||
close: closeTag,
|
||
start: entity.Offset,
|
||
end: entity.Offset + entity.Length,
|
||
len: entity.Length,
|
||
})
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// Функция для проверки, находится ли позиция в skipRanges
|
||
isInSkipRange := func(pos int) bool {
|
||
for skipRange := range skipRanges {
|
||
if pos >= skipRange[0] && pos < skipRange[1] {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Пишем текст один раз, вставляя HTML-теги по позициям
|
||
// и экранируя HTML-спецсимволы
|
||
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) {
|
||
// Пропускаем символы, которые находятся в skipRanges (текст пригласительных ссылок)
|
||
if isInSkipRange(i) {
|
||
continue
|
||
}
|
||
// Экранируем HTML-спецсимволы
|
||
switch runes[i] {
|
||
case '<':
|
||
result.WriteString("<")
|
||
case '>':
|
||
result.WriteString(">")
|
||
case '&':
|
||
result.WriteString("&")
|
||
default:
|
||
result.WriteRune(runes[i])
|
||
}
|
||
}
|
||
}
|
||
|
||
return result.String()
|
||
}
|