fix: harden channel rights and sticker compatibility

Co-authored-by: HSgram <3013954224@qq.com>
This commit is contained in:
A 2026-07-01 22:28:24 +08:00
parent 6867d201ed
commit 599453a3c4
33 changed files with 1520 additions and 130 deletions

View file

@ -20,27 +20,27 @@ const (
botInlineCacheMaxEntries = 256
)
func botInlineDisabledErr() error { return tgerr.New(400, "BOT_INLINE_DISABLED") }
func botInlineGeoNotAllowedErr() error { return tgerr.New(400, "BOT_INLINE_GEO_NOT_ALLOWED") }
func botWebviewDisabledErr() error { return tgerr.New(400, "BOT_WEBVIEW_DISABLED") }
func queryIDInvalidErr() error { return tgerr.New(400, "QUERY_ID_INVALID") }
func queryIDEmptyErr() error { return tgerr.New(400, "QUERY_ID_EMPTY") }
func resultIDEmptyErr() error { return tgerr.New(400, "RESULT_ID_EMPTY") }
func resultIDInvalidErr() error { return tgerr.New(400, "RESULT_ID_INVALID") }
func resultIDDuplicateErr() error { return tgerr.New(400, "RESULT_ID_DUPLICATE") }
func resultTypeInvalidErr() error { return tgerr.New(400, "RESULT_TYPE_INVALID") }
func resultsTooMuchErr() error { return tgerr.New(400, "RESULTS_TOO_MUCH") }
func sendMessageTypeInvalidErr() error { return tgerr.New(400, "SEND_MESSAGE_TYPE_INVALID") }
func nextOffsetInvalidErr() error { return tgerr.New(400, "NEXT_OFFSET_INVALID") }
func startParamEmptyErr() error { return tgerr.New(400, "START_PARAM_EMPTY") }
func switchPmTextEmptyErr() error { return tgerr.New(400, "SWITCH_PM_TEXT_EMPTY") }
func switchWebviewInvalidErr() error { return tgerr.New(400, "SWITCH_WEBVIEW_URL_INVALID") }
func inlineResultExpiredErr() error { return tgerr.New(400, "INLINE_RESULT_EXPIRED") }
func webDocumentInvalidErr() error { return tgerr.New(400, "WEBDOCUMENT_INVALID") }
func webDocumentMimeInvalidErr() error { return tgerr.New(400, "WEBDOCUMENT_MIME_INVALID") }
func webDocumentSizeTooBigErr() error { return tgerr.New(400, "WEBDOCUMENT_SIZE_TOO_BIG") }
func webDocumentURLEmptyErr() error { return tgerr.New(400, "WEBDOCUMENT_URL_EMPTY") }
func webDocumentURLInvalidErr() error { return tgerr.New(400, "WEBDOCUMENT_URL_INVALID") }
func botInlineDisabledErr() error { return tgerr.New(400, "BOT_INLINE_DISABLED") }
func botInlineGeoNotAllowedErr() error { return tgerr.New(400, "BOT_INLINE_GEO_NOT_ALLOWED") }
func botWebviewDisabledErr() error { return tgerr.New(400, "BOT_WEBVIEW_DISABLED") }
func queryIDInvalidErr() error { return tgerr.New(400, "QUERY_ID_INVALID") }
func queryIDEmptyErr() error { return tgerr.New(400, "QUERY_ID_EMPTY") }
func resultIDEmptyErr() error { return tgerr.New(400, "RESULT_ID_EMPTY") }
func resultIDInvalidErr() error { return tgerr.New(400, "RESULT_ID_INVALID") }
func resultIDDuplicateErr() error { return tgerr.New(400, "RESULT_ID_DUPLICATE") }
func resultTypeInvalidErr() error { return tgerr.New(400, "RESULT_TYPE_INVALID") }
func resultsTooMuchErr() error { return tgerr.New(400, "RESULTS_TOO_MUCH") }
func sendMessageTypeInvalidErr() error { return tgerr.New(400, "SEND_MESSAGE_TYPE_INVALID") }
func nextOffsetInvalidErr() error { return tgerr.New(400, "NEXT_OFFSET_INVALID") }
func startParamEmptyErr() error { return tgerr.New(400, "START_PARAM_EMPTY") }
func switchPmTextEmptyErr() error { return tgerr.New(400, "SWITCH_PM_TEXT_EMPTY") }
func switchWebviewInvalidErr() error { return tgerr.New(400, "SWITCH_WEBVIEW_URL_INVALID") }
func inlineResultExpiredErr() error { return tgerr.New(400, "INLINE_RESULT_EXPIRED") }
func webDocumentInvalidErr() error { return tgerr.New(400, "WEBDOCUMENT_INVALID") }
func webDocumentMimeInvalidErr() error { return tgerr.New(400, "WEBDOCUMENT_MIME_INVALID") }
func webDocumentSizeTooBigErr() error { return tgerr.New(400, "WEBDOCUMENT_SIZE_TOO_BIG") }
func webDocumentURLEmptyErr() error { return tgerr.New(400, "WEBDOCUMENT_URL_EMPTY") }
func webDocumentURLInvalidErr() error { return tgerr.New(400, "WEBDOCUMENT_URL_INVALID") }
func (r *Router) onMessagesGetInlineBotResults(ctx context.Context, req *tg.MessagesGetInlineBotResultsRequest) (*tg.MessagesBotResults, error) {
userID, _, err := r.currentUserID(ctx)
@ -1132,7 +1132,7 @@ func tgInlineWebDocument(in domain.BotInlineWebDocument) tg.WebDocumentClass {
AccessHash: in.AccessHash,
Size: in.Size,
MimeType: in.MimeType,
Attributes: tgDocumentAttributes(in.Attributes),
Attributes: tgDocumentAttributes(in.MimeType, in.Attributes),
}
}

View file

@ -808,20 +808,29 @@ func domainChannelAdminRights(rights tg.ChatAdminRights) domain.ChannelAdminRigh
func tgChatBannedRights(rights domain.ChannelBannedRights) tg.ChatBannedRights {
return tg.ChatBannedRights{
ViewMessages: rights.ViewMessages,
SendMessages: rights.SendMessages,
SendMedia: rights.SendMedia,
SendStickers: rights.SendStickers,
SendGifs: rights.SendGifs,
SendGames: rights.SendGames,
SendInline: rights.SendInline,
EmbedLinks: rights.EmbedLinks,
SendPolls: rights.SendPolls,
ChangeInfo: rights.ChangeInfo,
InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages,
EditRank: rights.EditRank,
UntilDate: rights.UntilDate,
ViewMessages: rights.ViewMessages,
SendMessages: rights.SendMessages,
SendMedia: rights.SendMedia,
SendStickers: rights.SendStickers,
SendGifs: rights.SendGifs,
SendGames: rights.SendGames,
SendInline: rights.SendInline,
EmbedLinks: rights.EmbedLinks,
SendPolls: rights.SendPolls,
ChangeInfo: rights.ChangeInfo,
InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages,
ManageTopics: rights.ManageTopics,
SendPhotos: rights.SendPhotos,
SendVideos: rights.SendVideos,
SendRoundvideos: rights.SendRoundvideos,
SendAudios: rights.SendAudios,
SendVoices: rights.SendVoices,
SendDocs: rights.SendDocs,
SendPlain: rights.SendPlain,
EditRank: rights.EditRank,
SendReactions: rights.SendReactions,
UntilDate: rights.UntilDate,
}
}
@ -835,20 +844,29 @@ func tgDefaultChatBannedRights(rights domain.ChannelBannedRights) tg.ChatBannedR
func domainChannelBannedRights(rights tg.ChatBannedRights) domain.ChannelBannedRights {
return domain.ChannelBannedRights{
ViewMessages: rights.ViewMessages,
SendMessages: rights.SendMessages,
SendMedia: rights.SendMedia,
SendStickers: rights.SendStickers,
SendGifs: rights.SendGifs,
SendGames: rights.SendGames,
SendInline: rights.SendInline,
EmbedLinks: rights.EmbedLinks,
SendPolls: rights.SendPolls,
ChangeInfo: rights.ChangeInfo,
InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages,
EditRank: rights.EditRank,
UntilDate: rights.UntilDate,
ViewMessages: rights.ViewMessages,
SendMessages: rights.SendMessages,
SendMedia: rights.SendMedia,
SendStickers: rights.SendStickers,
SendGifs: rights.SendGifs,
SendGames: rights.SendGames,
SendInline: rights.SendInline,
EmbedLinks: rights.EmbedLinks,
SendPolls: rights.SendPolls,
ChangeInfo: rights.ChangeInfo,
InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages,
ManageTopics: rights.ManageTopics,
SendPhotos: rights.SendPhotos,
SendVideos: rights.SendVideos,
SendRoundvideos: rights.SendRoundvideos,
SendAudios: rights.SendAudios,
SendVoices: rights.SendVoices,
SendDocs: rights.SendDocs,
SendPlain: rights.SendPlain,
EditRank: rights.EditRank,
SendReactions: rights.SendReactions,
UntilDate: rights.UntilDate,
}
}

View file

@ -67,3 +67,37 @@ func TestTGChannelFullIncludesExportedInvite(t *testing.T) {
t.Fatalf("channelFull.exported_invite = %#v, want active permanent invite", invite)
}
}
func TestChannelBannedRightsRoundTripModernFields(t *testing.T) {
in := tg.ChatBannedRights{
ViewMessages: true,
SendMessages: true,
SendMedia: true,
SendStickers: true,
SendGifs: true,
SendGames: true,
SendInline: true,
EmbedLinks: true,
SendPolls: true,
ChangeInfo: true,
InviteUsers: true,
PinMessages: true,
ManageTopics: true,
SendPhotos: true,
SendVideos: true,
SendRoundvideos: true,
SendAudios: true,
SendVoices: true,
SendDocs: true,
SendPlain: true,
EditRank: true,
SendReactions: true,
UntilDate: 12345,
}
domainRights := domainChannelBannedRights(in)
out := tgChatBannedRights(domainRights)
if out != in {
t.Fatalf("banned rights round-trip = %+v, want %+v", out, in)
}
}

View file

@ -8,6 +8,8 @@ import (
"telesrv/internal/domain"
)
const mimeApplicationXTGSticker = "application/x-tgsticker"
// 本文件集中 domain media 值对象 → tg.* 的转换tg.* 只在 rpc 层出现。
// 供 reaction / sticker 资源 RPC 与消息 media 共用。
@ -27,9 +29,13 @@ func tgMessageMedia(m *domain.MessageMedia) tg.MessageMediaClass {
}
return out
case domain.MessageMediaKindDocument:
nopremium := m.Nopremium
if m.Document != nil && m.Document.IsSticker() {
nopremium = true
}
out := &tg.MessageMediaDocument{
Spoiler: m.Spoiler,
Nopremium: m.Nopremium,
Nopremium: nopremium,
Voice: m.Voice,
Round: m.Round,
Video: m.Video,
@ -267,9 +273,9 @@ func tgDocument(d domain.Document) tg.DocumentClass {
Date: d.Date,
MimeType: d.MimeType,
Size: d.Size,
Thumbs: tgDocumentThumbs(d.Thumbs),
Thumbs: tgDocumentThumbs(d.MimeType, d.Thumbs),
DCID: d.DCID,
Attributes: tgDocumentAttributes(d.Attributes),
Attributes: tgDocumentAttributes(d.MimeType, d.Attributes),
}
}
@ -281,12 +287,15 @@ func tgDocuments(docs []domain.Document) []tg.DocumentClass {
return out
}
func tgDocumentThumbs(sizes []domain.PhotoSize) []tg.PhotoSizeClass {
func tgDocumentThumbs(mimeType string, sizes []domain.PhotoSize) []tg.PhotoSizeClass {
if len(sizes) == 0 {
return nil
}
out := make([]tg.PhotoSizeClass, 0, len(sizes))
for _, s := range sizes {
if isSeedSyntheticTGStickerPreviewThumb(mimeType, s) {
continue
}
if s.Kind == domain.PhotoSizeKindCached && len(s.Bytes) > 0 {
size := s.Size
if size == 0 {
@ -302,6 +311,17 @@ func tgDocumentThumbs(sizes []domain.PhotoSize) []tg.PhotoSizeClass {
return compactPhotoSizeClasses(out)
}
func isSeedSyntheticTGStickerPreviewThumb(mimeType string, s domain.PhotoSize) bool {
// Older seed imports gave TGS documents without thumbnails a 1x1 transparent
// "m" PNG. Clients can prefer that unusable preview and render blank stickers.
return mimeType == mimeApplicationXTGSticker &&
s.Kind == domain.PhotoSizeKindCached &&
s.Type == "m" &&
s.W <= 1 &&
s.H <= 1 &&
len(s.Bytes) > 0
}
func tgPhotoSizes(sizes []domain.PhotoSize) []tg.PhotoSizeClass {
if len(sizes) == 0 {
return nil
@ -408,15 +428,19 @@ func compactPhotoSizeClasses(in []tg.PhotoSizeClass) []tg.PhotoSizeClass {
return out
}
func tgDocumentAttributes(attrs []domain.DocumentAttribute) []tg.DocumentAttributeClass {
out := make([]tg.DocumentAttributeClass, 0, len(attrs))
func tgDocumentAttributes(mimeType string, attrs []domain.DocumentAttribute) []tg.DocumentAttributeClass {
out := make([]tg.DocumentAttributeClass, 0, len(attrs)+1)
hasAnimated := false
hasStickerLike := false
for _, a := range attrs {
switch a.Kind {
case domain.DocAttrImageSize:
out = append(out, &tg.DocumentAttributeImageSize{W: a.W, H: a.H})
case domain.DocAttrAnimated:
hasAnimated = true
out = append(out, &tg.DocumentAttributeAnimated{})
case domain.DocAttrSticker:
hasStickerLike = true
out = append(out, &tg.DocumentAttributeSticker{
Mask: a.Mask,
Alt: a.Alt,
@ -444,6 +468,7 @@ func tgDocumentAttributes(attrs []domain.DocumentAttribute) []tg.DocumentAttribu
case domain.DocAttrFilename:
out = append(out, &tg.DocumentAttributeFilename{FileName: a.FileName})
case domain.DocAttrCustomEmoji:
hasStickerLike = true
out = append(out, &tg.DocumentAttributeCustomEmoji{
Free: a.Free,
TextColor: a.TextColor,
@ -452,6 +477,9 @@ func tgDocumentAttributes(attrs []domain.DocumentAttribute) []tg.DocumentAttribu
})
}
}
if mimeType == mimeApplicationXTGSticker && hasStickerLike && !hasAnimated {
out = append(out, &tg.DocumentAttributeAnimated{})
}
return out
}
@ -671,6 +699,12 @@ func stickerSetRefFromInput(input tg.InputStickerSetClass) (domain.StickerSetRef
return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "emoji_generic_animations"}, true
case *tg.InputStickerSetEmojiDefaultStatuses:
return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: domain.StickerSetSystemKeyEmojiDefaultStatuses}, true
case *tg.InputStickerSetEmojiChannelDefaultStatuses:
return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: domain.StickerSetSystemKeyEmojiDefaultStatuses}, true
case *tg.InputStickerSetEmojiDefaultTopicIcons:
return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: domain.StickerSetSystemKeyEmojiDefaultTopicIcons}, true
case *tg.InputStickerSetPremiumGifts:
return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: domain.StickerSetSystemKeyPremiumGifts}, true
case *tg.InputStickerSetDice:
return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "dice:" + in.Emoticon}, true
default:

View file

@ -2,6 +2,8 @@ package rpc
import (
"context"
"sync"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
@ -51,6 +53,20 @@ func (r *Router) onMessagesSendReaction(ctx context.Context, req *tg.MessagesSen
return updates, nil
}
if peer.Type == domain.PeerTypeUser && r.deps.Messages != nil {
if len(reactions) == 0 && r.shouldSuppressTransientPrivateReactionClear(userID, peer, req.MsgID, date) {
res, err := r.deps.Messages.GetMessageReactions(ctx, userID, domain.PrivateMessageReactionsRequest{
OwnerUserID: userID,
Peer: peer,
IDs: []int{req.MsgID},
})
if err != nil {
return nil, messageReactionErr(err)
}
return r.privateMessagesReactionsUpdates(ctx, userID, peer, res, []int{req.MsgID}), nil
}
if req.Big && len(reactions) > 0 {
r.rememberTransientPrivateBigReaction(userID, peer, req.MsgID, date)
}
res, err := r.deps.Messages.SetMessageReactions(ctx, userID, domain.SetPrivateMessageReactionsRequest{
UserID: userID,
Peer: peer,
@ -64,6 +80,9 @@ func (r *Router) onMessagesSendReaction(ctx context.Context, req *tg.MessagesSen
if err != nil {
return nil, messageReactionErr(err)
}
if len(reactions) == 0 {
r.forgetTransientPrivateBigReaction(userID, peer, req.MsgID)
}
if err := r.recordMessageReactionUse(ctx, userID, reactions, req.GetAddToRecent(), date); err != nil {
return nil, internalErr()
}
@ -94,6 +113,104 @@ func (r *Router) onMessagesSendReaction(ctx context.Context, req *tg.MessagesSen
return tgEmptyUpdates(int(r.clock.Now().Unix())), nil
}
const (
transientPrivateBigReactionClearWindowSeconds = 3
transientPrivateBigReactionMaxEntries = 4096
)
type transientPrivateBigReactionKey struct {
UserID int64
PeerID int64
MessageID int
}
type transientPrivateBigReactionEntry struct {
ExpiresAt int
}
type transientPrivateBigReactionCache struct {
mu sync.Mutex
entries map[transientPrivateBigReactionKey]transientPrivateBigReactionEntry
}
func transientPrivateBigReactionMapKey(userID int64, peer domain.Peer, messageID int) transientPrivateBigReactionKey {
return transientPrivateBigReactionKey{
UserID: userID,
PeerID: peer.ID,
MessageID: messageID,
}
}
func (r *Router) rememberTransientPrivateBigReaction(userID int64, peer domain.Peer, messageID int, date int) {
if peer.Type != domain.PeerTypeUser || userID == 0 || peer.ID == 0 || messageID <= 0 || date <= 0 {
return
}
r.transientPrivateBigReactions.remember(transientPrivateBigReactionMapKey(userID, peer, messageID), date+transientPrivateBigReactionClearWindowSeconds, date)
}
func (r *Router) shouldSuppressTransientPrivateReactionClear(userID int64, peer domain.Peer, messageID int, date int) bool {
return r.transientPrivateBigReactions.shouldSuppress(transientPrivateBigReactionMapKey(userID, peer, messageID), date)
}
func (r *Router) forgetTransientPrivateBigReaction(userID int64, peer domain.Peer, messageID int) {
r.transientPrivateBigReactions.forget(transientPrivateBigReactionMapKey(userID, peer, messageID))
}
func (c *transientPrivateBigReactionCache) remember(key transientPrivateBigReactionKey, expiresAt int, now int) {
c.mu.Lock()
defer c.mu.Unlock()
if c.entries == nil {
c.entries = make(map[transientPrivateBigReactionKey]transientPrivateBigReactionEntry)
}
if len(c.entries) >= transientPrivateBigReactionMaxEntries {
c.pruneLocked(now)
}
if len(c.entries) >= transientPrivateBigReactionMaxEntries {
c.dropOneLocked()
}
c.entries[key] = transientPrivateBigReactionEntry{ExpiresAt: expiresAt}
}
func (c *transientPrivateBigReactionCache) shouldSuppress(key transientPrivateBigReactionKey, now int) bool {
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.entries[key]
if !ok {
return false
}
if now > entry.ExpiresAt {
delete(c.entries, key)
return false
}
return true
}
func (c *transientPrivateBigReactionCache) forget(key transientPrivateBigReactionKey) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.entries, key)
}
func (c *transientPrivateBigReactionCache) pruneLocked(now int) {
for key, entry := range c.entries {
if now > entry.ExpiresAt {
delete(c.entries, key)
}
}
}
func (c *transientPrivateBigReactionCache) dropOneLocked() {
var oldestKey transientPrivateBigReactionKey
oldestExpiresAt := int(^uint(0) >> 1)
for key, entry := range c.entries {
if entry.ExpiresAt < oldestExpiresAt {
oldestKey = key
oldestExpiresAt = entry.ExpiresAt
}
}
delete(c.entries, oldestKey)
}
func (r *Router) recordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error {
if len(reactions) == 0 || r.deps.Channels == nil {
return nil

View file

@ -294,3 +294,30 @@ func TestMessagesSendReactionPrivatePushesViewerLocalMessageID(t *testing.T) {
t.Fatalf("pushed recent reactions = %+v set=%v, want one unread non-my reaction", recent, ok)
}
}
func TestTransientPrivateBigReactionCacheIsBoundedAndExpires(t *testing.T) {
r := &Router{}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 2002}
r.rememberTransientPrivateBigReaction(1001, peer, 1, 10)
if !r.shouldSuppressTransientPrivateReactionClear(1001, peer, 1, 12) {
t.Fatalf("transient big reaction clear should be suppressed inside window")
}
if r.shouldSuppressTransientPrivateReactionClear(1001, peer, 1, 14) {
t.Fatalf("transient big reaction clear should not be suppressed after expiry")
}
var cache transientPrivateBigReactionCache
for i := 0; i < transientPrivateBigReactionMaxEntries+100; i++ {
cache.remember(transientPrivateBigReactionKey{
UserID: 1001,
PeerID: int64(2000 + i),
MessageID: i + 1,
}, 100+i, 1)
}
cache.mu.Lock()
got := len(cache.entries)
cache.mu.Unlock()
if got > transientPrivateBigReactionMaxEntries {
t.Fatalf("transient cache entries = %d, want <= %d", got, transientPrivateBigReactionMaxEntries)
}
}

View file

@ -20,6 +20,7 @@ func (r *Router) registerMessages(d *tg.ServerDispatcher) {
d.OnMessagesGetMaskStickers(r.onMessagesGetMaskStickers)
d.OnMessagesGetFeaturedStickers(r.onMessagesGetFeaturedStickers)
d.OnMessagesGetFeaturedEmojiStickers(r.onMessagesGetFeaturedEmojiStickers)
d.OnMessagesGetOldFeaturedStickers(r.onMessagesGetOldFeaturedStickers)
d.OnMessagesGetRecentStickers(r.onMessagesGetRecentStickers)
d.OnMessagesGetFavedStickers(r.onMessagesGetFavedStickers)
d.OnMessagesGetSavedGifs(r.onMessagesGetSavedGifs)
@ -109,10 +110,7 @@ func (r *Router) registerMessages(d *tg.ServerDispatcher) {
d.OnMessagesGetEmojiStatusGroups(func(ctx context.Context, hash int) (tg.MessagesEmojiGroupsClass, error) {
return tdesktop.EmojiStatusGroups(), nil
})
d.OnMessagesGetEmojiStickerGroups(func(ctx context.Context, hash int) (tg.MessagesEmojiGroupsClass, error) {
// 自定义 emoji 贴纸的分类(Premium);telesrv 未 seed custom-emoji 集,保持空。
return &tg.MessagesEmojiGroupsNotModified{}, nil
})
d.OnMessagesGetEmojiStickerGroups(r.onMessagesGetEmojiStickerGroups)
d.OnMessagesGetEmojiProfilePhotoGroups(func(ctx context.Context, hash int) (tg.MessagesEmojiGroupsClass, error) {
return tdesktop.EmojiProfilePhotoGroups(), nil
})

View file

@ -117,18 +117,19 @@ type Router struct {
// updateStatus 高频续期时数秒内只落一次 DB。
lastSeenPersist sync.Map // userID(int64) -> int64(unix)
// tempKeyResolveCache 缓存 rawTempKeyID -> resolved perm带过期容量有界。
tempKeyResolveCache *tempKeyResolveCache
storyProjectionCache *storyProjectionCache
storyPinnedCache *storyPinnedAvailableCache
storyPinnedListCache *storyPinnedStoriesCache
channelFullBotCache *channelFullBotInfoCache
userFullProjectionCache *userFullProjectionCache
peerSettingsProjectionCache *peerSettingsProjectionCache
channelFullProjectionCache *channelFullProjectionCache
emojiStickers *emojiStickerIndex
notifySettings *notifySettingsCache
stickerCatalog *stickerCatalogCache
accountSettings *accountSettingsCache
tempKeyResolveCache *tempKeyResolveCache
storyProjectionCache *storyProjectionCache
storyPinnedCache *storyPinnedAvailableCache
storyPinnedListCache *storyPinnedStoriesCache
channelFullBotCache *channelFullBotInfoCache
userFullProjectionCache *userFullProjectionCache
peerSettingsProjectionCache *peerSettingsProjectionCache
channelFullProjectionCache *channelFullProjectionCache
emojiStickers *emojiStickerIndex
notifySettings *notifySettingsCache
stickerCatalog *stickerCatalogCache
transientPrivateBigReactions transientPrivateBigReactionCache
accountSettings *accountSettingsCache
// webPageResolveSem 是链接预览异步解析的并发信号量(有界):发送后把 pending 占位
// 解析为卡片并就地替换。满则丢弃任务(消息留 pending。nil=未启用(测试可直接调
// resolvePendingWebPage 同步验证)。

View file

@ -819,6 +819,9 @@ func (r *Router) messageContactUserID(ctx context.Context, userID int64, phone s
// messageMediaFromDocument 由 Document 构造 MessageMedia并从属性推导 Video/Round/Voice 标志。
func messageMediaFromDocument(doc domain.Document, spoiler bool, ttl int) *domain.MessageMedia {
media := &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Document: &doc, Spoiler: spoiler, TTLSeconds: ttl}
if doc.IsSticker() {
media.Nopremium = true
}
for _, attr := range doc.Attributes {
switch attr.Kind {
case domain.DocAttrVideo:

View file

@ -801,6 +801,9 @@ func TestSendMediaPrivateSticker(t *testing.T) {
if !ok {
t.Fatalf("expected MessageMediaDocument, got %T", msg.Media)
}
if !media.Nopremium {
t.Fatal("sticker message media missing nopremium flag")
}
doc, ok := media.Document.(*tg.Document)
if !ok {
t.Fatalf("expected tg.Document, got %T", media.Document)
@ -822,6 +825,28 @@ func TestSendMediaPrivateSticker(t *testing.T) {
}
}
func TestTGMessageMediaDocumentMarksHistoricalStickerNopremium(t *testing.T) {
media := tgMessageMedia(&domain.MessageMedia{
Kind: domain.MessageMediaKindDocument,
Document: &domain.Document{
ID: 555,
AccessHash: 5,
MimeType: "application/x-tgsticker",
Attributes: []domain.DocumentAttribute{
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
{Kind: domain.DocAttrSticker, Alt: "🙂", StickerSetID: 10, StickerSetAccessHash: 20},
},
},
})
docMedia, ok := media.(*tg.MessageMediaDocument)
if !ok {
t.Fatalf("media = %T, want *tg.MessageMediaDocument", media)
}
if !docMedia.Nopremium {
t.Fatal("historical sticker message media missing nopremium flag")
}
}
func TestSendMediaPrivateUploadedPhoto(t *testing.T) {
ctx := context.Background()
r, owner, friend := newMediaTestRouter(t)

View file

@ -102,17 +102,11 @@ func (r *Router) onMessagesGetStickerSet(ctx context.Context, req *tg.MessagesGe
zap.Int("documents", len(fallbackDocs)),
)
}
if req.Hash != 0 && req.Hash == fallbackSet.Hash {
return &tg.MessagesStickerSetNotModified{}, nil
}
return tgMessagesStickerSet(fallbackSet, fallbackDocs), nil
}
// 未 seed 的系统集 / 未知短名:回退兼容 stub避免破坏客户端。
return tdesktop.StickerSet(req), nil
}
if req.Hash != 0 && req.Hash == set.Hash {
return &tg.MessagesStickerSetNotModified{}, nil
}
set, err = r.stickerSetWithViewerInstallState(ctx, set)
if err != nil {
return nil, err
@ -191,6 +185,43 @@ func (r *Router) onMessagesGetEmojiStickers(ctx context.Context, hash int64) (tg
return r.allStickersForKind(ctx, hash, domain.StickerSetKindEmoji)
}
func (r *Router) onMessagesGetEmojiStickerGroups(ctx context.Context, hash int) (tg.MessagesEmojiGroupsClass, error) {
empty := func() tg.MessagesEmojiGroupsClass {
return &tg.MessagesEmojiGroups{Hash: 0, Groups: []tg.EmojiGroupClass{}}
}
if r.deps.Files == nil {
return empty(), nil
}
sets := r.stickerCatalogSets(ctx, domain.StickerSetKindEmoji)
visible := make([]domain.StickerSet, 0, len(sets))
for _, set := range sets {
if set.ID == 0 || set.Archived {
continue
}
visible = append(visible, set)
}
if len(visible) == 0 {
return empty(), nil
}
catalogHash := emojiStickerGroupsHash(visible)
if hash != 0 && hash == catalogHash {
return &tg.MessagesEmojiGroupsNotModified{}, nil
}
iconEmojiID := emojiStickerGroupIconID(visible)
if iconEmojiID == 0 {
return empty(), nil
}
return &tg.MessagesEmojiGroups{
Hash: catalogHash,
Groups: []tg.EmojiGroupClass{
&tg.EmojiGroupPremium{
Title: "Premium",
IconEmojiID: iconEmojiID,
},
},
}, nil
}
func (r *Router) onMessagesGetMaskStickers(ctx context.Context, hash int64) (tg.MessagesAllStickersClass, error) {
return r.allStickersForKind(ctx, hash, domain.StickerSetKindMasks)
}
@ -329,6 +360,13 @@ func (r *Router) onMessagesGetFeaturedEmojiStickers(ctx context.Context, hash in
return r.featuredStickersForKind(ctx, hash, domain.StickerSetKindEmoji)
}
func (r *Router) onMessagesGetOldFeaturedStickers(ctx context.Context, req *tg.MessagesGetOldFeaturedStickersRequest) (tg.MessagesFeaturedStickersClass, error) {
if req == nil {
return r.onMessagesGetFeaturedStickers(ctx, 0)
}
return r.onMessagesGetFeaturedStickers(ctx, req.Hash)
}
// featuredStickersForKind 把已 seed 的(未归档)贴纸/emoji 集作为 trending 呈现。
// 性能:先用集目录 hash 比对,命中即返回 *NotModified——封面文档解析只在 cache-miss
// 时发生(一次批量 GetDocuments避免每次请求都解析封面。
@ -467,6 +505,31 @@ func featuredStickerSetsHash(sets []domain.StickerSet) int64 {
return int64(tdesktopCountHash(values))
}
func emojiStickerGroupsHash(sets []domain.StickerSet) int {
values := make([]int64, 0, len(sets)*2)
for _, set := range sets {
if set.ID == 0 || set.Archived {
continue
}
values = append(values, set.ID, int64(set.Hash))
}
return int(tdesktopCountHash(values) & 0x7fffffff)
}
func emojiStickerGroupIconID(sets []domain.StickerSet) int64 {
for _, set := range sets {
if set.ThumbDocumentID != 0 {
return set.ThumbDocumentID
}
for _, id := range set.DocumentIDs {
if id != 0 {
return id
}
}
}
return 0
}
func boolHashValue(v bool) int64 {
if v {
return 1

View file

@ -218,6 +218,45 @@ func TestMessagesGetStickerSetAndroidPlaceholderUsesSeededSet(t *testing.T) {
}
}
func TestMessagesGetStickerSetReturnsFullOnMatchingHash(t *testing.T) {
ctx := context.Background()
files := &fakeFiles{
docs: map[int64]domain.Document{
201: {ID: 201, AccessHash: 21, DCID: 2},
},
sets: map[domain.StickerSetKind][]domain.StickerSet{
domain.StickerSetKindStickers: {
{
ID: 10,
AccessHash: 100,
ShortName: "one",
Title: "One",
Kind: domain.StickerSetKindStickers,
Count: 1,
Hash: 123,
DocumentIDs: []int64{201},
},
},
},
}
r := &Router{deps: Deps{Files: files}}
res, err := r.onMessagesGetStickerSet(ctx, &tg.MessagesGetStickerSetRequest{
Stickerset: &tg.InputStickerSetID{ID: 10, AccessHash: 100},
Hash: 123,
})
if err != nil {
t.Fatalf("getStickerSet matching hash: %v", err)
}
full, ok := res.(*tg.MessagesStickerSet)
if !ok {
t.Fatalf("getStickerSet matching hash = %T, want *tg.MessagesStickerSet", res)
}
if full.Set.ID != 10 || len(full.Documents) != 1 {
t.Fatalf("getStickerSet matching hash returned set %d docs %d, want set 10 with one doc", full.Set.ID, len(full.Documents))
}
}
func TestMessagesGetStickerSetAndroidPlaceholderFallsBackToEmptyWithoutSeed(t *testing.T) {
ctx := context.Background()
r := &Router{deps: Deps{Files: &fakeFiles{}}}
@ -236,6 +275,30 @@ func TestMessagesGetStickerSetAndroidPlaceholderFallsBackToEmptyWithoutSeed(t *t
}
}
func TestStickerSetRefFromSystemInputs(t *testing.T) {
tests := []struct {
name string
in tg.InputStickerSetClass
want string
}{
{"emoji default statuses", &tg.InputStickerSetEmojiDefaultStatuses{}, domain.StickerSetSystemKeyEmojiDefaultStatuses},
{"emoji channel default statuses", &tg.InputStickerSetEmojiChannelDefaultStatuses{}, domain.StickerSetSystemKeyEmojiDefaultStatuses},
{"emoji default topic icons", &tg.InputStickerSetEmojiDefaultTopicIcons{}, domain.StickerSetSystemKeyEmojiDefaultTopicIcons},
{"premium gifts", &tg.InputStickerSetPremiumGifts{}, domain.StickerSetSystemKeyPremiumGifts},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ref, ok := stickerSetRefFromInput(tt.in)
if !ok {
t.Fatalf("stickerSetRefFromInput(%T) not handled", tt.in)
}
if ref.Kind != domain.StickerSetRefBySystem || ref.SystemKey != tt.want {
t.Fatalf("ref = %+v, want system key %q", ref, tt.want)
}
})
}
}
func TestMessagesGetMaskStickersUsesMaskCatalog(t *testing.T) {
ctx := context.Background()
files := &fakeFiles{
@ -333,6 +396,86 @@ func TestMessagesGetFeaturedStickersSurfacesSeededSets(t *testing.T) {
}
}
func TestMessagesGetOldFeaturedStickersUsesFeaturedCatalog(t *testing.T) {
ctx := context.Background()
files := &fakeFiles{
docs: map[int64]domain.Document{
201: {ID: 201, AccessHash: 21, DCID: 2},
},
sets: map[domain.StickerSetKind][]domain.StickerSet{
domain.StickerSetKindStickers: {
{
ID: 10,
AccessHash: 100,
ShortName: "one",
Title: "One",
Kind: domain.StickerSetKindStickers,
Count: 1,
Hash: 123,
DocumentIDs: []int64{201},
},
},
},
}
r := &Router{deps: Deps{Files: files}}
res, err := r.onMessagesGetOldFeaturedStickers(ctx, &tg.MessagesGetOldFeaturedStickersRequest{Limit: 20})
if err != nil {
t.Fatalf("getOldFeaturedStickers: %v", err)
}
full, ok := res.(*tg.MessagesFeaturedStickers)
if !ok {
t.Fatalf("getOldFeaturedStickers = %T, want *tg.MessagesFeaturedStickers", res)
}
if len(full.Sets) != 1 {
t.Fatalf("old featured sets = %d, want one", len(full.Sets))
}
}
func TestMessagesGetEmojiStickerGroupsUsesSeededEmojiCatalog(t *testing.T) {
ctx := context.Background()
files := &fakeFiles{
sets: map[domain.StickerSetKind][]domain.StickerSet{
domain.StickerSetKindEmoji: {
{
ID: 20,
AccessHash: 200,
ShortName: "emoji",
Title: "Emoji",
Kind: domain.StickerSetKindEmoji,
Count: 1,
Hash: 999,
Emojis: true,
ThumbDocumentID: 555,
DocumentIDs: []int64{201},
},
},
},
}
r := &Router{deps: Deps{Files: files}}
first, err := r.onMessagesGetEmojiStickerGroups(ctx, 0)
if err != nil {
t.Fatalf("getEmojiStickerGroups: %v", err)
}
full, ok := first.(*tg.MessagesEmojiGroups)
if !ok {
t.Fatalf("getEmojiStickerGroups = %T, want *tg.MessagesEmojiGroups", first)
}
if len(full.Groups) != 1 {
t.Fatalf("emoji sticker groups = %d, want one", len(full.Groups))
}
group, ok := full.Groups[0].(*tg.EmojiGroupPremium)
if !ok || group.IconEmojiID != 555 {
t.Fatalf("emoji sticker group = %T %+v, want premium icon 555", full.Groups[0], full.Groups[0])
}
second, err := r.onMessagesGetEmojiStickerGroups(ctx, full.Hash)
if err != nil {
t.Fatalf("getEmojiStickerGroups cached: %v", err)
}
if _, ok := second.(*tg.MessagesEmojiGroupsNotModified); !ok {
t.Fatalf("cached getEmojiStickerGroups = %T, want notModified", second)
}
}
// countingStickerFiles 包 *fakeFiles 计数 ListStickerSets验证目录缓存短路。
type countingStickerFiles struct {
*fakeFiles
@ -447,6 +590,59 @@ func TestTGDocumentCompactsCachedThumbToDownloadableSize(t *testing.T) {
}
}
func TestTGDocumentDropsSeedSyntheticTGSPreviewThumb(t *testing.T) {
doc := tgDocument(domain.Document{
ID: 100,
AccessHash: 1,
DCID: 2,
MimeType: "application/x-tgsticker",
Thumbs: []domain.PhotoSize{
{Kind: domain.PhotoSizeKindCached, Type: "m", W: 1, H: 1, Bytes: []byte("png")},
},
})
full, ok := doc.(*tg.Document)
if !ok {
t.Fatalf("tgDocument = %T, want *tg.Document", doc)
}
if len(full.Thumbs) != 0 {
t.Fatalf("thumbs = %#v, want no synthetic TGS preview thumb", full.Thumbs)
}
}
func TestTGDocumentAddsAnimatedAttributeForTGSSticker(t *testing.T) {
doc := tgDocument(domain.Document{
ID: 100,
AccessHash: 1,
DCID: 2,
MimeType: "application/x-tgsticker",
Attributes: []domain.DocumentAttribute{
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
{Kind: domain.DocAttrSticker, Alt: "🙂", StickerSetID: 10, StickerSetAccessHash: 20},
{Kind: domain.DocAttrFilename, FileName: "AnimatedSticker.tgs"},
},
})
full, ok := doc.(*tg.Document)
if !ok {
t.Fatalf("tgDocument = %T, want *tg.Document", doc)
}
hasSticker := false
hasAnimated := false
for _, attr := range full.Attributes {
switch attr.(type) {
case *tg.DocumentAttributeSticker:
hasSticker = true
case *tg.DocumentAttributeAnimated:
hasAnimated = true
}
}
if !hasSticker {
t.Fatal("TGS sticker document missing sticker attribute")
}
if !hasAnimated {
t.Fatal("TGS sticker document missing synthesized animated attribute")
}
}
func TestTGDocumentUsesDomainDocumentID(t *testing.T) {
const documentID int64 = 1382305375846410902