From 2c0dcbef97f1f33a613dc859ea6a5db3a81b9740 Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Wed, 21 Jan 2026 12:16:02 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=B2=D0=B0=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/domain/creative.py | 27 +++++--- src/domain/post.py | 13 +++- tg_bot/bot/bot.go | 6 +- tg_bot/screens/creative_editor.go | 63 ++++++------------- tg_bot/screens/ui/message_format.go | 36 ++--------- .../adapter/database/get_tracked_posts.go | 1 + tg_parser/internal/domain/post.go | 2 + 7 files changed, 59 insertions(+), 89 deletions(-) diff --git a/src/domain/creative.py b/src/domain/creative.py index 5d7e5d4..061b760 100644 --- a/src/domain/creative.py +++ b/src/domain/creative.py @@ -41,15 +41,16 @@ MAX_CREATIVE_MEDIA_BYTES = 20 * 1024 * 1024 _INVITE_LINK_HTML = re.compile( - r'([^<]*)', - re.IGNORECASE, + r'(.*?)', + re.IGNORECASE | re.DOTALL, ) _INVITE_LINK_PLAIN = re.compile(r'https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+', re.IGNORECASE) -_INVITE_LINK_REPLACED = re.compile(r'([^<]*)', re.IGNORECASE) -_INVITE_LINK_REPLACED_URL = re.compile( - r'\s*https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+\s*', - re.IGNORECASE, -) +_INVITE_LINK_REPLACED = re.compile(r'(.*?)', 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: @@ -70,14 +71,20 @@ def replace_invite_link_with_tag(text: str) -> str: if total > 1: raise CreativeMultipleInviteLinks() - # Если ссылка уже заменена, нормализуем текст если внутри был URL + # Если ссылка уже заменена, нормализуем текст если внутри был URL или теги if replaced_count == 1: - return _INVITE_LINK_REPLACED_URL.sub('', text) + def _normalize_replaced(match: re.Match[str]) -> str: + inner = _strip_html(match.group(1)) + if inner == "" or _INVITE_LINK_PLAIN.search(inner): + return '' + return f'{inner}' + + return _INVITE_LINK_REPLACED.sub(_normalize_replaced, text) if html_count == 1: def _replace_html(match: re.Match[str]) -> str: - inner = match.group(1).strip() + inner = _strip_html(match.group(1)) if _INVITE_LINK_PLAIN.fullmatch(inner): return '' return f'{inner}' diff --git a/src/domain/post.py b/src/domain/post.py index 6ff74dd..c867d0c 100644 --- a/src/domain/post.py +++ b/src/domain/post.py @@ -27,6 +27,15 @@ class Post(TimestampedModel): @property def url(self) -> str | None: - if not self.channel.username: + if self.channel.username: + return f'https://t.me/{self.channel.username}/{self.message_id}' + + telegram_id = self.channel.telegram_id + if telegram_id is None: return None - return f'https://t.me/{self.channel.username}/{self.message_id}' + + channel_id = telegram_id + if telegram_id < 0: + channel_id = -telegram_id - 1000000000000 + + return f'https://t.me/c/{channel_id}/{self.message_id}' diff --git a/tg_bot/bot/bot.go b/tg_bot/bot/bot.go index ef0fbb5..7831e66 100644 --- a/tg_bot/bot/bot.go +++ b/tg_bot/bot/bot.go @@ -321,7 +321,11 @@ func (b *Bot) Update(u *echotron.Update) { b.exec = e defer func() { b.exec = nil }() - if b.CurrentState == nil { + isStartCommand := u.Message != nil && (u.Message.Text == "/start" || + u.Message.Text == "/start login" || + strings.HasPrefix(u.Message.Text, "/start project_")) + + if b.CurrentState == nil && !isStartCommand { b.SetState(b.commandRouter("/start"), EditMessage) } diff --git a/tg_bot/screens/creative_editor.go b/tg_bot/screens/creative_editor.go index 2c5a749..79cffca 100644 --- a/tg_bot/screens/creative_editor.go +++ b/tg_bot/screens/creative_editor.go @@ -2,6 +2,7 @@ package screens import ( "fmt" + "regexp" "strings" "github.com/NicoNex/echotron/v3" @@ -49,6 +50,10 @@ const inviteLinkPlaceholder = "{{invite_link}}" const inviteLinkPreviewURL = "https://t.me/+nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра const buttonURLPlaceholder = "https://example.com" +var inviteLinkPattern = regexp.MustCompile(`https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+`) +var inviteLinkTagPattern = regexp.MustCompile(`\s*(.*?)\s*`) +var htmlTagPattern = regexp.MustCompile(`<[^>]+>`) + const ( inputModeText = "text" inputModeButtonText = "button_text" @@ -90,55 +95,23 @@ func (e *CreativeEditorFields) GetCreativeText() string { return msgCreativeDefault } - // Заменяем пригласительные ссылки с классом tg-link на preview-ссылку - // Находим все вхождения ... и заменяем на кликабельную ссылку - for strings.Contains(text, ``) { - startIdx := strings.Index(text, ``) - if startIdx == -1 { - break + // Заменяем плейсхолдеры tg-link на кликабельную preview-ссылку. + text = inviteLinkTagPattern.ReplaceAllStringFunc(text, func(match string) string { + sub := inviteLinkTagPattern.FindStringSubmatch(match) + inner := "" + if len(sub) > 1 { + inner = strings.TrimSpace(htmlTagPattern.ReplaceAllString(sub[1], "")) } - endIdx := strings.Index(text[startIdx:], "") - if endIdx == -1 { - log.Warn().Msg("Found but no closing ") - break + if inner == "" { + inner = inviteLinkPreviewURL } - endIdx += startIdx + return fmt.Sprintf(`%s`, inviteLinkPreviewURL, inner) + }) - // Извлекаем текст между тегами - linkText := text[startIdx+len(``) : endIdx] - // ВАЖНО: Удаляем все HTML-теги из текста ссылки для предотвращения неправильной вложенности - // Это нужно для старых креативов с неправильным HTML - 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 - } - // Удаляем тег - linkText = linkText[:openTag] + linkText[openTag+closeTag+1:] - } + // Заменяем пригласительные ссылки на preview URL только для отображения. + text = inviteLinkPattern.ReplaceAllString(text, inviteLinkPreviewURL) - linkText = strings.TrimSpace(linkText) - - // Если текст ссылки не пустой, добавляем атрибут href с примером ссылки - // Если пустой, показываем просто текст примера ссылки (без тега) - var replacement string - if linkText != "" { - // Есть текст - добавляем href атрибут к тегу - replacement = fmt.Sprintf(`%s`, inviteLinkPreviewURL, linkText) - } else { - // Пусто - показываем просто текст примера ссылки - replacement = fmt.Sprintf(`%s`, inviteLinkPreviewURL) - } - - text = text[:startIdx] + replacement + text[endIdx+len(""):] - } - - // Также заменяем плейсхолдер {{invite_link}} на preview URL (как в кнопках) + // Также заменяем плейсхолдер {{invite_link}} на preview URL (как в кнопках). text = strings.ReplaceAll(text, inviteLinkPlaceholder, inviteLinkPreviewURL) return text diff --git a/tg_bot/screens/ui/message_format.go b/tg_bot/screens/ui/message_format.go index 123847f..17003f1 100644 --- a/tg_bot/screens/ui/message_format.go +++ b/tg_bot/screens/ui/message_format.go @@ -2,7 +2,6 @@ package ui import ( "fmt" - "regexp" "sort" "strings" @@ -24,7 +23,6 @@ var simpleHTMLTags = map[echotron.MessageEntityType]htmlTag{ } // FormatMessageHTML converts Telegram entities to HTML and escapes the rest. -// Invite links are normalized into placeholders for previews. func FormatMessageHTML(message *echotron.Message) string { if message == nil { return "" @@ -138,22 +136,12 @@ func FormatMessageHTML(message *echotron.Message) string { openTag, closeTag = tag.open, tag.close } else if entity.Type == echotron.UrlEntity { entityText := string(runes[start:end]) - if isInviteLink(entityText) { - openTag = `` - closeTag = "" - } else { - openTag = fmt.Sprintf("", entityText) - closeTag = "" - } + openTag = fmt.Sprintf("", entityText) + closeTag = "" } else if entity.Type == echotron.TextLinkEntity { if entity.URL != "" { - if isInviteLink(entity.URL) { - openTag = `` - closeTag = "" - } else { - openTag = fmt.Sprintf("", entity.URL) - closeTag = "" - } + openTag = fmt.Sprintf("", entity.URL) + closeTag = "" } } @@ -204,12 +192,7 @@ func FormatMessageHTML(message *echotron.Message) string { } } - return normalizeInviteLinkTags(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/") + return normalizeHTMLTags(result.String()) } func normalizeHTMLTags(input string) string { @@ -291,15 +274,6 @@ func normalizeHTMLTags(input string) string { return out.String() } -func normalizeInviteLinkTags(input string) string { - if input == "" { - return input - } - - re := regexp.MustCompile(`\s*https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+\s*`) - return re.ReplaceAllString(input, ``) -} - // EscapeHTML escapes HTML special characters for safe output. func EscapeHTML(s string) string { s = strings.ReplaceAll(s, "&", "&") diff --git a/tg_parser/internal/adapter/database/get_tracked_posts.go b/tg_parser/internal/adapter/database/get_tracked_posts.go index b3b0234..f78fd0d 100644 --- a/tg_parser/internal/adapter/database/get_tracked_posts.go +++ b/tg_parser/internal/adapter/database/get_tracked_posts.go @@ -18,6 +18,7 @@ func (d *Database) GetTrackedPosts(ctx context.Context, channel domain.Channel) p.text, CASE WHEN c.username IS NOT NULL THEN CONCAT('https://t.me/', c.username, '/', p.message_id) + WHEN c.telegram_id IS NOT NULL THEN CONCAT('https://t.me/c/', (-c.telegram_id - 1000000000000), '/', p.message_id) ELSE '' END as link, COALESCE( diff --git a/tg_parser/internal/domain/post.go b/tg_parser/internal/domain/post.go index e7eea11..8e6f90d 100644 --- a/tg_parser/internal/domain/post.go +++ b/tg_parser/internal/domain/post.go @@ -19,6 +19,8 @@ func NewPost(channel Channel, messageID int, text string, views int) Post { link := "" if channel.Username != "" { link = fmt.Sprintf("https://t.me/%s/%d", channel.Username, messageID) + } else if channel.TelegramID != 0 { + link = fmt.Sprintf("https://t.me/c/%d/%d", ChannelIDFromChatID(channel.TelegramID), messageID) } return Post{ ID: uuid.New(),