From 5f7c0b98040058fb8160b8b1b5019aecc1faf0f7 Mon Sep 17 00:00:00 2001 From: A Date: Sat, 11 Jul 2026 21:43:13 +0800 Subject: [PATCH] feat: sync GIFv and saved GIF support --- README.md | 4 +- README.zh-CN.md | 4 +- ...aved_gif_order_and_legacy_cleanup.down.sql | 5 + ..._saved_gif_order_and_legacy_cleanup.up.sql | 81 ++++++++ internal/app/files/gif_transcoder.go | 177 ++++++++++++++++++ internal/app/files/gif_transcoder_test.go | 39 ++++ internal/app/files/photos.go | 66 +++++++ internal/app/files/photos_test.go | 101 ++++++++++ internal/app/files/seed.go | 4 +- internal/app/files/service.go | 18 ++ internal/domain/media.go | 23 ++- internal/domain/media_category.go | 6 +- internal/domain/media_category_test.go | 3 +- internal/rpc/convert_media.go | 9 +- internal/rpc/errors.go | 3 + internal/rpc/messages_history_rpc_test.go | 6 +- internal/rpc/messages_stickers_personal.go | 56 ++++-- .../messages_stickers_personal_rpc_test.go | 56 ++++-- internal/rpc/send_media.go | 9 +- internal/store/postgres/account.go | 10 +- .../account_stickers_integration_test.go | 11 ++ 21 files changed, 641 insertions(+), 50 deletions(-) create mode 100644 deploy/migrations/0081_saved_gif_order_and_legacy_cleanup.down.sql create mode 100644 deploy/migrations/0081_saved_gif_order_and_legacy_cleanup.up.sql create mode 100644 internal/app/files/gif_transcoder.go create mode 100644 internal/app/files/gif_transcoder_test.go diff --git a/README.md b/README.md index a9e821ef..5648e135 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,8 @@ codebase. | ✅ | Rich messages | Telegram Desktop rich text messages, rich content conversion, send/edit/scheduled flows, dialog/history projections, and memory/PostgreSQL persistence. | | ✅ | AI compose and ChatBot | Input-box rewrite/polish, default and custom tones, addstyle previews, local and external provider chains, streamed `@ChatBot` draft replies, and Business AI reply hooks. | | ✅ | Supergroups and channels | Create, join, leave, invite links, participants, admins, forum topics, history, send/edit/delete/read, reactions, public search, and previews. | -| ✅ | Media and files | Upload, download, local blob storage, photos, documents, thumbnails, external media fetch, web page previews, map tile cache hooks, profile/channel photos. | -| ✅ | Stickers and reactions | Sticker/reaction catalog, seed support, recent reactions, top reactions, default reactions, and moderation-oriented reaction paths. | +| ✅ | Media and files | Upload, download, local blob storage, photos, documents, thumbnails, canonical GIFv conversion, external media fetch, web page previews, map tile cache hooks, profile/channel photos. | +| ✅ | Stickers and reactions | Sticker/reaction catalog, seed support, saved GIFs, recent reactions, top reactions, default reactions, and moderation-oriented reaction paths. | | ✅ | Gifts and stars | Star gifts and local stars ledger foundations for compatibility and future feature work. | | ✅ | Bots and mini apps | Bot service foundations, callbacks, inline helpers, webview/mini-app paths, a minimal Bot API gateway for libraries such as `python-telegram-bot`, persistent `getUpdates` delivery, and demo tools. | | ✅ | Calls and live streams | Private call signaling foundations, group call state, RTMP live streaming, scheduled video chats, channel `join_as`, SFU/TURN building blocks, liveness, and expiry workers. | diff --git a/README.zh-CN.md b/README.zh-CN.md index c46321d5..dff223ee 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -46,8 +46,8 @@ https://github.com/user-attachments/assets/25e651dc-a022-4d60-8b9b-ca3e8bfe216c | ✅ | 富文本消息 | Telegram Desktop rich text message、富文本内容转换、send/edit/scheduled 流程、dialog/history 投影,以及 memory/PostgreSQL 持久化。 | | ✅ | AI 输入框与 ChatBot | 输入框改写/润色、默认和自定义 tone、addstyle 预览、本地与外部 provider 链、流式 `@ChatBot` 草稿回复、Business AI 回复钩子。 | | ✅ | 超级群与频道 | create、join、leave、邀请链接、成员、管理员、forum topics、history、send/edit/delete/read、reactions、公开搜索和预览。 | -| ✅ | 媒体与文件 | upload、download、本地 blob 存储、照片、文档、缩略图、外链媒体抓取、网页预览、地图缩略图缓存、用户/频道头像。 | -| ✅ | Stickers 与 Reactions | sticker/reaction catalog、seed 支持、recent reactions、top reactions、default reactions、reaction moderation 相关路径。 | +| ✅ | 媒体与文件 | upload、download、本地 blob 存储、照片、文档、缩略图、规范 GIFv 转换、外链媒体抓取、网页预览、地图缩略图缓存、用户/频道头像。 | +| ✅ | Stickers 与 Reactions | sticker/reaction catalog、seed 支持、saved GIFs、recent reactions、top reactions、default reactions、reaction moderation 相关路径。 | | ✅ | Gifts 与 Stars | star gifts、本地 stars ledger 基础,用于兼容和后续功能扩展。 | | ✅ | Bots 与 Mini Apps | bot 服务基础、callbacks、inline helpers、webview/mini-app 路径、适配 `python-telegram-bot` 等库的最小 Bot API gateway、持久化 `getUpdates` 投递队列和 demo 工具。 | | ✅ | 通话与直播 | 私聊通话信令基础、group call 状态、RTMP live stream、定时视频通话、频道 `join_as` 身份、SFU/TURN building blocks、liveness 与 expiry worker。 | diff --git a/deploy/migrations/0081_saved_gif_order_and_legacy_cleanup.down.sql b/deploy/migrations/0081_saved_gif_order_and_legacy_cleanup.down.sql new file mode 100644 index 00000000..84b18b40 --- /dev/null +++ b/deploy/migrations/0081_saved_gif_order_and_legacy_cleanup.down.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS user_sticker_collections_order_idx; +CREATE INDEX user_sticker_collections_order_idx + ON user_sticker_collections (owner_user_id, kind, used_at DESC); +ALTER TABLE user_sticker_collections DROP COLUMN IF EXISTS order_key; +DROP SEQUENCE IF EXISTS user_sticker_collections_order_key_seq; diff --git a/deploy/migrations/0081_saved_gif_order_and_legacy_cleanup.up.sql b/deploy/migrations/0081_saved_gif_order_and_legacy_cleanup.up.sql new file mode 100644 index 00000000..f5f02120 --- /dev/null +++ b/deploy/migrations/0081_saved_gif_order_and_legacy_cleanup.up.sql @@ -0,0 +1,81 @@ +CREATE SEQUENCE IF NOT EXISTS user_sticker_collections_order_key_seq; + +ALTER TABLE user_sticker_collections ADD COLUMN order_key bigint; + +WITH ordered AS ( + SELECT ctid, row_number() OVER ( + ORDER BY owner_user_id, kind, used_at, document_id + ) AS n + FROM user_sticker_collections +) +UPDATE user_sticker_collections AS c +SET order_key = ordered.n +FROM ordered +WHERE c.ctid = ordered.ctid; + +SELECT setval( + 'user_sticker_collections_order_key_seq', + GREATEST(COALESCE((SELECT max(order_key) FROM user_sticker_collections), 0), 1), + true +); + +ALTER TABLE user_sticker_collections + ALTER COLUMN order_key SET DEFAULT nextval('user_sticker_collections_order_key_seq'), + ALTER COLUMN order_key SET NOT NULL; + +DROP INDEX IF EXISTS user_sticker_collections_order_idx; +CREATE INDEX user_sticker_collections_order_idx + ON user_sticker_collections (owner_user_id, kind, order_key DESC); + +-- Saved GIFs must reference canonical GIFv documents. Remove dangling/raw-GIF +-- entries explicitly instead of making getSavedGifs silently skip bad state. +DELETE FROM user_sticker_collections AS c +WHERE c.kind = 'gif' + AND NOT EXISTS ( + SELECT 1 + FROM documents AS d + WHERE d.id = c.document_id + AND lower(d.mime_type) = 'video/mp4' + AND EXISTS ( + SELECT 1 FROM jsonb_array_elements(d.attributes) a + WHERE a->>'kind' = 'animated' + ) + AND EXISTS ( + SELECT 1 FROM jsonb_array_elements(d.attributes) a + WHERE a->>'kind' = 'video' + AND COALESCE((a->>'round_message')::boolean, false) = false + AND COALESCE((a->>'w')::int, 0) > 0 + AND COALESCE((a->>'h')::int, 0) > 0 + AND COALESCE((a->>'duration')::double precision, 0) > 0 + ) + ); + +-- Legacy raw image/gif rows are ordinary files, not TDesktop GIFv. Repair the +-- durable shared-media indexes without pretending their bytes were transcoded. +DELETE FROM message_box_media AS i +USING message_boxes AS b +WHERE i.owner_user_id = b.owner_user_id + AND i.box_id = b.box_id + AND i.category = 3 + AND lower(COALESCE(b.media #>> '{document,mime_type}', '')) = 'image/gif'; + +INSERT INTO message_box_media (owner_user_id, box_id, peer_id, category, message_date) +SELECT b.owner_user_id, b.box_id, b.peer_id, 4, b.message_date +FROM message_boxes AS b +WHERE NOT b.deleted + AND lower(COALESCE(b.media #>> '{document,mime_type}', '')) = 'image/gif' +ON CONFLICT DO NOTHING; + +DELETE FROM channel_message_media AS i +USING channel_messages AS m +WHERE i.channel_id = m.channel_id + AND i.id = m.id + AND i.category = 3 + AND lower(COALESCE(m.media #>> '{document,mime_type}', '')) = 'image/gif'; + +INSERT INTO channel_message_media (channel_id, id, category, message_date) +SELECT m.channel_id, m.id, 4, m.message_date +FROM channel_messages AS m +WHERE NOT m.deleted + AND lower(COALESCE(m.media #>> '{document,mime_type}', '')) = 'image/gif' +ON CONFLICT DO NOTHING; diff --git a/internal/app/files/gif_transcoder.go b/internal/app/files/gif_transcoder.go new file mode 100644 index 00000000..e4ed5787 --- /dev/null +++ b/internal/app/files/gif_transcoder.go @@ -0,0 +1,177 @@ +package files + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +const ( + gifTranscodeTimeout = 20 * time.Second + gifTranscodeMaxInputBytes = 50 << 20 + gifTranscodeMaxOutputBytes = 200 << 20 + gifTranscodeMaxConcurrent = 2 +) + +// GIFVideo 是服务端把真实 GIF 规范化为 Telegram GIFv 后的结果。 +type GIFVideo struct { + Data []byte + Width int + Height int + Duration float64 +} + +// GIFTranscoder 必须输出无声、faststart 的 H.264 MP4,并返回可持久化的视频元数据。 +type GIFTranscoder interface { + Transcode(ctx context.Context, data []byte) (GIFVideo, error) +} + +type FFmpegGIFTranscoder struct { + ffmpeg string + ffprobe string + timeout time.Duration + slots chan struct{} +} + +func NewFFmpegGIFTranscoder() (*FFmpegGIFTranscoder, error) { + ffmpeg, err := exec.LookPath("ffmpeg") + if err != nil { + return nil, err + } + ffprobeName := "ffprobe" + if ext := filepath.Ext(ffmpeg); ext != "" { + ffprobeName += ext + } + ffprobe := filepath.Join(filepath.Dir(ffmpeg), ffprobeName) + if _, err := os.Stat(ffprobe); err != nil { + ffprobe, err = exec.LookPath("ffprobe") + if err != nil { + return nil, err + } + } + return &FFmpegGIFTranscoder{ + ffmpeg: ffmpeg, ffprobe: ffprobe, timeout: gifTranscodeTimeout, + slots: make(chan struct{}, gifTranscodeMaxConcurrent), + }, nil +} + +func (t *FFmpegGIFTranscoder) Transcode(ctx context.Context, data []byte) (GIFVideo, error) { + if t == nil || t.ffmpeg == "" || t.ffprobe == "" { + return GIFVideo{}, fmt.Errorf("gif transcoder unavailable") + } + if len(data) == 0 || len(data) > gifTranscodeMaxInputBytes { + return GIFVideo{}, fmt.Errorf("gif input size out of range: %d", len(data)) + } + select { + case t.slots <- struct{}{}: + defer func() { <-t.slots }() + case <-ctx.Done(): + return GIFVideo{}, ctx.Err() + } + runCtx, cancel := context.WithTimeout(ctx, t.timeout) + defer cancel() + + input, err := os.CreateTemp("", "telesrv-gif-*.gif") + if err != nil { + return GIFVideo{}, fmt.Errorf("create gif input: %w", err) + } + inputPath := input.Name() + defer os.Remove(inputPath) + if _, err := input.Write(data); err != nil { + input.Close() + return GIFVideo{}, fmt.Errorf("write gif input: %w", err) + } + if err := input.Close(); err != nil { + return GIFVideo{}, fmt.Errorf("close gif input: %w", err) + } + output, err := os.CreateTemp("", "telesrv-gifv-*.mp4") + if err != nil { + return GIFVideo{}, fmt.Errorf("create gif output: %w", err) + } + outputPath := output.Name() + output.Close() + defer os.Remove(outputPath) + + cmd := exec.CommandContext(runCtx, t.ffmpeg, + "-hide_banner", "-loglevel", "error", "-y", + "-i", inputPath, "-map", "0:v:0", "-an", + "-vf", "scale=ceil(iw/2)*2:ceil(ih/2)*2:flags=lanczos", + "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", + "-pix_fmt", "yuv420p", "-movflags", "+faststart", outputPath) + stderr, err := cmd.CombinedOutput() + if runCtx.Err() != nil { + return GIFVideo{}, runCtx.Err() + } + if err != nil { + return GIFVideo{}, commandError("ffmpeg gif transcode", err, stderr) + } + info, err := os.Stat(outputPath) + if err != nil || info.Size() <= 0 || info.Size() > gifTranscodeMaxOutputBytes { + return GIFVideo{}, fmt.Errorf("gif output size invalid") + } + meta, err := t.probe(runCtx, outputPath) + if err != nil { + return GIFVideo{}, err + } + video, err := os.ReadFile(outputPath) + if err != nil { + return GIFVideo{}, fmt.Errorf("read gif output: %w", err) + } + meta.Data = video + return meta, nil +} + +func (t *FFmpegGIFTranscoder) probe(ctx context.Context, path string) (GIFVideo, error) { + cmd := exec.CommandContext(ctx, t.ffprobe, "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=width,height,duration:format=duration", "-of", "json", path) + out, err := cmd.CombinedOutput() + if err != nil { + return GIFVideo{}, commandError("ffprobe gif output", err, out) + } + var result struct { + Streams []struct { + Width, Height int + Duration string + } `json:"streams"` + Format struct { + Duration string `json:"duration"` + } `json:"format"` + } + if err := json.Unmarshal(out, &result); err != nil || len(result.Streams) != 1 { + return GIFVideo{}, fmt.Errorf("invalid ffprobe gif metadata") + } + duration := parsePositiveFloat(result.Streams[0].Duration) + if duration == 0 { + duration = parsePositiveFloat(result.Format.Duration) + } + stream := result.Streams[0] + if stream.Width <= 0 || stream.Height <= 0 || duration <= 0 { + return GIFVideo{}, fmt.Errorf("incomplete gif video metadata") + } + return GIFVideo{Width: stream.Width, Height: stream.Height, Duration: duration}, nil +} + +func parsePositiveFloat(value string) float64 { + v, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil || v <= 0 { + return 0 + } + return v +} + +func commandError(op string, err error, output []byte) error { + msg := strings.TrimSpace(string(output)) + if len(msg) > 512 { + msg = msg[:512] + } + if msg == "" { + return fmt.Errorf("%s: %w", op, err) + } + return fmt.Errorf("%s: %w: %s", op, err, msg) +} diff --git a/internal/app/files/gif_transcoder_test.go b/internal/app/files/gif_transcoder_test.go new file mode 100644 index 00000000..9460a6c0 --- /dev/null +++ b/internal/app/files/gif_transcoder_test.go @@ -0,0 +1,39 @@ +package files + +import ( + "bytes" + "context" + "image" + "image/color" + "image/gif" + "testing" +) + +func TestFFmpegGIFTranscoderProducesCanonicalMP4(t *testing.T) { + transcoder, err := NewFFmpegGIFTranscoder() + if err != nil { + t.Skipf("ffmpeg/ffprobe unavailable: %v", err) + } + palette := color.Palette{color.Black, color.White} + first := image.NewPaletted(image.Rect(0, 0, 3, 5), palette) + second := image.NewPaletted(image.Rect(0, 0, 3, 5), palette) + for i := range second.Pix { + second.Pix[i] = 1 + } + var input bytes.Buffer + if err := gif.EncodeAll(&input, &gif.GIF{ + Image: []*image.Paletted{first, second}, Delay: []int{10, 10}, LoopCount: 0, + }); err != nil { + t.Fatal(err) + } + result, err := transcoder.Transcode(context.Background(), input.Bytes()) + if err != nil { + t.Fatalf("Transcode: %v", err) + } + if len(result.Data) < 12 || string(result.Data[4:8]) != "ftyp" { + t.Fatalf("output is not MP4: %x", result.Data[:min(len(result.Data), 16)]) + } + if result.Width != 4 || result.Height != 6 || result.Duration <= 0 { + t.Fatalf("metadata = %+v, want even 4x6 positive duration", result) + } +} diff --git a/internal/app/files/photos.go b/internal/app/files/photos.go index e5276e8c..fce5bd96 100644 --- a/internal/app/files/photos.go +++ b/internal/app/files/photos.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/rand" + "crypto/sha256" "encoding/binary" "fmt" "image" @@ -242,6 +243,10 @@ func (s *Service) CreateDocumentFromUpload(ctx context.Context, file domain.Uplo if body.Size == 0 { return domain.Document{}, domain.ErrDocumentInvalid } + body, spec, err = s.normalizeUploadedGIF(ctx, body, spec) + if err != nil { + return domain.Document{}, err + } // faststart:MP4 视频若 moov 在末尾,搬到文件头以支持流式播放。普通 Telegram 客户端 // 上传前会做这步;DrKLO 发 story 视频不转码导致 moov 在末尾,TDesktop 流式播放路径 // 无法解复用(av_read_frame Invalid data)。不转码、保留原编码(含 HEVC)。 @@ -306,6 +311,55 @@ func (s *Service) CreateDocumentFromUpload(ctx context.Context, file domain.Uplo return doc, nil } +func (s *Service) normalizeUploadedGIF(ctx context.Context, body assembledUploadBlob, spec domain.DocumentSpec) (assembledUploadBlob, domain.DocumentSpec, error) { + if !strings.EqualFold(strings.TrimSpace(spec.MimeType), "image/gif") || spec.ForceFile { + return body, spec, nil + } + if s.gifs == nil || body.Size <= 0 || body.Size > gifTranscodeMaxInputBytes { + return assembledUploadBlob{}, domain.DocumentSpec{}, domain.ErrDocumentInvalid + } + data, total, err := s.blobs.GetRange(ctx, body.ObjectKey, 0, gifTranscodeMaxInputBytes+1) + if err != nil || total != body.Size || int64(len(data)) != body.Size { + return assembledUploadBlob{}, domain.DocumentSpec{}, domain.ErrDocumentInvalid + } + started := time.Now() + converted, err := s.gifs.Transcode(ctx, data) + if err != nil || len(converted.Data) == 0 || converted.Width <= 0 || converted.Height <= 0 || converted.Duration <= 0 { + s.log.Warn("GIF to MP4 conversion failed", zap.Int64("input_bytes", body.Size), zap.Error(err)) + return assembledUploadBlob{}, domain.DocumentSpec{}, domain.ErrDocumentInvalid + } + objectKey, err := s.blobs.Put(ctx, converted.Data) + if err != nil { + return assembledUploadBlob{}, domain.DocumentSpec{}, err + } + sum := sha256.Sum256(converted.Data) + body = assembledUploadBlob{ObjectKey: objectKey, Size: int64(len(converted.Data)), SHA256: append([]byte(nil), sum[:]...)} + spec.MimeType = "video/mp4" + spec.Attributes = canonicalGIFVideoAttributes(spec.Attributes, converted, spec.NosoundVideo) + s.log.Info("GIF normalized to MP4", + zap.Int64("input_bytes", total), zap.Int("output_bytes", len(converted.Data)), + zap.Int("width", converted.Width), zap.Int("height", converted.Height), + zap.Float64("duration", converted.Duration), zap.Duration("dur", time.Since(started))) + return body, spec, nil +} + +func canonicalGIFVideoAttributes(attrs []domain.DocumentAttribute, video GIFVideo, nosoundVideo bool) []domain.DocumentAttribute { + out := make([]domain.DocumentAttribute, 0, 3) + for _, attr := range attrs { + if attr.Kind == domain.DocAttrFilename { + out = append(out, attr) + } + } + if !nosoundVideo { + out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAnimated}) + } + out = append(out, domain.DocumentAttribute{ + Kind: domain.DocAttrVideo, W: video.Width, H: video.Height, + Duration: video.Duration, SupportsStreaming: true, NoSound: true, VideoCodec: "h264", + }) + return out +} + // maxFaststartBytes 限制 faststart 一次性载入内存的视频大小;超过则跳过(流式 faststart // 复杂度高,超大视频是边角)。与缩略图回退路径同样全量读 blob,内存模式无新增。 const maxFaststartBytes = 200 << 20 @@ -423,6 +477,18 @@ func (s *Service) CreateDocumentFromBytes(ctx context.Context, data []byte, spec if strings.TrimSpace(spec.MimeType) == "" { spec.MimeType = "application/octet-stream" } + if strings.EqualFold(strings.TrimSpace(spec.MimeType), "image/gif") && !spec.ForceFile { + if s.gifs == nil || len(data) > gifTranscodeMaxInputBytes { + return domain.Document{}, domain.ErrDocumentInvalid + } + converted, err := s.gifs.Transcode(ctx, data) + if err != nil || len(converted.Data) == 0 || converted.Width <= 0 || converted.Height <= 0 || converted.Duration <= 0 { + return domain.Document{}, domain.ErrDocumentInvalid + } + data = converted.Data + spec.MimeType = "video/mp4" + spec.Attributes = canonicalGIFVideoAttributes(spec.Attributes, converted, spec.NosoundVideo) + } objectKey, size, sum, err := s.blobs.PutReader(ctx, bytes.NewReader(data)) if err != nil { return domain.Document{}, err diff --git a/internal/app/files/photos_test.go b/internal/app/files/photos_test.go index 6a52edd8..ca37adaa 100644 --- a/internal/app/files/photos_test.go +++ b/internal/app/files/photos_test.go @@ -14,6 +14,76 @@ import ( "telesrv/internal/domain" ) +func TestCreateDocumentFromUploadNormalizesGIFToGIFv(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("NewLocalFS: %v", err) + } + transcoder := &fakeGIFTranscoder{result: GIFVideo{Data: []byte("fake-mp4"), Width: 320, Height: 240, Duration: 1.5}} + svc := NewService(media, blobs, 2, WithGIFTranscoder(transcoder), WithVideoThumbnailer(nil)) + if _, err := svc.SaveFilePart(ctx, 10, 90, 0, []byte("GIF89a-fake")); err != nil { + t.Fatalf("SaveFilePart: %v", err) + } + doc, err := svc.CreateDocumentFromUpload(ctx, + domain.UploadedFileRef{OwnerUserID: 10, FileID: 90, Parts: 1, Name: "animation.gif"}, + domain.DocumentSpec{MimeType: "image/gif", Attributes: []domain.DocumentAttribute{ + {Kind: domain.DocAttrFilename, FileName: "animation.gif"}, + {Kind: domain.DocAttrImageSize, W: 320, H: 240}, + }}) + if err != nil { + t.Fatalf("CreateDocumentFromUpload: %v", err) + } + if transcoder.calls != 1 || doc.MimeType != "video/mp4" || !doc.IsGif() || doc.Size != int64(len("fake-mp4")) { + t.Fatalf("document = %+v calls=%d, want canonical GIFv", doc, transcoder.calls) + } + blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", doc.ID)) + if err != nil || !ok || blob.MimeType != "video/mp4" { + t.Fatalf("gifv blob = %+v ok=%v err=%v", blob, ok, err) + } + got, err := blobs.Get(ctx, blob.ObjectKey) + if err != nil || !bytes.Equal(got, []byte("fake-mp4")) { + t.Fatalf("gifv bytes=%q err=%v", got, err) + } +} + +func TestCreateDocumentFromUploadGIFFlags(t *testing.T) { + tests := []struct { + name string + spec domain.DocumentSpec + wantMime string + wantGIF bool + wantCalls int + }{ + {name: "album nosound is normal video", spec: domain.DocumentSpec{MimeType: "image/gif", NosoundVideo: true}, wantMime: "video/mp4", wantGIF: false, wantCalls: 1}, + {name: "force file preserves original", spec: domain.DocumentSpec{MimeType: "image/gif", ForceFile: true}, wantMime: "image/gif", wantGIF: false, wantCalls: 0}, + } + for i, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatal(err) + } + transcoder := &fakeGIFTranscoder{result: GIFVideo{Data: []byte("mp4"), Width: 10, Height: 12, Duration: 1}} + svc := NewService(media, blobs, 2, WithGIFTranscoder(transcoder), WithVideoThumbnailer(nil)) + fileID := int64(910 + i) + if _, err := svc.SaveFilePart(ctx, 10, fileID, 0, []byte("gif")); err != nil { + t.Fatal(err) + } + doc, err := svc.CreateDocumentFromUpload(ctx, domain.UploadedFileRef{OwnerUserID: 10, FileID: fileID, Parts: 1}, tc.spec) + if err != nil { + t.Fatal(err) + } + if doc.MimeType != tc.wantMime || doc.IsGif() != tc.wantGIF || transcoder.calls != tc.wantCalls { + t.Fatalf("doc=%+v calls=%d", doc, transcoder.calls) + } + }) + } +} + func TestCreateDocumentFromUploadGeneratesVideoThumbWhenMissing(t *testing.T) { ctx := context.Background() media := newFakeMediaStore() @@ -189,6 +259,26 @@ func TestCreateDocumentFromBytesStoresBodyAndAttributes(t *testing.T) { } } +func TestCreateDocumentFromBytesNormalizesExternalGIF(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatal(err) + } + transcoder := &fakeGIFTranscoder{result: GIFVideo{Data: []byte("external-mp4"), Width: 20, Height: 10, Duration: 2}} + svc := NewService(media, blobs, 2, WithGIFTranscoder(transcoder), WithVideoThumbnailer(nil)) + doc, err := svc.CreateDocumentFromBytes(ctx, []byte("external-gif"), domain.DocumentSpec{ + MimeType: "image/gif", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: "x.gif"}}, + }) + if err != nil { + t.Fatal(err) + } + if !doc.IsGif() || doc.MimeType != "video/mp4" || transcoder.calls != 1 { + t.Fatalf("doc=%+v calls=%d", doc, transcoder.calls) + } +} + func TestCreateAvatarMarkupGeneratesDownloadableStaticSizes(t *testing.T) { ctx := context.Background() media := newFakeMediaStore() @@ -442,6 +532,17 @@ type fakeVideoThumbnailer struct { err error } +type fakeGIFTranscoder struct { + result GIFVideo + err error + calls int +} + +func (f *fakeGIFTranscoder) Transcode(_ context.Context, _ []byte) (GIFVideo, error) { + f.calls++ + return f.result, f.err +} + func (f *fakeVideoThumbnailer) Extract(context.Context, []byte, string) ([]byte, error) { f.calls++ if f.err != nil { diff --git a/internal/app/files/seed.go b/internal/app/files/seed.go index 9e65f680..1e22fc28 100644 --- a/internal/app/files/seed.go +++ b/internal/app/files/seed.go @@ -648,7 +648,7 @@ func seedDocumentAttributes(attrs []seedAttrJSON) []domain.DocumentAttribute { } out = append(out, attr) case "DocumentAttributeVideo": - out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: a.W, H: a.H, Duration: a.Duration, RoundMessage: a.RoundMessage, SupportsStreaming: a.SupportsStreaming}) + out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: a.W, H: a.H, Duration: a.Duration, RoundMessage: a.RoundMessage, SupportsStreaming: a.SupportsStreaming, NoSound: a.NoSound, VideoCodec: a.VideoCodec}) case "DocumentAttributeAudio": out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: int(a.Duration), Voice: a.Voice, Title: a.Title, Performer: a.Performer}) case "DocumentAttributeFilename": @@ -889,6 +889,8 @@ type seedAttrJSON struct { Duration float64 `json:"duration"` RoundMessage bool `json:"round_message"` SupportsStreaming bool `json:"supports_streaming"` + NoSound bool `json:"nosound"` + VideoCodec string `json:"video_codec"` Voice bool `json:"voice"` Title string `json:"title"` Performer string `json:"performer"` diff --git a/internal/app/files/service.go b/internal/app/files/service.go index 135a4f32..789d86fd 100644 --- a/internal/app/files/service.go +++ b/internal/app/files/service.go @@ -52,6 +52,8 @@ type Service struct { log *zap.Logger thumbs VideoThumbnailer thumbsSet bool + gifs GIFTranscoder + gifsSet bool blobCache *blobMetaCache byteCache *blobBytesCache // blobMetaSF/blobBytesSF 合并对同一热 blob 的并发首次访问:否则每个并发 getFile 都各打 @@ -91,6 +93,14 @@ func WithVideoThumbnailer(thumbnailer VideoThumbnailer) Option { } } +// WithGIFTranscoder 覆盖真实 GIF→MP4 规范化器。传 nil 可用于测试不可用路径。 +func WithGIFTranscoder(transcoder GIFTranscoder) Option { + return func(s *Service) { + s.gifs = transcoder + s.gifsSet = true + } +} + // WithUploadPartQuota 覆盖用户级 in-flight 上传分片配额;字段 <=0 表示该维度不限制。 func WithUploadPartQuota(quota domain.UploadPartQuota) Option { return func(s *Service) { @@ -135,6 +145,14 @@ func NewService(media store.MediaStore, blobs BlobBackend, dc int, opts ...Optio s.thumbs = thumbnailer } } + if !s.gifsSet { + transcoder, err := NewFFmpegGIFTranscoder() + if err != nil { + s.log.Warn("ffmpeg/ffprobe not found; GIF uploads will be rejected", zap.Error(err)) + } else { + s.gifs = transcoder + } + } return s } diff --git a/internal/domain/media.go b/internal/domain/media.go index 8c898f1c..a042163e 100644 --- a/internal/domain/media.go +++ b/internal/domain/media.go @@ -109,6 +109,9 @@ type DocumentSpec struct { Attributes []DocumentAttribute Thumb *UploadedFileRef // 可选缩略图上传,生成 doc::m ForceFile bool + // NosoundVideo 对应 inputMediaUploadedDocument.nosound_video。真实 GIF 仍会 + // 转成 MP4,但该标志存在时按普通无声视频落库,不附 animated 属性(album 路径)。 + NosoundVideo bool } // FileDownloadRequest 是 upload.getFile 解析后的下载请求; @@ -205,6 +208,8 @@ type DocumentAttribute struct { Duration float64 `json:"duration,omitempty"` RoundMessage bool `json:"round_message,omitempty"` SupportsStreaming bool `json:"supports_streaming,omitempty"` + NoSound bool `json:"no_sound,omitempty"` + VideoCodec string `json:"video_codec,omitempty"` // audio AudioDuration int `json:"audio_duration,omitempty"` @@ -350,14 +355,24 @@ func (d Document) stickerMaterialMimeOrExt() string { return mimeType } -// IsGif reports whether the document is a savable GIF (documentAttributeAnimated). +// IsGif reports whether the document is a canonical, savable Telegram GIFv. +// Telegram 的 GIF 是无声 MP4;仅有 animated 属性的 raw image/gif 不是合法 saved GIF。 func (d Document) IsGif() bool { + if !strings.EqualFold(strings.TrimSpace(d.MimeType), "video/mp4") { + return false + } + var animated, video bool for _, attr := range d.Attributes { - if attr.Kind == DocAttrAnimated { - return true + switch attr.Kind { + case DocAttrAnimated: + animated = true + case DocAttrVideo: + if !attr.RoundMessage && attr.W > 0 && attr.H > 0 && attr.Duration > 0 { + video = true + } } } - return false + return animated && video } // Photo 是已存储的 Telegram 照片(头像或图片消息)。 diff --git a/internal/domain/media_category.go b/internal/domain/media_category.go index 6fcb41fa..0ad0eefe 100644 --- a/internal/domain/media_category.go +++ b/internal/domain/media_category.go @@ -95,8 +95,8 @@ func ClassifyMediaCategories(media *MessageMedia, entities []MessageEntity) []Me } // classifyDocumentCategory 按 TL DocumentAttribute 判定一个文档落入哪个媒体类别。 -// 客户端的标签页 UI 以 TL 属性(而非 MIME)为准,故这里也只看属性。优先级: -// sticker(不入任何标签页)> animated(GIF) > audio(music/voice) > video(video/round) > 通用文件。 +// 客户端的标签页 UI 以 TL 属性和 GIFv MIME 共同判定。优先级:sticker(不入任何标签页)> +// canonical animated MP4 (GIF) > audio(music/voice) > video(video/round) > 通用文件。 func classifyDocumentCategory(doc *Document) (MediaCategory, bool) { if doc == nil { return MediaCategoryNone, false @@ -130,7 +130,7 @@ func classifyDocumentCategory(doc *Document) (MediaCategory, bool) { switch { case hasSticker: return MediaCategoryNone, false // 贴纸/自定义 emoji 不出现在共享媒体 - case hasAnimated: + case hasAnimated && doc.IsGif(): return MediaCategoryGif, true case hasAudio: if audioVoice { diff --git a/internal/domain/media_category_test.go b/internal/domain/media_category_test.go index 25e88275..e67c3728 100644 --- a/internal/domain/media_category_test.go +++ b/internal/domain/media_category_test.go @@ -24,7 +24,8 @@ func TestClassifyMediaCategories(t *testing.T) { {"poll", &MessageMedia{Kind: MessageMediaKindPoll}, nil, []MediaCategory{MediaCategoryPoll}}, {"video", doc(DocumentAttribute{Kind: DocAttrVideo}), nil, []MediaCategory{MediaCategoryVideo}}, {"round video note", doc(DocumentAttribute{Kind: DocAttrVideo, RoundMessage: true}), nil, []MediaCategory{MediaCategoryRoundVideo}}, - {"gif animation", doc(DocumentAttribute{Kind: DocAttrAnimated}), nil, []MediaCategory{MediaCategoryGif}}, + {"gif animation", &MessageMedia{Kind: MessageMediaKindDocument, Document: &Document{MimeType: "video/mp4", Attributes: []DocumentAttribute{{Kind: DocAttrAnimated}, {Kind: DocAttrVideo, W: 320, H: 240, Duration: 1}}}}, nil, []MediaCategory{MediaCategoryGif}}, + {"raw gif is a file", &MessageMedia{Kind: MessageMediaKindDocument, Document: &Document{MimeType: "image/gif", Attributes: []DocumentAttribute{{Kind: DocAttrAnimated}}}}, nil, []MediaCategory{MediaCategoryFile}}, {"music", doc(DocumentAttribute{Kind: DocAttrAudio}), nil, []MediaCategory{MediaCategoryMusic}}, {"voice", doc(DocumentAttribute{Kind: DocAttrAudio, Voice: true}), nil, []MediaCategory{MediaCategoryVoice}}, {"generic file", doc(DocumentAttribute{Kind: DocAttrFilename, FileName: "x.pdf"}), nil, []MediaCategory{MediaCategoryFile}}, diff --git a/internal/rpc/convert_media.go b/internal/rpc/convert_media.go index ea817a3f..edd6fabb 100644 --- a/internal/rpc/convert_media.go +++ b/internal/rpc/convert_media.go @@ -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, diff --git a/internal/rpc/errors.go b/internal/rpc/errors.go index 57d05d07..243ae969 100644 --- a/internal/rpc/errors.go +++ b/internal/rpc/errors.go @@ -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") } diff --git a/internal/rpc/messages_history_rpc_test.go b/internal/rpc/messages_history_rpc_test.go index a136fd9c..2c6964f8 100644 --- a/internal/rpc/messages_history_rpc_test.go +++ b/internal/rpc/messages_history_rpc_test.go @@ -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{ diff --git a/internal/rpc/messages_stickers_personal.go b/internal/rpc/messages_stickers_personal.go index 1e9c6820..d129abcc 100644 --- a/internal/rpc/messages_stickers_personal.go +++ b/internal/rpc/messages_stickers_personal.go @@ -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 { diff --git a/internal/rpc/messages_stickers_personal_rpc_test.go b/internal/rpc/messages_stickers_personal_rpc_test.go index 3cd98139..ebd4258d 100644 --- a/internal/rpc/messages_stickers_personal_rpc_test.go +++ b/internal/rpc/messages_stickers_personal_rpc_test.go @@ -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) } } diff --git a/internal/rpc/send_media.go b/internal/rpc/send_media.go index a81dfce1..a6837af0 100644 --- a/internal/rpc/send_media.go +++ b/internal/rpc/send_media.go @@ -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: diff --git a/internal/store/postgres/account.go b/internal/store/postgres/account.go index a7391398..587f8fca 100644 --- a/internal/store/postgres/account.go +++ b/internal/store/postgres/account.go @@ -540,17 +540,19 @@ func (s *PasswordStore) SaveStickerCollectionItem(ctx context.Context, userID in if _, err := tx.Exec(ctx, ` INSERT INTO user_sticker_collections (owner_user_id, kind, document_id, used_at) VALUES ($1, $2, $3, $4) -ON CONFLICT (owner_user_id, kind, document_id) DO UPDATE SET used_at = EXCLUDED.used_at`, +ON CONFLICT (owner_user_id, kind, document_id) DO UPDATE +SET used_at = EXCLUDED.used_at, + order_key = nextval('user_sticker_collections_order_key_seq')`, userID, string(kind), documentID, now); err != nil { return fmt.Errorf("upsert sticker collection item: %w", err) } // 截断超上界:单次有序窗口扫描(索引 user_sticker_collections_order_idx 服务 - // used_at DESC 排序),按 ctid 删除排名 > max 的旧项,避免 NOT IN 双扫全集。 + // order_key DESC 排序),按 ctid 删除排名 > max 的旧项,避免 NOT IN 双扫全集。 if _, err := tx.Exec(ctx, ` DELETE FROM user_sticker_collections WHERE ctid IN ( SELECT ctid FROM ( - SELECT ctid, ROW_NUMBER() OVER (ORDER BY used_at DESC, document_id DESC) AS rn + SELECT ctid, ROW_NUMBER() OVER (ORDER BY order_key DESC) AS rn FROM user_sticker_collections WHERE owner_user_id = $1 AND kind = $2 ) t WHERE rn > $3 @@ -572,7 +574,7 @@ func (s *PasswordStore) ListStickerCollection(ctx context.Context, userID int64, SELECT document_id, used_at FROM user_sticker_collections WHERE owner_user_id = $1 AND kind = $2 -ORDER BY used_at DESC, document_id DESC +ORDER BY order_key DESC LIMIT $3`, userID, string(kind), limit) if err != nil { return nil, fmt.Errorf("list sticker collection: %w", err) diff --git a/internal/store/postgres/account_stickers_integration_test.go b/internal/store/postgres/account_stickers_integration_test.go index a88f116e..f56568c0 100644 --- a/internal/store/postgres/account_stickers_integration_test.go +++ b/internal/store/postgres/account_stickers_integration_test.go @@ -38,6 +38,17 @@ func TestStickerCollectionsRoundTripPostgres(t *testing.T) { t.Fatalf("faved after re-fave = %v, want 101 first", ids) } + // used_at 只有秒精度:同秒保存仍必须严格按最后一次 mutation 排序。 + if err := store.SaveStickerCollectionItem(ctx, owner, faved, 102, false, 3000, 100); err != nil { + t.Fatalf("same-second fave 102: %v", err) + } + if err := store.SaveStickerCollectionItem(ctx, owner, faved, 101, false, 3000, 100); err != nil { + t.Fatalf("same-second re-fave 101: %v", err) + } + if ids := stickerIDs(t, store, ctx, owner, faved); len(ids) != 2 || ids[0] != 101 || ids[1] != 102 { + t.Fatalf("same-second order = %v, want [101 102]", ids) + } + // 截断:max=2,加第 3 个挤掉最旧。 if err := store.SaveStickerCollectionItem(ctx, owner, faved, 103, false, 1003, 2); err != nil { t.Fatalf("fave 103 (max 2): %v", err)