diff --git a/internal/rpc/stickers_emoji_index.go b/internal/rpc/stickers_emoji_index.go index 3daa4efe..b07813b6 100644 --- a/internal/rpc/stickers_emoji_index.go +++ b/internal/rpc/stickers_emoji_index.go @@ -73,11 +73,33 @@ func normalizeStickerEmoticon(e string) string { return strings.TrimSpace(e) } +// normalizeStickerSearchEmoticon 解析官方客户端通过 messages.getStickers +// 传递的特殊贴纸类别标记。TDesktop、DrKLO Android 与 Telegram-iOS 都使用 +// wave+star 获取 greeting、double-star 获取 premium preview、folder+star 获取 +// premium/cloud catalog;它们不是普通复合 emoji,需先映射到 seed pack 的基础键。 +// +// 只匹配这三个完整标记,不能把任意复合 emoji 拆分成单个 emoji,否则会改变普通 +// sticker search 的精确匹配语义。先去掉 variation selector,可同时接纳三端的 +// Unicode 表示差异。 +func normalizeStickerSearchEmoticon(e string) string { + e = normalizeStickerEmoticon(e) + switch e { + case "👋⭐": + return "👋" + case "⭐⭐": + return "⭐" + case "📂⭐": + return "📂" + default: + return e + } +} + func (r *Router) onMessagesGetStickers(ctx context.Context, req *tg.MessagesGetStickersRequest) (tg.MessagesStickersClass, error) { if req == nil || r.deps.Files == nil || r.emojiStickers == nil { return &tg.MessagesStickers{Hash: 0, Stickers: []tg.DocumentClass{}}, nil } - docIDs := r.emojiStickers.lookup(normalizeStickerEmoticon(req.Emoticon), func() map[string][]int64 { + docIDs := r.emojiStickers.lookup(normalizeStickerSearchEmoticon(req.Emoticon), func() map[string][]int64 { return r.buildEmojiStickerIndex(ctx) }) if len(docIDs) > maxStickersPerEmoji { diff --git a/internal/rpc/stickers_emoji_index_test.go b/internal/rpc/stickers_emoji_index_test.go index cf852fd6..1a7f4234 100644 --- a/internal/rpc/stickers_emoji_index_test.go +++ b/internal/rpc/stickers_emoji_index_test.go @@ -15,15 +15,21 @@ func emojiStickerRouter(t *testing.T) *Router { t.Helper() files := &fakeFiles{ docs: map[int64]domain.Document{ - 201: {ID: 201, AccessHash: 1, MimeType: "application/x-tgsticker"}, - 202: {ID: 202, AccessHash: 2, MimeType: "application/x-tgsticker"}, - 301: {ID: 301, AccessHash: 3, MimeType: "application/x-tgsticker"}, + 201: {ID: 201, AccessHash: 1, MimeType: "application/x-tgsticker", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, + 202: {ID: 202, AccessHash: 2, MimeType: "application/x-tgsticker", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, + 301: {ID: 301, AccessHash: 3, MimeType: "application/x-tgsticker", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, + 401: {ID: 401, AccessHash: 4, MimeType: "application/x-tgsticker", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, + 402: {ID: 402, AccessHash: 5, MimeType: "application/x-tgsticker", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, + 403: {ID: 403, AccessHash: 6, MimeType: "application/x-tgsticker", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, }, sets: map[domain.StickerSetKind][]domain.StickerSet{ domain.StickerSetKindStickers: { {ID: 10, Hash: 1, Packs: []domain.StickerPack{ {Emoticon: "👍", DocumentIDs: []int64{201, 202}}, {Emoticon: "🔥", DocumentIDs: []int64{301}}, + {Emoticon: "👋", DocumentIDs: []int64{401}}, + {Emoticon: "⭐", DocumentIDs: []int64{402}}, + {Emoticon: "📂", DocumentIDs: []int64{403}}, }}, {ID: 12, Hash: 2, Archived: true, Packs: []domain.StickerPack{ {Emoticon: "👍", DocumentIDs: []int64{999}}, // 归档集应被排除 @@ -98,6 +104,54 @@ func TestMessagesGetStickersByEmoji(t *testing.T) { } } +// TestMessagesGetStickersSpecialCategories 固定 TDesktop、DrKLO Android 与 +// Telegram-iOS 共用的特殊类别标记。这些标记不是普通复合 emoji:服务端应把它们 +// 解析到对应的基础目录,同时不能拆分任意多 emoji 查询。 +func TestMessagesGetStickersSpecialCategories(t *testing.T) { + r := emojiStickerRouter(t) + ctx := WithUserID(context.Background(), 1000000001) + + tests := []struct { + name string + emoticon string + base string + wantDocID int64 + }{ + {name: "greeting", emoticon: "👋⭐️", base: "👋", wantDocID: 401}, + {name: "greeting_without_vs16", emoticon: "👋⭐", base: "👋", wantDocID: 401}, + {name: "premium_preview", emoticon: "⭐️⭐️", base: "⭐", wantDocID: 402}, + {name: "premium_preview_mixed_vs16", emoticon: "⭐⭐️", base: "⭐", wantDocID: 402}, + {name: "all_premium", emoticon: "📂⭐️", base: "📂", wantDocID: 403}, + {name: "all_premium_without_vs16", emoticon: "📂⭐", base: "📂", wantDocID: 403}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + base := mustStickers(t, r, ctx, tt.base, 0).(*tg.MessagesStickers) + got := mustStickers(t, r, ctx, tt.emoticon, 0) + ids := stickerDocIDs(t, got) + if len(ids) != 1 || ids[0] != tt.wantDocID { + t.Fatalf("%q stickers = %v, want [%d]", tt.emoticon, ids, tt.wantDocID) + } + full := got.(*tg.MessagesStickers) + if full.Hash == 0 || full.Hash != base.Hash { + t.Fatalf("%q hash = %d, base %q hash = %d", tt.emoticon, full.Hash, tt.base, base.Hash) + } + if again, err := r.onMessagesGetStickers(ctx, &tg.MessagesGetStickersRequest{ + Emoticon: tt.emoticon, + Hash: full.Hash, + }); err != nil { + t.Fatalf("getStickers %q hash: %v", tt.emoticon, err) + } else if _, ok := again.(*tg.MessagesStickersNotModified); !ok { + t.Fatalf("getStickers %q with matching hash = %T, want NotModified", tt.emoticon, again) + } + }) + } + + if ids := stickerDocIDs(t, mustStickers(t, r, ctx, "👋🔥", 0)); len(ids) != 0 { + t.Fatalf("ordinary compound emoji stickers = %v, want empty", ids) + } +} + func mustStickers(t *testing.T, r *Router, ctx context.Context, emoticon string, hash int64) tg.MessagesStickersClass { t.Helper() res, err := r.onMessagesGetStickers(ctx, &tg.MessagesGetStickersRequest{Emoticon: emoticon, Hash: hash})