feat: sync GIFv and saved GIF support

This commit is contained in:
A 2026-07-11 21:43:13 +08:00
parent c0088f1160
commit 5f7c0b9804
21 changed files with 641 additions and 50 deletions

View file

@ -459,13 +459,18 @@ func tgDocumentAttributes(mimeType string, attrs []domain.DocumentAttribute) []t
Stickerset: tgInputStickerSetFromIDs(a.StickerSetID, a.StickerSetAccessHash),
})
case domain.DocAttrVideo:
out = append(out, &tg.DocumentAttributeVideo{
video := &tg.DocumentAttributeVideo{
RoundMessage: a.RoundMessage,
SupportsStreaming: a.SupportsStreaming,
Nosound: a.NoSound,
Duration: a.Duration,
W: a.W,
H: a.H,
})
}
if a.VideoCodec != "" {
video.SetVideoCodec(a.VideoCodec)
}
out = append(out, video)
case domain.DocAttrAudio:
attr := &tg.DocumentAttributeAudio{
Voice: a.Voice,

View file

@ -100,6 +100,9 @@ func stickersetInvalidErr() error { return tgerr.New(406, "STICKERSET_INVALID")
// stickerInvalidErr 表示输入文档不是合法贴纸/GIF(faveSticker/saveRecentSticker/saveGif)。
func stickerInvalidErr() error { return tgerr.New(400, "STICKER_DOCUMENT_INVALID") }
// gifIDInvalidErr 表示 messages.saveGif 引用的文档不存在或不是规范 GIFv。
func gifIDInvalidErr() error { return tgerr.New(400, "GIF_ID_INVALID") }
func mediaCaptionTooLongErr() error { return tgerr.New(400, "MEDIA_CAPTION_TOO_LONG") }
func replyMarkupInvalidErr() error { return tgerr.New(400, "REPLY_MARKUP_INVALID") }

View file

@ -246,7 +246,11 @@ func TestMessagesGetSearchCountersUsesMediaCategoryCounts(t *testing.T) {
send(4, "music", docMedia(4, domain.DocumentAttribute{Kind: domain.DocAttrAudio, Title: "song"}), nil)
send(5, "voice", docMedia(5, domain.DocumentAttribute{Kind: domain.DocAttrAudio, Voice: true}), nil)
send(6, "round", docMedia(6, domain.DocumentAttribute{Kind: domain.DocAttrVideo, RoundMessage: true}), nil)
send(7, "gif", docMedia(7, domain.DocumentAttribute{Kind: domain.DocAttrAnimated}), nil)
gif := docMedia(7,
domain.DocumentAttribute{Kind: domain.DocAttrAnimated},
domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: 320, H: 240, Duration: 1})
gif.Document.MimeType = "video/mp4"
send(7, "gif", gif, nil)
send(8, "poll", &domain.MessageMedia{Kind: domain.MessageMediaKindPoll}, nil)
filters := []tg.MessagesFilterClass{

View file

@ -1,7 +1,9 @@
package rpc
import (
"bytes"
"context"
"fmt"
"github.com/gotd/td/tg"
@ -27,6 +29,9 @@ func (r *Router) stickerCollectionSvc() (stickerCollectionService, bool) {
func (r *Router) stickerDocumentFromInput(ctx context.Context, input tg.InputDocumentClass, requireGif bool) (domain.Document, error) {
in, ok := input.(*tg.InputDocument)
if !ok || in.ID == 0 || r.deps.Files == nil {
if requireGif {
return domain.Document{}, gifIDInvalidErr()
}
return domain.Document{}, stickerInvalidErr()
}
doc, found, err := r.deps.Files.GetDocument(ctx, in.ID)
@ -34,6 +39,9 @@ func (r *Router) stickerDocumentFromInput(ctx context.Context, input tg.InputDoc
return domain.Document{}, internalErr()
}
ok = found && doc.AccessHash == in.AccessHash
if ok && len(in.FileReference) > 0 && !bytes.Equal(in.FileReference, doc.FileReference) {
ok = false
}
if ok {
if requireGif {
ok = doc.IsGif()
@ -42,6 +50,9 @@ func (r *Router) stickerDocumentFromInput(ctx context.Context, input tg.InputDoc
}
}
if !ok {
if requireGif {
return domain.Document{}, gifIDInvalidErr()
}
return domain.Document{}, stickerInvalidErr()
}
return doc, nil
@ -95,7 +106,7 @@ func (r *Router) onMessagesSaveRecentSticker(ctx context.Context, req *tg.Messag
func (r *Router) onMessagesSaveGif(ctx context.Context, req *tg.MessagesSaveGifRequest) (bool, error) {
if req == nil {
return false, stickerInvalidErr()
return false, gifIDInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
@ -137,7 +148,10 @@ func (r *Router) onMessagesGetFavedStickers(ctx context.Context, hash int64) (tg
if err != nil {
return nil, internalErr()
}
docs := r.stickerCollectionDocuments(ctx, userID, domain.StickerCollectionFaved, nil)
docs, err := r.stickerCollectionDocuments(ctx, userID, domain.StickerCollectionFaved, nil)
if err != nil {
return nil, internalErr()
}
catalogHash := stickerDocumentsHash(docs)
if hash != 0 && hash == catalogHash {
return &tg.MessagesFavedStickersNotModified{}, nil
@ -159,7 +173,10 @@ func (r *Router) onMessagesGetRecentStickers(ctx context.Context, req *tg.Messag
kind = domain.StickerCollectionRecentAttached
}
var dates []int
docs := r.stickerCollectionDocuments(ctx, userID, kind, &dates)
docs, err := r.stickerCollectionDocuments(ctx, userID, kind, &dates)
if err != nil {
return nil, internalErr()
}
catalogHash := stickerDocumentsHash(docs)
if req != nil && req.Hash != 0 && req.Hash == catalogHash {
return &tg.MessagesRecentStickersNotModified{}, nil
@ -177,7 +194,10 @@ func (r *Router) onMessagesGetSavedGifs(ctx context.Context, hash int64) (tg.Mes
if err != nil {
return nil, internalErr()
}
docs := r.stickerCollectionDocuments(ctx, userID, domain.StickerCollectionGif, nil)
docs, err := r.stickerCollectionDocuments(ctx, userID, domain.StickerCollectionGif, nil)
if err != nil {
return nil, internalErr()
}
catalogHash := stickerDocumentsHash(docs)
if hash != 0 && hash == catalogHash {
return &tg.MessagesSavedGifsNotModified{}, nil
@ -186,21 +206,24 @@ func (r *Router) onMessagesGetSavedGifs(ctx context.Context, hash int64) (tg.Mes
}
// stickerCollectionDocuments 取某集合并解析为完整文档(最新在前,顺序与集合一致)。
// 解析不到的文档(已删/不可用)跳过。若 datesOut 非 nil,按相同顺序填充 used_at。
func (r *Router) stickerCollectionDocuments(ctx context.Context, userID int64, kind domain.StickerCollectionKind, datesOut *[]int) []domain.Document {
// 集合引用缺失或类型错误属于坏数据并 fail-fast;若 datesOut 非 nil,按相同顺序填充 used_at。
func (r *Router) stickerCollectionDocuments(ctx context.Context, userID int64, kind domain.StickerCollectionKind, datesOut *[]int) ([]domain.Document, error) {
svc, ok := r.stickerCollectionSvc()
if !ok || r.deps.Files == nil {
if datesOut != nil {
*datesOut = []int{}
}
return nil
return nil, nil
}
items, err := svc.ListStickerCollection(ctx, userID, kind, domain.MaxStickerCollectionItems(kind))
if err != nil || len(items) == 0 {
if err != nil {
return nil, err
}
if len(items) == 0 {
if datesOut != nil {
*datesOut = []int{}
}
return nil
return nil, nil
}
ids := make([]int64, 0, len(items))
dateByID := make(map[int64]int, len(items))
@ -210,10 +233,7 @@ func (r *Router) stickerCollectionDocuments(ctx context.Context, userID int64, k
}
resolved, err := r.deps.Files.GetDocuments(ctx, ids)
if err != nil {
if datesOut != nil {
*datesOut = []int{}
}
return nil
return nil, err
}
byID := documentsByID(resolved)
docs := make([]domain.Document, 0, len(items))
@ -221,7 +241,13 @@ func (r *Router) stickerCollectionDocuments(ctx context.Context, userID int64, k
for _, id := range ids { // 保持集合顺序(最新在前)
doc, ok := byID[id]
if !ok {
continue
return nil, fmt.Errorf("sticker collection %s references missing document %d", kind, id)
}
if kind == domain.StickerCollectionGif && !doc.IsGif() {
return nil, fmt.Errorf("saved gif collection references non-GIFv document %d", id)
}
if kind != domain.StickerCollectionGif && !doc.IsSticker() {
return nil, fmt.Errorf("sticker collection %s references non-sticker document %d", kind, id)
}
docs = append(docs, doc)
dates = append(dates, dateByID[id])
@ -229,7 +255,7 @@ func (r *Router) stickerCollectionDocuments(ctx context.Context, userID int64, k
if datesOut != nil {
*datesOut = dates
}
return docs
return docs, nil
}
func stickerDocumentsHash(docs []domain.Document) int64 {

View file

@ -14,20 +14,28 @@ import (
"telesrv/internal/store/memory"
)
func stickerCollectionRouter(t *testing.T) *Router {
func stickerCollectionRouter(t *testing.T) (*Router, *captureSessions) {
t.Helper()
files := &fakeFiles{docs: map[int64]domain.Document{
101: {ID: 101, AccessHash: 11, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
102: {ID: 102, AccessHash: 12, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
201: {ID: 201, AccessHash: 21, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrAnimated}}},
201: {ID: 201, AccessHash: 21, MimeType: "video/mp4", Attributes: []domain.DocumentAttribute{
{Kind: domain.DocAttrAnimated},
{Kind: domain.DocAttrVideo, W: 320, H: 240, Duration: 1},
}},
202: {ID: 202, AccessHash: 22, MimeType: "video/mp4", Attributes: []domain.DocumentAttribute{
{Kind: domain.DocAttrAnimated},
{Kind: domain.DocAttrVideo, W: 640, H: 360, Duration: 2},
}},
301: {ID: 301, AccessHash: 31, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrAudio}}},
}}
passwordStore := memory.NewPasswordStore()
sessions := &captureSessions{}
return New(Config{}, Deps{
Account: appaccount.NewService(passwordStore, appaccount.WithStickerCollections(passwordStore)),
Files: files,
Sessions: &captureSessions{},
}, zaptest.NewLogger(t), clock.System)
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System), sessions
}
func inputDoc(id, accessHash int64) *tg.InputDocument {
@ -36,7 +44,7 @@ func inputDoc(id, accessHash int64) *tg.InputDocument {
// TestFavedStickersRoundTrip 回归:faveSticker/getFavedStickers 此前未注册/返空。
func TestFavedStickersRoundTrip(t *testing.T) {
r := stickerCollectionRouter(t)
r, _ := stickerCollectionRouter(t)
ctx := WithUserID(context.Background(), 1000000001)
// 非贴纸文档拒绝。
@ -80,7 +88,7 @@ func TestFavedStickersRoundTrip(t *testing.T) {
// TestRecentStickersRoundTrip 验证 saveRecentSticker/getRecentStickers + dates + clear。
func TestRecentStickersRoundTrip(t *testing.T) {
r := stickerCollectionRouter(t)
r, _ := stickerCollectionRouter(t)
ctx := WithUserID(context.Background(), 1000000001)
if ok, err := r.onMessagesSaveRecentSticker(ctx, &tg.MessagesSaveRecentStickerRequest{ID: inputDoc(101, 11)}); err != nil || !ok {
@ -110,22 +118,48 @@ func TestRecentStickersRoundTrip(t *testing.T) {
// TestSavedGifsRoundTrip 验证 saveGif/getSavedGifs + 非 GIF 拒绝。
func TestSavedGifsRoundTrip(t *testing.T) {
r := stickerCollectionRouter(t)
r, sessions := stickerCollectionRouter(t)
ctx := WithUserID(context.Background(), 1000000001)
// 非 GIF(贴纸)拒绝。
if ok, err := r.onMessagesSaveGif(ctx, &tg.MessagesSaveGifRequest{ID: inputDoc(101, 11)}); ok || !tgerr.Is(err, "STICKER_DOCUMENT_INVALID") {
t.Fatalf("save non-gif = ok %v err %v, want STICKER_DOCUMENT_INVALID", ok, err)
if ok, err := r.onMessagesSaveGif(ctx, &tg.MessagesSaveGifRequest{ID: inputDoc(101, 11)}); ok || !tgerr.Is(err, "GIF_ID_INVALID") {
t.Fatalf("save non-gif = ok %v err %v, want GIF_ID_INVALID", ok, err)
}
if ok, err := r.onMessagesSaveGif(ctx, &tg.MessagesSaveGifRequest{ID: inputDoc(201, 21)}); err != nil || !ok {
t.Fatalf("save gif = ok %v err %v", ok, err)
}
if pushed, ok := sessions.lastUserPush().(*tg.Updates); !ok || len(pushed.Updates) != 1 {
t.Fatalf("save gif push = %T %+v, want updateSavedGifs", sessions.lastUserPush(), pushed)
} else if _, ok := pushed.Updates[0].(*tg.UpdateSavedGifs); !ok {
t.Fatalf("save gif update = %T, want *tg.UpdateSavedGifs", pushed.Updates[0])
}
if ok, err := r.onMessagesSaveGif(ctx, &tg.MessagesSaveGifRequest{ID: inputDoc(202, 22)}); err != nil || !ok {
t.Fatalf("save second gif = ok %v err %v", ok, err)
}
out, err := r.onMessagesGetSavedGifs(ctx, 0)
if err != nil {
t.Fatalf("get saved gifs: %v", err)
}
if got := out.(*tg.MessagesSavedGifs); len(got.Gifs) != 1 {
t.Fatalf("saved gifs = %d, want 1", len(got.Gifs))
full := out.(*tg.MessagesSavedGifs)
if len(full.Gifs) != 2 || full.Gifs[0].(*tg.Document).ID != 202 {
t.Fatalf("saved gifs = %+v, want newest 202 first", full.Gifs)
}
again, err := r.onMessagesGetSavedGifs(ctx, full.Hash)
if err != nil {
t.Fatalf("get saved gifs by hash: %v", err)
}
if _, ok := again.(*tg.MessagesSavedGifsNotModified); !ok {
t.Fatalf("get saved gifs by hash = %T, want NotModified", again)
}
if ok, err := r.onMessagesSaveGif(ctx, &tg.MessagesSaveGifRequest{ID: inputDoc(201, 21), Unsave: true}); err != nil || !ok {
t.Fatalf("unsave gif = ok %v err %v", ok, err)
}
out, err = r.onMessagesGetSavedGifs(ctx, 0)
if err != nil {
t.Fatalf("get after unsave: %v", err)
}
if got := out.(*tg.MessagesSavedGifs); len(got.Gifs) != 1 || got.Gifs[0].(*tg.Document).ID != 202 {
t.Fatalf("saved gifs after unsave = %+v, want [202]", got.Gifs)
}
}

View file

@ -530,9 +530,10 @@ func (r *Router) resolveInputMedia(ctx context.Context, userID int64, input tg.I
return nil, fileReferenceInvalidErr()
}
spec := domain.DocumentSpec{
MimeType: in.MimeType,
Attributes: domainDocumentAttributes(in.Attributes),
ForceFile: in.ForceFile,
MimeType: in.MimeType,
Attributes: domainDocumentAttributes(in.Attributes),
ForceFile: in.ForceFile,
NosoundVideo: in.NosoundVideo,
}
if thumb, ok := in.GetThumb(); ok {
if tref, ok := uploadedFileRef(userID, thumb); ok {
@ -951,7 +952,7 @@ func domainDocumentAttributes(attrs []tg.DocumentAttributeClass) []domain.Docume
}
out = append(out, attr)
case *tg.DocumentAttributeVideo:
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: v.W, H: v.H, Duration: v.Duration, RoundMessage: v.RoundMessage, SupportsStreaming: v.SupportsStreaming})
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: v.W, H: v.H, Duration: v.Duration, RoundMessage: v.RoundMessage, SupportsStreaming: v.SupportsStreaming, NoSound: v.Nosound, VideoCodec: v.VideoCodec})
case *tg.DocumentAttributeAudio:
out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: v.Duration, Voice: v.Voice, Title: v.Title, Performer: v.Performer, Waveform: v.Waveform})
case *tg.DocumentAttributeFilename: