owpengram-server/internal/rpc/webpage_url_extract.go

169 lines
7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package rpc
import (
"context"
"regexp"
"strings"
"unicode/utf16"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
"telesrv/internal/links"
)
// urlInTextRe 匹配原始文本里的带 scheme 链接(取到首个空白或尖括号/引号为止)。
// 是否接纳由 detectURLEntities 再按 http(s) 或当前 app-link 配置收口,不能把任意
// foo:// 都提升为服务端认证的可点击实体。
var urlInTextRe = regexp.MustCompile(`(?i)[a-z][a-z0-9+.-]*://[^\s<>"')】]+`)
// urlTrailingPunct 是不属于 URL 的句末标点('/' 是合法路径末尾,保留)。
const urlTrailingPunct = ".,;:!?)]}'\"。,、!?"
// detectURLEntities 服务端扫描消息文本生成 url 高亮实体MessageEntityURL。除 http(s)
// 外,仅接受当前 Router 配置允许的 app-link scheme/host。偏移/长度按 UTF-16 码元
// Telegram 实体口径)。自定义 scheme 只参与 entity不改变网页预览的 http(s) 边界。
func detectURLEntities(message string, appLinks links.AppLinkBuilder) []tg.MessageEntityClass {
if !strings.Contains(message, "://") {
return nil
}
locs := urlInTextRe.FindAllStringIndex(message, -1)
if len(locs) == 0 {
return nil
}
out := make([]tg.MessageEntityClass, 0, len(locs))
for _, loc := range locs {
raw := strings.TrimRight(message[loc[0]:loc[1]], urlTrailingPunct)
if raw == "" {
continue
}
schemeEnd := strings.Index(raw, "://")
if schemeEnd <= 0 {
continue
}
scheme := raw[:schemeEnd]
if !strings.EqualFold(scheme, "http") && !strings.EqualFold(scheme, "https") && !appLinks.AcceptsEntityURL(raw) {
continue
}
out = append(out, &tg.MessageEntityURL{
Offset: utf16CodeUnitLen(message[:loc[0]]),
Length: utf16CodeUnitLen(raw),
})
}
return out
}
// firstPreviewableURL 从消息文本+实体中提取首个可预览的 http/https 链接,用于链接预览:
// - MessageEntityTextURLURL 直接在实体里markdown 风格 [text](url))。
// - MessageEntityURLURL 是文本里 [offset,offset+length) 的子串Telegram 实体偏移以
// UTF-16 码元计,需按 UTF-16 切片,不能按 rune/byte
// - 回退:实体里没有 URL 时扫描原始文本。DrKLO 发送时带 url 实体,但 TDesktop 等客户端
// 不带、依赖服务端检测原始文本里的链接(与官方服务端行为一致),故必须扫文本兜底。
//
// 取出现顺序的第一个合法链接(与官方"预览第一条链接"一致)。无可预览链接返回 ok=false。
func firstPreviewableURL(message string, entities []tg.MessageEntityClass) (string, bool) {
var units []uint16 // 惰性:仅遇到 MessageEntityURL需按 UTF-16 偏移切片)时才编码全文。
for _, entity := range entities {
var candidate string
switch e := entity.(type) {
case *tg.MessageEntityTextURL:
candidate = e.URL // URL 内联,无需切文本。
case *tg.MessageEntityURL:
if units == nil {
units = utf16.Encode([]rune(message))
}
candidate = sliceUTF16(units, e.Offset, e.Length)
default:
continue
}
if normalized, ok := domain.NormalizeWebPageURL(candidate); ok {
return normalized, true
}
}
// 回退扫原始文本:绝大多数消息无链接,无 "://" 子串则直接跳过正则与分配。
// firstURLInText 会继续限定为 http(s),自定义 app-link 永不进入网页预览。
if !strings.Contains(message, "://") {
return "", false
}
if raw, ok := firstURLInText(message); ok {
if normalized, ok := domain.NormalizeWebPageURL(raw); ok {
return normalized, true
}
}
return "", false
}
// firstURLInText 扫描原始文本里的首个 http(s) 链接,剥掉句末标点。
func firstURLInText(message string) (string, bool) {
for _, match := range urlInTextRe.FindAllString(message, -1) {
// 句末标点不属于 URL"见 https://example.com。" / "...go).")。'/' 是合法路径末尾,保留。
match = strings.TrimRight(match, urlTrailingPunct)
schemeEnd := strings.Index(match, "://")
if schemeEnd > 0 && (strings.EqualFold(match[:schemeEnd], "http") || strings.EqualFold(match[:schemeEnd], "https")) {
return match, true
}
}
return "", false
}
// sliceUTF16 按 UTF-16 码元偏移/长度从已编码序列取子串;越界返回空串。
func sliceUTF16(units []uint16, offset, length int) string {
if offset < 0 || length <= 0 || offset > len(units) || length > len(units)-offset {
return ""
}
return strings.TrimSpace(string(utf16.Decode(units[offset : offset+length])))
}
// webPagePendingOrCachedMedia 为发送构造链接预览媒体:
// - 若该 URL 已解析缓存(典型:客户端输入时 getWebPagePreview 已触发解析),直接挂 done
// 卡片——发送 echo 即带卡,不依赖异步 updateWebPage 换卡官方行为TDesktop 对自己
// 发出的消息不应用该换卡,故必须在 echo 直接带 done
// - 否则挂 pending 占位,由异步 resolver 解析后经 updateWebPage 换卡。
//
// 未启用预览或 URL 不可规范化返回 nil发送降级为无预览不报错
func (r *Router) webPagePendingOrCachedMedia(ctx context.Context, rawURL string, invertMedia, forceLarge, forceSmall bool) *domain.MessageMedia {
if page, ok := r.resolveAIComposeStyleWebPage(ctx, rawURL); ok {
page.ForceLargeMedia = forceLarge
page.ForceSmallMedia = forceSmall
return &domain.MessageMedia{Kind: domain.MessageMediaKindWebPage, InvertMedia: invertMedia, WebPage: &page}
}
if r.deps.Files == nil || !r.deps.Files.WebPagePreviewEnabled() {
return nil
}
normalized, ok := domain.NormalizeWebPageURL(rawURL)
if !ok {
return nil
}
if page, found := r.deps.Files.LookupWebPage(ctx, normalized); found && page.State == domain.MessageWebPageStateDone {
page.ForceLargeMedia = forceLarge
page.ForceSmallMedia = forceSmall
return &domain.MessageMedia{Kind: domain.MessageMediaKindWebPage, InvertMedia: invertMedia, WebPage: &page}
}
return &domain.MessageMedia{
Kind: domain.MessageMediaKindWebPage,
InvertMedia: invertMedia,
WebPage: &domain.MessageWebPage{
State: domain.MessageWebPageStatePending,
ID: domain.WebPageURLHash(normalized),
URL: normalized,
// date 是客户端重新拉取 pending 消息的绝对截止时间。TDesktop/DrKLO 在
// date<=now 时立即重取;若 resolver 此时仍运行TDesktop 会把占位记成 sticky
// failed后到的 done update 也不会重新显示卡片。
Date: int(r.clock.Now().Add(webPagePendingLifetime).Unix()),
ForceLargeMedia: forceLarge,
ForceSmallMedia: forceSmall,
},
}
}
// webPageMediaFromText 为文本发送构造链接预览媒体no_webpage 抑制;否则取首个可预览 URL。
func (r *Router) webPageMediaFromText(ctx context.Context, message string, entities []tg.MessageEntityClass, noWebpage, invertMedia bool) *domain.MessageMedia {
if noWebpage {
return nil
}
rawURL, ok := firstPreviewableURL(message, entities)
if !ok {
return nil
}
return r.webPagePendingOrCachedMedia(ctx, rawURL, invertMedia, false, false)
}