owpengram-server/internal/rpc/webpage_url_extract.go
2026-09-01 12:06:31 +03:00

306 lines
11 KiB
Go
Raw Permalink 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"
"sort"
"strconv"
"strings"
"unicode/utf16"
"github.com/iamxvbaba/td/tg"
"golang.org/x/net/publicsuffix"
"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<>"')】]+`)
// bareURLInTextRe 匹配官方客户端会本地识别的裸域名 URL例如 github.com/@alice
// 前导边界排除 email、已有 scheme URL 内部和域名中间;候选命中后仍由 publicsuffix
// 校验 TLD避免把普通带点文本误升为链接。
var bareURLInTextRe = regexp.MustCompile(`(?i)(^|[^a-z0-9_@./:+-])((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})(?::[0-9]{1,5})?(?:[/?#][^\s<>"')】]*)?)`)
// urlTrailingPunct 是不属于 URL 的句末标点('/' 是合法路径末尾,保留)。
const urlTrailingPunct = ".,;:!?)]}'\"。,、!?"
type byteSpan struct {
start int
end int
scheme string
}
func rawURLByteSpans(message string) []byteSpan {
if !strings.Contains(message, "://") && !strings.Contains(message, ".") {
return nil
}
var out []byteSpan
if strings.Contains(message, "://") {
for _, loc := range urlInTextRe.FindAllStringIndex(message, -1) {
raw := strings.TrimRight(message[loc[0]:loc[1]], urlTrailingPunct)
if raw == "" {
continue
}
schemeEnd := strings.Index(raw, "://")
if schemeEnd <= 0 {
continue
}
out = append(out, byteSpan{
start: loc[0],
end: loc[0] + len(raw),
scheme: strings.ToLower(raw[:schemeEnd]),
})
}
}
if strings.Contains(message, ".") {
for _, loc := range bareURLInTextRe.FindAllStringSubmatchIndex(message, -1) {
if len(loc) < 6 || loc[4] < 0 || loc[5] <= loc[4] {
continue
}
start, end := loc[4], loc[5]
raw := strings.TrimRight(message[start:end], urlTrailingPunct)
end = start + len(raw)
if raw == "" || overlapsByteSpan(out, start, end) || !bareURLCandidateValid(raw) {
continue
}
out = append(out, byteSpan{start: start, end: end})
}
}
if len(out) == 0 {
return nil
}
sort.Slice(out, func(i, j int) bool {
if out[i].start == out[j].start {
return out[i].end < out[j].end
}
return out[i].start < out[j].start
})
return out
}
func overlapsByteSpan(spans []byteSpan, start, end int) bool {
for _, span := range spans {
if start < span.end && span.start < end {
return true
}
}
return false
}
// detectURLEntities 服务端扫描消息文本生成 url 高亮实体MessageEntityURL。裸域名
// URLgithub.com/@alice按官方客户端行为接纳带 scheme URL 除 http(s) 外,仅接受当前
// Router 配置允许的 app-link scheme/host。偏移/长度按 UTF-16 码元Telegram 实体口径)。
// 自定义 scheme 只参与 entity不改变网页预览的 http(s) 边界。
func detectURLEntities(message string, appLinks links.AppLinkBuilder) []tg.MessageEntityClass {
spans := rawURLByteSpans(message)
if len(spans) == 0 {
return nil
}
out := make([]tg.MessageEntityClass, 0, len(spans))
for _, span := range spans {
raw := message[span.start:span.end]
if span.scheme != "" && span.scheme != "http" && span.scheme != "https" && !appLinks.AcceptsEntityURL(raw) {
continue
}
out = append(out, &tg.MessageEntityURL{
Offset: utf16CodeUnitLen(message[:span.start]),
Length: utf16CodeUnitLen(raw),
})
}
return out
}
func bareURLCandidateValid(raw string) bool {
if raw == "" || strings.Contains(raw, "://") || strings.ContainsAny(raw, " \t\r\n<>\"'") {
return false
}
hostPort := raw
if cut := strings.IndexAny(hostPort, "/?#"); cut >= 0 {
hostPort = hostPort[:cut]
}
if hostPort == "" || strings.Contains(hostPort, "@") {
return false
}
host := hostPort
if h, port, ok := splitBareHostPort(hostPort); ok {
host = h
n, err := strconv.Atoi(port)
if err != nil || n <= 0 || n > 65535 {
return false
}
}
host = strings.ToLower(strings.TrimSuffix(host, "."))
if !bareDomainHostValid(host) {
return false
}
return bareDomainTLDValid(host)
}
func splitBareHostPort(hostPort string) (string, string, bool) {
idx := strings.LastIndexByte(hostPort, ':')
if idx < 0 {
return hostPort, "", false
}
return hostPort[:idx], hostPort[idx+1:], true
}
func bareDomainHostValid(host string) bool {
if host == "" || strings.Contains(host, ":") || strings.HasPrefix(host, ".") || strings.HasSuffix(host, ".") {
return false
}
labels := strings.Split(host, ".")
if len(labels) < 2 {
return false
}
for _, label := range labels {
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for i := 0; i < len(label); i++ {
c := label[i]
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' {
continue
}
return false
}
}
return true
}
func bareDomainTLDValid(host string) bool {
tld := host[strings.LastIndexByte(host, '.')+1:]
suffix, icann := publicsuffix.PublicSuffix("x." + tld)
return icann && suffix == tld
}
// firstPreviewableURL 从消息文本+实体中提取首个可预览链接,用于链接预览;裸域名会按
// 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 := normalizePreviewURLCandidate(candidate); ok {
return normalized, true
}
}
// 回退扫原始文本:绝大多数消息无链接,无 URL 触发字符则直接跳过正则与分配。
// firstURLInText 会继续限定为 http(s)/裸域名,自定义 app-link 永不进入网页预览。
if !strings.Contains(message, "://") && !strings.Contains(message, ".") {
return "", false
}
if raw, ok := firstURLInText(message); ok {
return raw, true
}
return "", false
}
func normalizePreviewURLCandidate(candidate string) (string, bool) {
candidate = strings.TrimRight(strings.TrimSpace(candidate), urlTrailingPunct)
if normalized, ok := domain.NormalizeWebPageURL(candidate); ok {
return normalized, true
}
if !bareURLCandidateValid(candidate) {
return "", false
}
return domain.NormalizeWebPageURL("https://" + candidate)
}
// firstURLInText 扫描原始文本里的首个可预览 http(s)/裸域名链接,剥掉句末标点。
func firstURLInText(message string) (string, bool) {
for _, span := range rawURLByteSpans(message) {
raw := message[span.start:span.end]
if normalized, ok := normalizePreviewURLCandidate(raw); ok {
return normalized, 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 := normalizePreviewURLCandidate(rawURL)
if !ok {
return nil
}
if page, found := r.deps.Files.LookupWebPage(ctx, normalized); found {
if page.State == domain.MessageWebPageStateEmpty {
return nil
}
if 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)
}