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) }