From 10de46219110ec38f5e4cf154c8c6770a1da99b1 Mon Sep 17 00:00:00 2001 From: A Date: Wed, 22 Jul 2026 21:42:52 +0800 Subject: [PATCH] fix: sync web page preview pending deadline --- internal/rpc/messages_compat.go | 2 +- .../rpc/messages_send_webpage_rpc_test.go | 44 +++++++++++++++++++ internal/rpc/webpage_resolver.go | 4 ++ internal/rpc/webpage_url_extract.go | 7 +-- 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/internal/rpc/messages_compat.go b/internal/rpc/messages_compat.go index af120cdb..919b0948 100644 --- a/internal/rpc/messages_compat.go +++ b/internal/rpc/messages_compat.go @@ -380,7 +380,7 @@ func (r *Router) webPagePreviewMedia(ctx context.Context, message string, entiti // resolveWebPageForRequest 为交互式读 RPC 解析链接预览:先查缓存(LookupWebPage,命中即返回, // 不抓取不阻塞);未命中才同步抓取,但用受限短预算(webpageRequestResolveBudget)而非异步解析 -// 的 20s,避免慢/挂上游把 RPC worker 钉死。命中(含负缓存的 empty)返回 ok=true,调用方据 state +// 的 30s,避免慢/挂上游把 RPC worker 钉死。命中(含负缓存的 empty)返回 ok=true,调用方据 state // 决定;抓取失败返回 false。未启用返回 false。 func (r *Router) resolveWebPageForRequest(ctx context.Context, url string) (domain.MessageWebPage, bool) { if page, ok := r.resolveAIComposeStyleWebPage(ctx, url); ok { diff --git a/internal/rpc/messages_send_webpage_rpc_test.go b/internal/rpc/messages_send_webpage_rpc_test.go index 5f3be62a..47b56109 100644 --- a/internal/rpc/messages_send_webpage_rpc_test.go +++ b/internal/rpc/messages_send_webpage_rpc_test.go @@ -3,6 +3,7 @@ package rpc import ( "context" "testing" + "time" "github.com/iamxvbaba/td/tg" @@ -27,6 +28,8 @@ func TestSendMessageAttachesWebPagePending(t *testing.T) { ctx := context.Background() r, owner, friend := newMediaTestRouter(t) r.deps.Files.(*fakeFiles).webPagePreviewOn = true + now := time.Date(2030, time.January, 2, 3, 4, 5, 0, time.UTC) + r.clock = fixedClock{now: now} updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, @@ -55,11 +58,52 @@ func TestSendMessageAttachesWebPagePending(t *testing.T) { if url, _ := pending.GetURL(); url != wpTestURL { t.Errorf("pending url = %q, want %q", url, wpTestURL) } + wantDeadline := int(now.Add(webPagePendingLifetime).Unix()) + if pending.Date != wantDeadline { + t.Errorf("pending date = %d, want retry deadline %d", pending.Date, wantDeadline) + } + if time.Unix(int64(pending.Date), 0).Sub(now) <= webPageResolveTimeout { + t.Errorf("pending deadline must outlive resolver timeout: deadline=%s timeout=%s", time.Unix(int64(pending.Date), 0), webPageResolveTimeout) + } if !msg.InvertMedia { t.Errorf("invert_media not projected onto message") } } +// TestSendChannelMessageUsesSameFutureWebPageDeadline 锁定频道发送也经过同一 pending +// 截止时间构造路径,避免只修私聊 echo 而频道仍被客户端立即判定过期。 +func TestSendChannelMessageUsesSameFutureWebPageDeadline(t *testing.T) { + ctx := context.Background() + r, owner, channel := newRichChannelTestRouter(t) + r.deps.Files.(*fakeFiles).webPagePreviewOn = true + // 本测试只验证发送投影,不启动异步解析 goroutine。 + r.webPageResolveSem = nil + now := time.Date(2030, time.February, 3, 4, 5, 6, 0, time.UTC) + r.clock = fixedClock{now: now} + + updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: wpTestMessage, + Entities: wpURLEntities(), + RandomID: 5106, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + msg := newMessageFromUpdates(t, updates) + wrap, ok := msg.Media.(*tg.MessageMediaWebPage) + if !ok { + t.Fatalf("channel media = %T, want *tg.MessageMediaWebPage", msg.Media) + } + pending, ok := wrap.Webpage.(*tg.WebPagePending) + if !ok { + t.Fatalf("channel webpage = %T, want *tg.WebPagePending", wrap.Webpage) + } + if want := int(now.Add(webPagePendingLifetime).Unix()); pending.Date != want { + t.Errorf("channel pending date = %d, want retry deadline %d", pending.Date, want) + } +} + // TestSendMessageAttachesCachedDoneCard 验证:URL 已缓存解析时,发送 echo 直接带 done 卡片 // (非 pending)——官方行为,TDesktop 据此立即渲染、不依赖异步换卡。 func TestSendMessageAttachesCachedDoneCard(t *testing.T) { diff --git a/internal/rpc/webpage_resolver.go b/internal/rpc/webpage_resolver.go index ff435f4a..7d89086b 100644 --- a/internal/rpc/webpage_resolver.go +++ b/internal/rpc/webpage_resolver.go @@ -18,6 +18,10 @@ import ( const ( webPageResolveConcurrency = 16 webPageResolveTimeout = 30 * time.Second + // webPagePendingLifetime 是客户端在重新拉取 pending 消息前等待的窗口。 + // TDesktop 与 DrKLO 都把 webPagePending.date 解释为绝对截止时间,而不是处理开始时间; + // 该窗口必须严格大于 resolver 的最大执行时间,避免正常的慢解析被客户端提前标记为失败。 + webPagePendingLifetime = 2 * time.Minute ) type webPageResolveJob struct { diff --git a/internal/rpc/webpage_url_extract.go b/internal/rpc/webpage_url_extract.go index a778c89a..155eaca0 100644 --- a/internal/rpc/webpage_url_extract.go +++ b/internal/rpc/webpage_url_extract.go @@ -135,9 +135,10 @@ func (r *Router) webPagePendingOrCachedMedia(ctx context.Context, rawURL string, State: domain.MessageWebPageStatePending, ID: domain.WebPageURLHash(normalized), URL: normalized, - // Date=「processing started」时刻:留 0(=1970)会被严格客户端判为 pending 早已 - // 过期 → 直接显示纯文本,须填发送时刻。 - Date: int(r.clock.Now().Unix()), + // date 是客户端重新拉取 pending 消息的绝对截止时间。TDesktop/DrKLO 在 + // date<=now 时立即重取;若 resolver 此时仍运行,TDesktop 会把占位记成 sticky + // failed,后到的 done update 也不会重新显示卡片。 + Date: int(r.clock.Now().Add(webPagePendingLifetime).Unix()), ForceLargeMedia: forceLarge, ForceSmallMedia: forceSmall, },