fix(messages): sync recognize configured app link entities

This commit is contained in:
iamxvbaba 2026-08-01 00:12:02 +08:00
parent 35d3908660
commit c9371c6048
12 changed files with 327 additions and 72 deletions

View file

@ -135,6 +135,24 @@ func (b AppLinkBuilder) BuildUsername(username string, query url.Values) string
return b.Build("resolve", query) return b.Build("resolve", query)
} }
// AcceptsEntityURL reports whether a raw custom-scheme URL belongs to this
// server's configured app-link namespace. The legacy route-as-host scheme is
// accepted for rollout compatibility; a distinct host-based scheme is limited
// to the exact configured host so arbitrary same-scheme links aren't promoted
// to clickable message entities by the server.
func (b AppLinkBuilder) AcceptsEntityURL(raw string) bool {
parsed, err := url.Parse(raw)
if err != nil || parsed.Opaque != "" || parsed.User != nil || parsed.Hostname() == "" || parsed.Port() != "" {
return false
}
if strings.EqualFold(parsed.Scheme, b.legacyScheme) {
return true
}
return b.baseHost != "" &&
strings.EqualFold(parsed.Scheme, b.baseScheme) &&
strings.EqualFold(parsed.Host, b.baseHost)
}
// MatchesRoute accepts the exact configured host-path form and the retained // MatchesRoute accepts the exact configured host-path form and the retained
// legacy route-as-host form. Query validation remains the caller's concern. // legacy route-as-host form. Query validation remains the caller's concern.
func (b AppLinkBuilder) MatchesRoute(parsed *url.URL, route string) bool { func (b AppLinkBuilder) MatchesRoute(parsed *url.URL, route string) bool {

View file

@ -161,6 +161,34 @@ func TestAppLinkBuilderPreservesLegacyAndSupportsHostBase(t *testing.T) {
} }
} }
func TestAppLinkBuilderAcceptsEntityURL(t *testing.T) {
hosted, err := NewAppLinkBuilder("telesrv", "owpg://links.example.test")
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
raw string
want bool
}{
{name: "legacy route", raw: "telesrv://resolve?domain=Alice", want: true},
{name: "legacy case insensitive", raw: "TELESRV://resolve?domain=Alice", want: true},
{name: "configured host base", raw: "owpg://links.example.test/Alice", want: true},
{name: "configured host case insensitive", raw: "OWPG://LINKS.EXAMPLE.TEST/Alice", want: true},
{name: "same scheme wrong host", raw: "owpg://other.example.test/Alice", want: false},
{name: "credentials", raw: "telesrv://user@resolve/path", want: false},
{name: "port", raw: "telesrv://resolve:443/path", want: false},
{name: "unconfigured scheme", raw: "other://resolve", want: false},
{name: "missing route host", raw: "telesrv://", want: false},
} {
t.Run(tc.name, func(t *testing.T) {
if got := hosted.AcceptsEntityURL(tc.raw); got != tc.want {
t.Fatalf("AcceptsEntityURL(%q) = %v, want %v", tc.raw, got, tc.want)
}
})
}
}
func TestValidateAppName(t *testing.T) { func TestValidateAppName(t *testing.T) {
if got, err := ValidateAppName(" Example Chat "); err != nil || got != "Example Chat" { if got, err := ValidateAppName(" Example Chat "); err != nil || got != "Example Chat" {
t.Fatalf("ValidateAppName valid = %q, %v", got, err) t.Fatalf("ValidateAppName valid = %q, %v", got, err)

View file

@ -6,6 +6,8 @@ import (
"unicode/utf8" "unicode/utf8"
"github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tg"
"telesrv/internal/links"
) )
// 服务端自动实体检测:@mention / #hashtag / $cashtag / bot command。 // 服务端自动实体检测:@mention / #hashtag / $cashtag / bot command。
@ -24,16 +26,15 @@ import (
// augmentAutoEntities 在客户端已发实体基础上补充服务端检测的自动实体。补充项与任何 // augmentAutoEntities 在客户端已发实体基础上补充服务端检测的自动实体。补充项与任何
// 已有实体(客户端富文本意图实体或先补入的自动实体)区间相交时丢弃,避免把 mention/ // 已有实体(客户端富文本意图实体或先补入的自动实体)区间相交时丢弃,避免把 mention/
// hashtag 打进 code/pre/textUrl/已有 mentionName 内部或彼此重叠(对齐官方不重复打实体)。 // hashtag 打进 code/pre/textUrl/已有 mentionName 内部或彼此重叠(对齐官方不重复打实体)。
// 客户端已带 url/textUrl 实体(自行检测过,如 DrKLO)时跳过 url 检测,沿用既有口径。 // URL 按跨度逐条补缺,不能因客户端带了某一条 HTTP URL 就跳过同消息中客户端不认识的
// 客户端实体保持在前(超过上限裁剪时优先保留),结果裁剪到实体上限。 // app-link。客户端实体保持在前(超过上限裁剪时优先保留),结果裁剪到实体上限。
func augmentAutoEntities(message string, entities []tg.MessageEntityClass) []tg.MessageEntityClass { func augmentAutoEntities(message string, entities []tg.MessageEntityClass, appLinks links.AppLinkBuilder) []tg.MessageEntityClass {
// 快路径:绝大多数消息不含任何可自动识别的触发字符。单次 ContainsAny 扫描即短路返回, // 快路径:绝大多数消息不含任何可自动识别的触发字符。单次 ContainsAny 扫描即短路返回,
// 跳过下面各检测器对全文的扫描与区间分配(纯文本发送零额外开销)。所有 http(s) 链接 // 跳过下面各检测器对全文的扫描与区间分配(纯文本发送零额外开销)。所有 http(s) 链接
// 都含 '/',故 "@#$/" 一并覆盖 url 检测;email/phone 未实现故不在触发集内。 // 都含 '/',故 "@#$/" 一并覆盖 url 检测;email/phone 未实现故不在触发集内。
if message == "" || !strings.ContainsAny(message, "@#$/") { if message == "" || !strings.ContainsAny(message, "@#$/") {
return entities return entities
} }
hasClientURL := false
type interval struct{ start, end int } type interval struct{ start, end int }
occupied := make([]interval, 0, len(entities)+8) occupied := make([]interval, 0, len(entities)+8)
for _, e := range entities { for _, e := range entities {
@ -41,10 +42,6 @@ func augmentAutoEntities(message string, entities []tg.MessageEntityClass) []tg.
off := e.GetOffset() off := e.GetOffset()
occupied = append(occupied, interval{off, off + ln}) occupied = append(occupied, interval{off, off + ln})
} }
switch e.(type) {
case *tg.MessageEntityURL, *tg.MessageEntityTextURL:
hasClientURL = true
}
} }
overlaps := func(s, e int) bool { overlaps := func(s, e int) bool {
for _, iv := range occupied { for _, iv := range occupied {
@ -75,9 +72,9 @@ func augmentAutoEntities(message string, entities []tg.MessageEntityClass) []tg.
} }
// URL 跨度始终计算并加入排除区(occupied),使 @mention/#hashtag 等不会落进 URL 路径内部 // URL 跨度始终计算并加入排除区(occupied),使 @mention/#hashtag 等不会落进 URL 路径内部
// (如 https://t.me/@scam 的 @scam,既不符官方语义也是钓鱼风险);但仅在客户端未带任何 // (如 https://t.me/@scam 的 @scam,既不符官方语义也是钓鱼风险)。逐跨度补缺可覆盖
// url/textUrl 实体时才作为实体下发,沿用 all-or-nothing(DrKLO 一带即全带;TDesktop 不带、依赖服务端) // “客户端带 HTTP entity、但不认识 telesrv://”的混合消息,同时仍避免重复实体
for _, u := range detectURLEntities(message) { for _, u := range detectURLEntities(message, appLinks) {
ln := u.GetLength() ln := u.GetLength()
if ln <= 0 { if ln <= 0 {
continue continue
@ -87,7 +84,7 @@ func augmentAutoEntities(message string, entities []tg.MessageEntityClass) []tg.
continue continue
} }
occupied = append(occupied, interval{off, off + ln}) occupied = append(occupied, interval{off, off + ln})
if !hasClientURL && len(entities)+len(extra) < maxMessageEntityCount { if len(entities)+len(extra) < maxMessageEntityCount {
extra = append(extra, u) extra = append(extra, u)
} }
} }
@ -114,6 +111,10 @@ func augmentAutoEntities(message string, entities []tg.MessageEntityClass) []tg.
return append(out, extra...) return append(out, extra...)
} }
func (r *Router) augmentAutoEntities(message string, entities []tg.MessageEntityClass) []tg.MessageEntityClass {
return augmentAutoEntities(message, entities, r.appLinks)
}
// isWordRune 判定「单词字符」(用于实体前导边界:前一个字符是单词字符时不是新实体起点, // isWordRune 判定「单词字符」(用于实体前导边界:前一个字符是单词字符时不是新实体起点,
// 借此排除 email 的 local@domain、路径里的 and/or 等)。 // 借此排除 email 的 local@domain、路径里的 and/or 等)。
func isWordRune(r rune) bool { func isWordRune(r rune) bool {

View file

@ -50,7 +50,7 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
if hasMessage && richMessage == nil { if hasMessage && richMessage == nil {
// 编辑后的文本同样补服务端自动实体url/@mention/#hashtag/bot command与发送一致 // 编辑后的文本同样补服务端自动实体url/@mention/#hashtag/bot command与发送一致
// 覆盖频道/私聊编辑与各自的定时编辑分支editScheduledMessage 仅由本处调用)。 // 覆盖频道/私聊编辑与各自的定时编辑分支editScheduledMessage 仅由本处调用)。
entities = augmentAutoEntities(message, entities) entities = r.augmentAutoEntities(message, entities)
} }
userID, _, err := r.currentUserID(ctx) userID, _, err := r.currentUserID(ctx)
if err != nil { if err != nil {

View file

@ -62,7 +62,7 @@ func TestMessagesEditMessageReturnsUpdateAndRecordsOwnerContext(t *testing.T) {
Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22},
ID: 3, ID: 3,
} }
req.SetMessage("edited") req.SetMessage("edited telesrv://resolve?domain=Alice")
req.SetEntities([]tg.MessageEntityClass{&tg.MessageEntityBold{Offset: 0, Length: 6}}) req.SetEntities([]tg.MessageEntityClass{&tg.MessageEntityBold{Offset: 0, Length: 6}})
var in bin.Buffer var in bin.Buffer
if err := req.Encode(&in); err != nil { if err := req.Encode(&in); err != nil {
@ -85,14 +85,16 @@ func TestMessagesEditMessageReturnsUpdateAndRecordsOwnerContext(t *testing.T) {
t.Fatalf("edit update = %#v, want pts=7 count=1", got.Updates[0]) t.Fatalf("edit update = %#v, want pts=7 count=1", got.Updates[0])
} }
msg, ok := edit.Message.(*tg.Message) msg, ok := edit.Message.(*tg.Message)
if !ok || msg.ID != 3 || msg.Message != "edited" { if !ok || msg.ID != 3 || msg.Message != "edited telesrv://resolve?domain=Alice" {
t.Fatalf("edited message = %#v, want id=3 text edited", edit.Message) t.Fatalf("edited message = %#v, want id=3 text with app-link", edit.Message)
} }
if messages.editReq.OwnerUserID != userID || messages.editReq.Peer.ID != peerID || messages.editReq.ID != 3 || messages.editReq.OriginAuthKeyID != authKeyID || messages.editReq.OriginSessionID != 77 { if messages.editReq.OwnerUserID != userID || messages.editReq.Peer.ID != peerID || messages.editReq.ID != 3 || messages.editReq.OriginAuthKeyID != authKeyID || messages.editReq.OriginSessionID != 77 {
t.Fatalf("edit request = %+v, want owner peer message id and origin", messages.editReq) t.Fatalf("edit request = %+v, want owner peer message id and origin", messages.editReq)
} }
if len(messages.editReq.Entities) != 1 || messages.editReq.Entities[0].Type != domain.MessageEntityBold { if len(messages.editReq.Entities) != 2 || messages.editReq.Entities[0].Type != domain.MessageEntityBold ||
t.Fatalf("edit entities = %+v, want bold", messages.editReq.Entities) messages.editReq.Entities[1].Type != domain.MessageEntityURL || messages.editReq.Entities[1].Offset != 7 ||
messages.editReq.Entities[1].Length != utf16CodeUnitLen("telesrv://resolve?domain=Alice") {
t.Fatalf("edit entities = %+v, want bold plus configured app-link", messages.editReq.Entities)
} }
} }

View file

@ -303,7 +303,7 @@ func TestMonoforumSendMessageWritePath(t *testing.T) {
} }
// TDesktop 的订阅者请求不携带 InputReplyToMonoForum;服务端必须从调用者推导 saved_peer=self。 // TDesktop 的订阅者请求不携带 InputReplyToMonoForum;服务端必须从调用者推导 saved_peer=self。
subReq := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "hi from sub", RandomID: 555} subReq := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "hi from sub telesrv://resolve?domain=Owner", RandomID: 555}
subReq.ClearDraft = true subReq.ClearDraft = true
subReq.SetAllowPaidStars(20) subReq.SetAllowPaidStars(20)
suggestedInput := tg.SuggestedPost{} suggestedInput := tg.SuggestedPost{}
@ -325,6 +325,15 @@ func TestMonoforumSendMessageWritePath(t *testing.T) {
if message, ok := newMessage.Message.(*tg.Message); ok { if message, ok := newMessage.Message.(*tg.Message); ok {
subMessageID = message.ID subMessageID = message.ID
subPaidStars, _ = message.GetPaidMessageStars() subPaidStars, _ = message.GetPaidMessageStars()
var hasAppLink bool
for _, entity := range message.Entities {
if url, ok := entity.(*tg.MessageEntityURL); ok && url.Offset == utf16CodeUnitLen("hi from sub ") && url.Length == utf16CodeUnitLen("telesrv://resolve?domain=Owner") {
hasAppLink = true
}
}
if !hasAppLink {
t.Fatalf("monoforum message missing configured app-link entity: %+v", message.Entities)
}
} }
} }
if balance, ok := update.(*tg.UpdateStarsBalance); ok { if balance, ok := update.(*tg.UpdateStarsBalance); ok {

View file

@ -272,7 +272,7 @@ func (r *Router) onMessagesSaveQuickReplyText(ctx context.Context, req *tg.Messa
Date: int(r.clock.Now().Unix()), Date: int(r.clock.Now().Unix()),
Message: req.Message, Message: req.Message,
// 快速回复模板与普通发送一致补服务端自动实体url/@mention/#hashtag/bot command // 快速回复模板与普通发送一致补服务端自动实体url/@mention/#hashtag/bot command
Entities: domainMessageEntitiesForViewer(userID, augmentAutoEntities(req.Message, req.Entities)), Entities: domainMessageEntitiesForViewer(userID, r.augmentAutoEntities(req.Message, req.Entities)),
}) })
if err != nil { if err != nil {
return nil, businessAutomationErr(err) return nil, businessAutomationErr(err)

View file

@ -65,6 +65,12 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
sendErr = internalErr() sendErr = internalErr()
return nil, sendErr return nil, sendErr
} }
// 自动实体必须在普通/频道/monoforum 分流前完成,保证所有文本写路径持久化同一份
// 服务端补全结果。指纹仍基于原始请求,派生实体不改变 random_id 幂等语义。
entities := req.Entities
if req.RichMessage == nil {
entities = r.augmentAutoEntities(req.Message, entities)
}
suggestedInput, hasSuggestedPost := req.GetSuggestedPost() suggestedInput, hasSuggestedPost := req.GetSuggestedPost()
// monoforum 普通用户发送不带 reply_tosaved_peer 必须由服务端推导为自己;管理员回复才必须 // monoforum 普通用户发送不带 reply_tosaved_peer 必须由服务端推导为自己;管理员回复才必须
// 显式携带 monoforum_peer_id。仅凭 reply_to 判路由会把用户请求误送进普通 megagroup 路径。 // 显式携带 monoforum_peer_id。仅凭 reply_to 判路由会把用户请求误送进普通 megagroup 路径。
@ -127,7 +133,7 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
IdempotencyFingerprint: idempotencyFingerprint, IdempotencyFingerprint: idempotencyFingerprint,
IdempotencyPreflighted: replay.checked, IdempotencyPreflighted: replay.checked,
Message: req.Message, Message: req.Message,
Entities: domainMessageEntities(req.Entities), Entities: domainMessageEntities(entities),
ReplyTo: replyTo, ReplyTo: replyTo,
Silent: req.Silent, Silent: req.Silent,
NoForwards: req.Noforwards, NoForwards: req.Noforwards,
@ -198,16 +204,11 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
sendErr = messageEmptyErr() sendErr = messageEmptyErr()
return nil, sendErr return nil, sendErr
} }
// 自动实体高亮:客户端未带 url/@mention/#hashtag/bot command 等「可自动识别」实体时,服务端
// 检测原文补充(官方服务端行为),否则 @username/链接等不渲染为可点蓝色。富文本走独立结构,不处理。
if richMessage == nil {
req.Entities = augmentAutoEntities(req.Message, req.Entities)
}
// 链接预览:纯文本消息(私聊或频道)含可预览 URL 且未抑制时,挂 pending 占位,异步解析回填。 // 链接预览:纯文本消息(私聊或频道)含可预览 URL 且未抑制时,挂 pending 占位,异步解析回填。
// 富文本消息有独立媒体语义,不叠加。 // 富文本消息有独立媒体语义,不叠加。
var previewMedia *domain.MessageMedia var previewMedia *domain.MessageMedia
if richMessage == nil { if richMessage == nil {
previewMedia = r.webPageMediaFromText(ctx, req.Message, req.Entities, req.NoWebpage, req.InvertMedia) previewMedia = r.webPageMediaFromText(ctx, req.Message, entities, req.NoWebpage, req.InvertMedia)
} }
if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) { if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) {
updates, err := r.scheduleOutgoing(ctx, userID, peer, outgoingSend{ updates, err := r.scheduleOutgoing(ctx, userID, peer, outgoingSend{
@ -215,7 +216,7 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
idempotencyFingerprint: idempotencyFingerprint, idempotencyFingerprint: idempotencyFingerprint,
idempotencyPreflighted: replay.checked, idempotencyPreflighted: replay.checked,
message: req.Message, message: req.Message,
entities: req.Entities, entities: entities,
media: previewMedia, media: previewMedia,
silent: req.Silent, silent: req.Silent,
noforwards: req.Noforwards, noforwards: req.Noforwards,
@ -236,7 +237,7 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
idempotencyFingerprint: idempotencyFingerprint, idempotencyFingerprint: idempotencyFingerprint,
idempotencyPreflighted: replay.checked, idempotencyPreflighted: replay.checked,
message: req.Message, message: req.Message,
entities: req.Entities, entities: entities,
media: previewMedia, media: previewMedia,
silent: req.Silent, silent: req.Silent,
noforwards: req.Noforwards, noforwards: req.Noforwards,

View file

@ -227,6 +227,110 @@ func TestSendMessageHighlightsBareURL(t *testing.T) {
} }
} }
// TestSendMessageHighlightsConfiguredAppLink locks the server-only compatibility
// contract: clients may omit the custom-scheme entity, while the persisted/echoed
// message still carries MessageEntityURL and never starts webpage resolution.
func TestSendMessageHighlightsConfiguredAppLink(t *testing.T) {
ctx := context.Background()
r, owner, friend := newMediaTestRouter(t)
r.deps.Files.(*fakeFiles).webPagePreviewOn = true
message := "👍 telesrv://resolve?domain=Alice"
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
Message: message,
RandomID: 5302,
})
if err != nil {
t.Fatalf("sendMessage: %v", err)
}
msg := newMessageFromUpdates(t, updates)
if msg.Media != nil {
t.Fatalf("custom app-link media = %T, want nil", msg.Media)
}
for _, entity := range msg.Entities {
if url, ok := entity.(*tg.MessageEntityURL); ok && url.Offset == 3 && url.Length == utf16CodeUnitLen("telesrv://resolve?domain=Alice") {
return
}
}
t.Fatalf("sent message missing configured app-link entity: %+v", msg.Entities)
}
// TestSendMessageFillsCustomLinkBesideClientHTTPEntity covers the partial-client
// case exposed by Android: an existing HTTP entity must not suppress app-link detection.
func TestSendMessageFillsCustomLinkBesideClientHTTPEntity(t *testing.T) {
ctx := context.Background()
r, owner, friend := newMediaTestRouter(t)
message := "https://x telesrv://resolve?domain=Alice"
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
Message: message,
Entities: []tg.MessageEntityClass{&tg.MessageEntityURL{Offset: 0, Length: utf16CodeUnitLen("https://x")}},
RandomID: 5303,
})
if err != nil {
t.Fatalf("sendMessage: %v", err)
}
msg := newMessageFromUpdates(t, updates)
if len(msg.Entities) != 2 {
t.Fatalf("message entities = %+v, want client HTTP plus server app-link", msg.Entities)
}
custom, ok := msg.Entities[1].(*tg.MessageEntityURL)
if !ok || custom.Offset != utf16CodeUnitLen("https://x ") || custom.Length != utf16CodeUnitLen("telesrv://resolve?domain=Alice") {
t.Fatalf("custom entity = %#v", msg.Entities[1])
}
}
func TestAutoEntityDerivationDoesNotMutateSendRequests(t *testing.T) {
t.Run("send-message-replay", func(t *testing.T) {
ctx := context.Background()
r, owner, friend := newMediaTestRouter(t)
req := &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
Message: "telesrv://resolve?domain=Alice",
RandomID: 5304,
}
first, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), req)
if err != nil {
t.Fatalf("first sendMessage: %v", err)
}
if len(req.Entities) != 0 {
t.Fatalf("sendMessage request was mutated: %+v", req.Entities)
}
if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), req); err != nil {
t.Fatalf("sendMessage replay: %v", err)
}
if got := newMessageFromUpdates(t, first); len(got.Entities) != 1 {
t.Fatalf("first send entities = %+v, want derived app-link", got.Entities)
}
})
t.Run("send-media-replay", func(t *testing.T) {
ctx := context.Background()
r, owner, friend := newMediaTestRouter(t)
req := &tg.MessagesSendMediaRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
Media: &tg.InputMediaContact{PhoneNumber: "+15550005305", FirstName: "Alice"},
Message: "telesrv://resolve?domain=Alice",
RandomID: 5305,
}
first, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), req)
if err != nil {
t.Fatalf("first sendMedia: %v", err)
}
if len(req.Entities) != 0 {
t.Fatalf("sendMedia request was mutated: %+v", req.Entities)
}
if _, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), req); err != nil {
t.Fatalf("sendMedia replay: %v", err)
}
if got := newMessageFromUpdates(t, first); len(got.Entities) != 1 {
t.Fatalf("first media caption entities = %+v, want derived app-link", got.Entities)
}
})
}
// TestSendMessageNoURLNoPlaceholder 验证无 URL 实体时不挂占位。 // TestSendMessageNoURLNoPlaceholder 验证无 URL 实体时不挂占位。
func TestSendMessageNoURLNoPlaceholder(t *testing.T) { func TestSendMessageNoURLNoPlaceholder(t *testing.T) {
ctx := context.Background() ctx := context.Background()

View file

@ -269,7 +269,9 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
// 原始 TL 指纹;编码失败则留空,由 store 使用 domain fallback。 // 原始 TL 指纹;编码失败则留空,由 store 使用 domain fallback。
idempotencyFingerprint, _ := sendMediaIdempotencyFingerprint(req) idempotencyFingerprint, _ := sendMediaIdempotencyFingerprint(req)
// 媒体 caption 里的链接/@mention/#hashtag 等同样补自动高亮实体(客户端未带时)。 // 媒体 caption 里的链接/@mention/#hashtag 等同样补自动高亮实体(客户端未带时)。
req.Entities = augmentAutoEntities(req.Message, req.Entities) // 派生结果保持在局部变量中,不能回写原始 TL request否则同一请求对象重试时指纹会
// 从“客户端输入”变成“服务端派生输入”,错误触发 RANDOM_ID_DUPLICATE。
entities := r.augmentAutoEntities(req.Message, req.Entities)
userID, _, err := r.currentUserID(ctx) userID, _, err := r.currentUserID(ctx)
if err != nil { if err != nil {
return nil, internalErr() return nil, internalErr()
@ -351,7 +353,7 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
IdempotencyFingerprint: idempotencyFingerprint, IdempotencyFingerprint: idempotencyFingerprint,
IdempotencyPreflighted: replay.checked, IdempotencyPreflighted: replay.checked,
Message: req.Message, Message: req.Message,
Entities: domainMessageEntities(req.Entities), Entities: domainMessageEntities(entities),
Media: media, Media: media,
ReplyTo: replyTo, ReplyTo: replyTo,
Silent: req.Silent, Silent: req.Silent,
@ -414,7 +416,7 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
idempotencyFingerprint: idempotencyFingerprint, idempotencyFingerprint: idempotencyFingerprint,
idempotencyPreflighted: replay.checked, idempotencyPreflighted: replay.checked,
message: req.Message, message: req.Message,
entities: req.Entities, entities: entities,
media: media, media: media,
silent: req.Silent, silent: req.Silent,
noforwards: req.Noforwards, noforwards: req.Noforwards,
@ -429,7 +431,7 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
idempotencyFingerprint: idempotencyFingerprint, idempotencyFingerprint: idempotencyFingerprint,
idempotencyPreflighted: replay.checked, idempotencyPreflighted: replay.checked,
message: req.Message, message: req.Message,
entities: req.Entities, entities: entities,
media: media, media: media,
silent: req.Silent, silent: req.Silent,
noforwards: req.Noforwards, noforwards: req.Noforwards,
@ -583,7 +585,7 @@ func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesS
idempotencyFingerprint: idempotencyFingerprint, idempotencyFingerprint: idempotencyFingerprint,
idempotencyPreflighted: replays[i].checked, idempotencyPreflighted: replays[i].checked,
message: item.Message, message: item.Message,
entities: augmentAutoEntities(item.Message, item.Entities), entities: r.augmentAutoEntities(item.Message, item.Entities),
media: media, media: media,
silent: req.Silent, silent: req.Silent,
noforwards: req.Noforwards, noforwards: req.Noforwards,

View file

@ -9,19 +9,22 @@ import (
"github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tg"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/links"
) )
// urlInTextRe 匹配原始文本里的 http(s) 链接(取到首个空白或尖括号/引号为止)。 // urlInTextRe 匹配原始文本里的带 scheme 链接(取到首个空白或尖括号/引号为止)。
var urlInTextRe = regexp.MustCompile(`https?://[^\s<>"')】]+`) // 是否接纳由 detectURLEntities 再按 http(s) 或当前 app-link 配置收口,不能把任意
// foo:// 都提升为服务端认证的可点击实体。
var urlInTextRe = regexp.MustCompile(`(?i)[a-z][a-z0-9+.-]*://[^\s<>"')】]+`)
// urlTrailingPunct 是不属于 URL 的句末标点('/' 是合法路径末尾,保留)。 // urlTrailingPunct 是不属于 URL 的句末标点('/' 是合法路径末尾,保留)。
const urlTrailingPunct = ".,;:!?)]}'\"。,、!?" const urlTrailingPunct = ".,;:!?)]}'\"。,、!?"
// detectURLEntities 服务端扫描消息文本生成 url 高亮实体MessageEntityURLTDesktop 等客户端 // detectURLEntities 服务端扫描消息文本生成 url 高亮实体MessageEntityURL除 http(s)
// 发消息不带 url 实体、依赖服务端检测原文(官方服务端行为),否则链接不高亮。偏移/长度按 // 外,仅接受当前 Router 配置允许的 app-link scheme/host。偏移/长度按 UTF-16 码元
// UTF-16 码元Telegram 实体口径)。 // Telegram 实体口径)。自定义 scheme 只参与 entity不改变网页预览的 http(s) 边界。
func detectURLEntities(message string) []tg.MessageEntityClass { func detectURLEntities(message string, appLinks links.AppLinkBuilder) []tg.MessageEntityClass {
if !strings.Contains(message, "http") { if !strings.Contains(message, "://") {
return nil return nil
} }
locs := urlInTextRe.FindAllStringIndex(message, -1) locs := urlInTextRe.FindAllStringIndex(message, -1)
@ -34,6 +37,14 @@ func detectURLEntities(message string) []tg.MessageEntityClass {
if raw == "" { if raw == "" {
continue 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{ out = append(out, &tg.MessageEntityURL{
Offset: utf16CodeUnitLen(message[:loc[0]]), Offset: utf16CodeUnitLen(message[:loc[0]]),
Length: utf16CodeUnitLen(raw), Length: utf16CodeUnitLen(raw),
@ -69,8 +80,9 @@ func firstPreviewableURL(message string, entities []tg.MessageEntityClass) (stri
return normalized, true return normalized, true
} }
} }
// 回退扫原始文本:绝大多数消息无链接,无 "http" 子串则直接跳过正则与分配。 // 回退扫原始文本:绝大多数消息无链接,无 "://" 子串则直接跳过正则与分配。
if !strings.Contains(message, "http") { // firstURLInText 会继续限定为 http(s),自定义 app-link 永不进入网页预览。
if !strings.Contains(message, "://") {
return "", false return "", false
} }
if raw, ok := firstURLInText(message); ok { if raw, ok := firstURLInText(message); ok {
@ -83,16 +95,15 @@ func firstPreviewableURL(message string, entities []tg.MessageEntityClass) (stri
// firstURLInText 扫描原始文本里的首个 http(s) 链接,剥掉句末标点。 // firstURLInText 扫描原始文本里的首个 http(s) 链接,剥掉句末标点。
func firstURLInText(message string) (string, bool) { func firstURLInText(message string) (string, bool) {
match := urlInTextRe.FindString(message) for _, match := range urlInTextRe.FindAllString(message, -1) {
if match == "" { // 句末标点不属于 URL"见 https://example.com。" / "...go).")。'/' 是合法路径末尾,保留。
return "", false 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
}
} }
// 句末标点不属于 URL"见 https://example.com。" / "...go).")。'/' 是合法路径末尾,保留。 return "", false
match = strings.TrimRight(match, urlTrailingPunct)
if match == "" {
return "", false
}
return match, true
} }
// sliceUTF16 按 UTF-16 码元偏移/长度从已编码序列取子串;越界返回空串。 // sliceUTF16 按 UTF-16 码元偏移/长度从已编码序列取子串;越界返回空串。

View file

@ -4,8 +4,31 @@ import (
"testing" "testing"
"github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tg"
"telesrv/internal/links"
) )
var testDefaultAppLinks = func() links.AppLinkBuilder {
appLinks, err := links.NewAppLinkBuilder("telesrv", "")
if err != nil {
panic(err)
}
return appLinks
}()
func testAugmentAutoEntities(message string, entities []tg.MessageEntityClass) []tg.MessageEntityClass {
return augmentAutoEntities(message, entities, testDefaultAppLinks)
}
func testAugmentAutoEntitiesWithAppLinks(t *testing.T, message string, entities []tg.MessageEntityClass, scheme, base string) []tg.MessageEntityClass {
t.Helper()
appLinks, err := links.NewAppLinkBuilder(scheme, base)
if err != nil {
t.Fatal(err)
}
return augmentAutoEntities(message, entities, appLinks)
}
func TestFirstPreviewableURL(t *testing.T) { func TestFirstPreviewableURL(t *testing.T) {
t.Run("text-url-entity", func(t *testing.T) { t.Run("text-url-entity", func(t *testing.T) {
got, ok := firstPreviewableURL("click here", []tg.MessageEntityClass{ got, ok := firstPreviewableURL("click here", []tg.MessageEntityClass{
@ -74,6 +97,26 @@ func TestFirstPreviewableURL(t *testing.T) {
t.Fatalf("ftp URL should be rejected") t.Fatalf("ftp URL should be rejected")
} }
}) })
t.Run("custom-scheme-entity-does-not-preview", func(t *testing.T) {
message := "telesrv://resolve?domain=Alice"
entities := testAugmentAutoEntities(message, nil)
if len(entities) != 1 {
t.Fatalf("entities = %d, want 1", len(entities))
}
if got, ok := firstPreviewableURL(message, entities); ok {
t.Fatalf("custom app-link must not become a webpage preview, got %q", got)
}
})
t.Run("custom-scheme-before-http-still-previews-http", func(t *testing.T) {
message := "telesrv://resolve?domain=Alice then https://example.com/x"
entities := testAugmentAutoEntities(message, nil)
got, ok := firstPreviewableURL(message, entities)
if !ok || got != "https://example.com/x" {
t.Fatalf("got (%q,%v), want https://example.com/x", got, ok)
}
})
} }
func urlEntity(t *testing.T, e tg.MessageEntityClass) *tg.MessageEntityURL { func urlEntity(t *testing.T, e tg.MessageEntityClass) *tg.MessageEntityURL {
@ -89,7 +132,7 @@ func urlEntity(t *testing.T, e tg.MessageEntityClass) *tg.MessageEntityURL {
// 高亮UTF-16 偏移正确,多链接全检测,客户端已带 url 实体则不重复。 // 高亮UTF-16 偏移正确,多链接全检测,客户端已带 url 实体则不重复。
func TestAugmentAutoEntitiesURL(t *testing.T) { func TestAugmentAutoEntitiesURL(t *testing.T) {
t.Run("detect-when-no-client-entities", func(t *testing.T) { t.Run("detect-when-no-client-entities", func(t *testing.T) {
got := augmentAutoEntities("see https://example.com/x now", nil) got := testAugmentAutoEntities("see https://example.com/x now", nil)
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("entities = %d, want 1", len(got)) t.Fatalf("entities = %d, want 1", len(got))
} }
@ -99,7 +142,7 @@ func TestAugmentAutoEntitiesURL(t *testing.T) {
} }
}) })
t.Run("utf16-offset-with-emoji", func(t *testing.T) { t.Run("utf16-offset-with-emoji", func(t *testing.T) {
got := augmentAutoEntities("\U0001f44d https://x", nil) // 👍=2 units, space=1 → url at 3 got := testAugmentAutoEntities("\U0001f44d https://x", nil) // 👍=2 units, space=1 → url at 3
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("entities = %d, want 1", len(got)) t.Fatalf("entities = %d, want 1", len(got))
} }
@ -108,28 +151,64 @@ func TestAugmentAutoEntitiesURL(t *testing.T) {
} }
}) })
t.Run("multiple-urls", func(t *testing.T) { t.Run("multiple-urls", func(t *testing.T) {
if got := augmentAutoEntities("https://a.com and https://b.com", nil); len(got) != 2 { if got := testAugmentAutoEntities("https://a.com and https://b.com", nil); len(got) != 2 {
t.Fatalf("entities = %d, want 2", len(got)) t.Fatalf("entities = %d, want 2", len(got))
} }
}) })
t.Run("respect-client-url-entity", func(t *testing.T) { t.Run("respect-client-url-entity", func(t *testing.T) {
ents := []tg.MessageEntityClass{&tg.MessageEntityURL{Offset: 0, Length: 9}} ents := []tg.MessageEntityClass{&tg.MessageEntityURL{Offset: 0, Length: 9}}
if got := augmentAutoEntities("https://x more https://y", ents); len(got) != 1 { if got := testAugmentAutoEntities("https://x more https://y", ents); len(got) != 2 {
t.Fatalf("client url entity present → no server detection, got %d", len(got)) t.Fatalf("server should fill the missing URL span, got %d", len(got))
} }
}) })
t.Run("trailing-punct-not-in-entity", func(t *testing.T) { t.Run("trailing-punct-not-in-entity", func(t *testing.T) {
got := augmentAutoEntities("见 https://example.com。", nil) // 见 ...。 got := testAugmentAutoEntities("见 https://example.com。", nil) // 见 ...。
e := urlEntity(t, got[0]) e := urlEntity(t, got[0])
if e.Length != utf16CodeUnitLen("https://example.com") { if e.Length != utf16CodeUnitLen("https://example.com") {
t.Errorf("length = %d, want %d (trailing 。 excluded)", e.Length, utf16CodeUnitLen("https://example.com")) t.Errorf("length = %d, want %d (trailing 。 excluded)", e.Length, utf16CodeUnitLen("https://example.com"))
} }
}) })
t.Run("no-url-no-entities", func(t *testing.T) { t.Run("no-url-no-entities", func(t *testing.T) {
if got := augmentAutoEntities("plain text", nil); len(got) != 0 { if got := testAugmentAutoEntities("plain text", nil); len(got) != 0 {
t.Fatalf("entities = %d, want 0", len(got)) t.Fatalf("entities = %d, want 0", len(got))
} }
}) })
t.Run("configured-custom-scheme", func(t *testing.T) {
message := "👍 TELESRV://resolve?domain=Alice。"
got := testAugmentAutoEntities(message, nil)
if len(got) != 1 {
t.Fatalf("entities = %d, want 1", len(got))
}
e := urlEntity(t, got[0])
wantURL := "TELESRV://resolve?domain=Alice"
if e.Offset != 3 || e.Length != utf16CodeUnitLen(wantURL) {
t.Fatalf("offset/length = %d/%d, want 3/%d", e.Offset, e.Length, utf16CodeUnitLen(wantURL))
}
})
t.Run("mixed-client-http-and-missing-custom-scheme", func(t *testing.T) {
message := "https://x telesrv://resolve?domain=Alice"
client := []tg.MessageEntityClass{&tg.MessageEntityURL{Offset: 0, Length: utf16CodeUnitLen("https://x")}}
got := testAugmentAutoEntities(message, client)
if len(got) != 2 {
t.Fatalf("entities = %d, want client HTTP plus server app-link", len(got))
}
e := urlEntity(t, got[1])
if e.Offset != utf16CodeUnitLen("https://x ") || e.Length != utf16CodeUnitLen("telesrv://resolve?domain=Alice") {
t.Fatalf("custom entity offset/length = %d/%d", e.Offset, e.Length)
}
})
t.Run("unconfigured-scheme-rejected", func(t *testing.T) {
if got := testAugmentAutoEntities("foo://resolve telesrv://resolve", nil); len(got) != 1 {
t.Fatalf("entities = %d, want only configured telesrv link", len(got))
}
})
t.Run("host-base-is-exact", func(t *testing.T) {
message := "owpg://links.example.test/Alice owpg://other.example.test/Bob telesrv://resolve?domain=Carol"
got := testAugmentAutoEntitiesWithAppLinks(t, message, nil, "telesrv", "owpg://links.example.test")
if len(got) != 2 {
t.Fatalf("entities = %d, want configured host-base and legacy link", len(got))
}
})
} }
// BenchmarkAugmentAutoEntities 量化发送热路径:纯文本(无触发字符)应零分配走快路径短路; // BenchmarkAugmentAutoEntities 量化发送热路径:纯文本(无触发字符)应零分配走快路径短路;
@ -139,14 +218,14 @@ func BenchmarkAugmentAutoEntities(b *testing.B) {
msg := "hey everyone, just wanted to share some thoughts about the meeting today and tomorrow" msg := "hey everyone, just wanted to share some thoughts about the meeting today and tomorrow"
b.ReportAllocs() b.ReportAllocs()
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
_ = augmentAutoEntities(msg, nil) _ = testAugmentAutoEntities(msg, nil)
} }
}) })
b.Run("with-mention-hashtag-url", func(b *testing.B) { b.Run("with-mention-hashtag-url", func(b *testing.B) {
msg := "hi @alice please check #golang docs at https://example.com/x thanks" msg := "hi @alice please check #golang docs at https://example.com/x thanks"
b.ReportAllocs() b.ReportAllocs()
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
_ = augmentAutoEntities(msg, nil) _ = testAugmentAutoEntities(msg, nil)
} }
}) })
} }
@ -167,16 +246,16 @@ func mentionAt(t *testing.T, got []tg.MessageEntityClass, off, ln int) {
func TestAugmentAutoEntitiesMention(t *testing.T) { func TestAugmentAutoEntitiesMention(t *testing.T) {
t.Run("bare-mentions", func(t *testing.T) { t.Run("bare-mentions", func(t *testing.T) {
// 对齐官方抓包:纯 "@G0ldenMods\n@NGame_Official" → 两个裸 messageEntityMention含前导 @。 // 对齐官方抓包:纯 "@G0ldenMods\n@NGame_Official" → 两个裸 messageEntityMention含前导 @。
got := augmentAutoEntities("@G0ldenMods\n@NGame_Official", nil) got := testAugmentAutoEntities("@G0ldenMods\n@NGame_Official", nil)
mentionAt(t, got, 0, 11) // @G0ldenMods = 10+1 mentionAt(t, got, 0, 11) // @G0ldenMods = 10+1
mentionAt(t, got, 12, 15) // @NGame_Official = 14+1 mentionAt(t, got, 12, 15) // @NGame_Official = 14+1
}) })
t.Run("mention-mid-text", func(t *testing.T) { t.Run("mention-mid-text", func(t *testing.T) {
got := augmentAutoEntities("hi @alice see you", nil) got := testAugmentAutoEntities("hi @alice see you", nil)
mentionAt(t, got, 3, 6) // @alice mentionAt(t, got, 3, 6) // @alice
}) })
t.Run("email-not-a-mention", func(t *testing.T) { t.Run("email-not-a-mention", func(t *testing.T) {
for _, e := range augmentAutoEntities("mail me at bob@example.com please", nil) { for _, e := range testAugmentAutoEntities("mail me at bob@example.com please", nil) {
if _, ok := e.(*tg.MessageEntityMention); ok { if _, ok := e.(*tg.MessageEntityMention); ok {
t.Fatalf("email local@domain must not yield a mention: %#v", e) t.Fatalf("email local@domain must not yield a mention: %#v", e)
} }
@ -184,13 +263,13 @@ func TestAugmentAutoEntitiesMention(t *testing.T) {
}) })
t.Run("utf16-offset-with-emoji", func(t *testing.T) { t.Run("utf16-offset-with-emoji", func(t *testing.T) {
// 👍(2 units) + space(1) → @bob 起于 offset 3。 // 👍(2 units) + space(1) → @bob 起于 offset 3。
got := augmentAutoEntities("\U0001f44d @bob", nil) got := testAugmentAutoEntities("\U0001f44d @bob", nil)
mentionAt(t, got, 3, 4) mentionAt(t, got, 3, 4)
}) })
t.Run("no-overlap-with-client-entity", func(t *testing.T) { t.Run("no-overlap-with-client-entity", func(t *testing.T) {
// 客户端把 "@bob" 区间标成 textUrl[offset 0,len 4)),服务端不得再补 mention。 // 客户端把 "@bob" 区间标成 textUrl[offset 0,len 4)),服务端不得再补 mention。
ents := []tg.MessageEntityClass{&tg.MessageEntityTextURL{Offset: 0, Length: 4, URL: "https://x"}} ents := []tg.MessageEntityClass{&tg.MessageEntityTextURL{Offset: 0, Length: 4, URL: "https://x"}}
for _, e := range augmentAutoEntities("@bob", ents) { for _, e := range testAugmentAutoEntities("@bob", ents) {
if _, ok := e.(*tg.MessageEntityMention); ok { if _, ok := e.(*tg.MessageEntityMention); ok {
t.Fatalf("mention must not overlap client entity: %#v", e) t.Fatalf("mention must not overlap client entity: %#v", e)
} }
@ -201,7 +280,7 @@ func TestAugmentAutoEntitiesMention(t *testing.T) {
// 裸 URL 路径里的 @scam / #frag 不得被误标成 mention/hashtag钓鱼风险 // 裸 URL 路径里的 @scam / #frag 不得被误标成 mention/hashtag钓鱼风险
msg := "Docs see https://t.me/@scam and #promo" msg := "Docs see https://t.me/@scam and #promo"
ents := []tg.MessageEntityClass{&tg.MessageEntityTextURL{Offset: 0, Length: 4, URL: "https://x"}} ents := []tg.MessageEntityClass{&tg.MessageEntityTextURL{Offset: 0, Length: 4, URL: "https://x"}}
got := augmentAutoEntities(msg, ents) got := testAugmentAutoEntities(msg, ents)
for _, e := range got { for _, e := range got {
if m, ok := e.(*tg.MessageEntityMention); ok { if m, ok := e.(*tg.MessageEntityMention); ok {
t.Fatalf("mention must not be synthesised inside a raw URL: offset=%d len=%d", m.Offset, m.Length) t.Fatalf("mention must not be synthesised inside a raw URL: offset=%d len=%d", m.Offset, m.Length)
@ -220,7 +299,7 @@ func TestAugmentAutoEntitiesMention(t *testing.T) {
}) })
t.Run("hashtag", func(t *testing.T) { t.Run("hashtag", func(t *testing.T) {
var found bool var found bool
for _, e := range augmentAutoEntities("love #golang here", nil) { for _, e := range testAugmentAutoEntities("love #golang here", nil) {
if h, ok := e.(*tg.MessageEntityHashtag); ok && h.Offset == 5 && h.Length == 7 { if h, ok := e.(*tg.MessageEntityHashtag); ok && h.Offset == 5 && h.Length == 7 {
found = true found = true
} }
@ -230,7 +309,7 @@ func TestAugmentAutoEntitiesMention(t *testing.T) {
} }
}) })
t.Run("hashtag-all-digits-skipped", func(t *testing.T) { t.Run("hashtag-all-digits-skipped", func(t *testing.T) {
for _, e := range augmentAutoEntities("number #123 here", nil) { for _, e := range testAugmentAutoEntities("number #123 here", nil) {
if _, ok := e.(*tg.MessageEntityHashtag); ok { if _, ok := e.(*tg.MessageEntityHashtag); ok {
t.Fatalf("leading-digit hashtag must be skipped: %#v", e) t.Fatalf("leading-digit hashtag must be skipped: %#v", e)
} }
@ -238,7 +317,7 @@ func TestAugmentAutoEntitiesMention(t *testing.T) {
}) })
t.Run("bot-command", func(t *testing.T) { t.Run("bot-command", func(t *testing.T) {
var found bool var found bool
for _, e := range augmentAutoEntities("/start@MyBot now", nil) { for _, e := range testAugmentAutoEntities("/start@MyBot now", nil) {
if c, ok := e.(*tg.MessageEntityBotCommand); ok && c.Offset == 0 && c.Length == 12 { if c, ok := e.(*tg.MessageEntityBotCommand); ok && c.Offset == 0 && c.Length == 12 {
found = true found = true
} }
@ -248,7 +327,7 @@ func TestAugmentAutoEntitiesMention(t *testing.T) {
} }
}) })
t.Run("slash-in-path-not-command", func(t *testing.T) { t.Run("slash-in-path-not-command", func(t *testing.T) {
for _, e := range augmentAutoEntities("see and/or maybe", nil) { for _, e := range testAugmentAutoEntities("see and/or maybe", nil) {
if _, ok := e.(*tg.MessageEntityBotCommand); ok { if _, ok := e.(*tg.MessageEntityBotCommand); ok {
t.Fatalf("and/or must not be a bot command: %#v", e) t.Fatalf("and/or must not be a bot command: %#v", e)
} }
@ -256,7 +335,7 @@ func TestAugmentAutoEntitiesMention(t *testing.T) {
}) })
t.Run("cashtag", func(t *testing.T) { t.Run("cashtag", func(t *testing.T) {
var found bool var found bool
for _, e := range augmentAutoEntities("buy $USD now", nil) { for _, e := range testAugmentAutoEntities("buy $USD now", nil) {
if c, ok := e.(*tg.MessageEntityCashtag); ok && c.Offset == 4 && c.Length == 4 { if c, ok := e.(*tg.MessageEntityCashtag); ok && c.Offset == 4 && c.Length == 4 {
found = true found = true
} }