169 lines
7 KiB
Go
169 lines
7 KiB
Go
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 链接,用于链接预览:
|
||
// - MessageEntityTextURL:URL 直接在实体里(markdown 风格 [text](url))。
|
||
// - MessageEntityURL:URL 是文本里 [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)
|
||
}
|