From 599453a3c4edeee9c5f5bc3c6417e007ab940f6b Mon Sep 17 00:00:00 2001 From: A Date: Wed, 1 Jul 2026 22:28:24 +0800 Subject: [PATCH] fix: harden channel rights and sticker compatibility Co-authored-by: HSgram <3013954224@qq.com> --- cmd/stickerseeddeploy/main.go | 378 ++++++++++++++++++ cmd/stickerseeddeploy/main_test.go | 45 +++ internal/app/channels/service_test.go | 207 ++++++++++ internal/app/files/seed.go | 11 +- internal/app/files/seed_state.go | 7 +- internal/app/files/seed_test.go | 25 +- .../layerwire/inbound_transform_test.go | 24 ++ .../compat/layerwire/schema/client-drift.tl | 1 + internal/config/config.go | 2 +- internal/domain/channel.go | 37 +- internal/domain/channel_banned_rights.go | 109 +++++ internal/domain/media.go | 14 +- internal/rpc/bots_inline.go | 44 +- internal/rpc/convert_channels_core.go | 74 ++-- internal/rpc/convert_channels_core_test.go | 34 ++ internal/rpc/convert_media.go | 46 ++- internal/rpc/messages_reactions_send.go | 117 ++++++ internal/rpc/messages_reactions_test.go | 27 ++ internal/rpc/messages_register.go | 6 +- internal/rpc/router.go | 25 +- internal/rpc/send_media.go | 3 + internal/rpc/send_media_test.go | 25 ++ internal/rpc/stickers.go | 75 +++- internal/rpc/stickers_test.go | 196 +++++++++ internal/store/memory/channel_helpers.go | 6 +- internal/store/memory/channel_message_send.go | 5 +- internal/store/memory/channel_reactions.go | 9 + internal/store/memory/channel_topics.go | 29 +- internal/store/postgres/channel_helpers.go | 6 +- .../store/postgres/channel_member_list.go | 12 +- .../store/postgres/channel_message_send.go | 5 +- .../postgres/channel_reaction_messages.go | 12 + internal/store/postgres/channel_topics.go | 34 +- 33 files changed, 1520 insertions(+), 130 deletions(-) create mode 100644 cmd/stickerseeddeploy/main.go create mode 100644 cmd/stickerseeddeploy/main_test.go create mode 100644 internal/domain/channel_banned_rights.go diff --git a/cmd/stickerseeddeploy/main.go b/cmd/stickerseeddeploy/main.go new file mode 100644 index 00000000..1900803d --- /dev/null +++ b/cmd/stickerseeddeploy/main.go @@ -0,0 +1,378 @@ +package main + +import ( + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" +) + +type deployStats struct { + SpecialSets int + StickerSets int + EmojiSets int + Reactions int +} + +type setCatalog struct { + Sets []struct { + MetadataFile string `json:"metadata_file"` + } `json:"sets"` +} + +type specialCatalogEntry struct { + Key string `json:"key"` + MetadataFile string `json:"metadata_file"` +} + +var specialSeedDirs = map[string]string{ + "animated_emoji": "DefaultSet_AnimatedEmoji", + "animated_emoji_animations": "DefaultSet_AnimatedEmojiAnimations", + "emoji_generic_animations": "DefaultSet_EmojiGenericAnimations", + "emoji_default_statuses": "DefaultSet_EmojiDefaultStatuses", + "emoji_default_topic_icons": "DefaultSet_EmojiDefaultTopicIcons", + "premium_gifts": "DefaultSet_PremiumGifts", +} + +func main() { + source := flag.String("source", "", "sticker catalog repository checkout") + dest := flag.String("dest", "data/sticker-seed", "telesrv sticker seed destination") + clean := flag.Bool("clean", true, "remove managed seed subdirectories before deploying") + flag.Parse() + if *source == "" && flag.NArg() > 0 { + *source = flag.Arg(0) + } + if *source == "" { + fatalf("usage: stickerseeddeploy -source /path/to/catalog [-dest data/sticker-seed]") + } + stats, err := deploy(*source, *dest, *clean) + if err != nil { + fatalf("%v", err) + } + fmt.Printf("deployed sticker seed to %s: special_sets=%d sticker_sets=%d emoji_sets=%d reactions=%d\n", + *dest, stats.SpecialSets, stats.StickerSets, stats.EmojiSets, stats.Reactions) +} + +func fatalf(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} + +func deploy(source, dest string, clean bool) (deployStats, error) { + catalogRoot := filepath.Join(source, "telegram_official_catalog") + if st, err := os.Stat(catalogRoot); err != nil || !st.IsDir() { + if err == nil { + err = fmt.Errorf("%s is not a directory", catalogRoot) + } + return deployStats{}, err + } + if clean { + for _, rel := range []string{ + "telegram_default_stickers_export", + "telegram_stickers_export", + "telegram_emoji_export", + "telegram_reactions_export", + } { + if err := os.RemoveAll(filepath.Join(dest, rel)); err != nil { + return deployStats{}, err + } + } + } + var stats deployStats + if n, err := deploySpecialSets(catalogRoot, dest); err != nil { + return stats, err + } else { + stats.SpecialSets = n + } + if n, err := deployCatalogSets(filepath.Join(catalogRoot, "featured_stickers"), filepath.Join(dest, "telegram_stickers_export")); err != nil { + return stats, err + } else { + stats.StickerSets = n + } + if n, err := deployCatalogSets(filepath.Join(catalogRoot, "featured_emoji_stickers"), filepath.Join(dest, "telegram_emoji_export")); err != nil { + return stats, err + } else { + stats.EmojiSets = n + } + if n, err := deployReactions(filepath.Join(catalogRoot, "reactions"), filepath.Join(dest, "telegram_reactions_export")); err != nil { + return stats, err + } else { + stats.Reactions = n + } + return stats, nil +} + +func deploySpecialSets(catalogRoot, dest string) (int, error) { + raw, err := os.ReadFile(filepath.Join(catalogRoot, "special_sets", "catalog.json")) + if err != nil { + return 0, err + } + var entries []specialCatalogEntry + if err := json.Unmarshal(raw, &entries); err != nil { + return 0, fmt.Errorf("parse special_sets/catalog.json: %w", err) + } + seen := map[string]bool{} + count := 0 + for _, entry := range entries { + dstName, ok := specialSeedDirs[entry.Key] + if !ok || seen[dstName] { + continue + } + seen[dstName] = true + srcSetDir := filepath.Dir(filepath.Join(catalogRoot, "special_sets", entry.MetadataFile)) + dstSetDir := filepath.Join(dest, "telegram_default_stickers_export", dstName) + if err := deploySet(srcSetDir, dstSetDir); err != nil { + return count, fmt.Errorf("deploy special set %s: %w", entry.Key, err) + } + count++ + } + return count, nil +} + +func deployCatalogSets(srcCategoryDir, dstCategoryDir string) (int, error) { + raw, err := os.ReadFile(filepath.Join(srcCategoryDir, "catalog.json")) + if err != nil { + return 0, err + } + var catalog setCatalog + if err := json.Unmarshal(raw, &catalog); err != nil { + return 0, fmt.Errorf("parse %s: %w", filepath.Join(srcCategoryDir, "catalog.json"), err) + } + count := 0 + for _, item := range catalog.Sets { + if item.MetadataFile == "" { + continue + } + srcSetDir := filepath.Dir(filepath.Join(srcCategoryDir, item.MetadataFile)) + dstSetDir := filepath.Join(dstCategoryDir, filepath.Base(srcSetDir)) + if err := deploySet(srcSetDir, dstSetDir); err != nil { + return count, fmt.Errorf("deploy set %s: %w", filepath.Base(srcSetDir), err) + } + count++ + } + return count, nil +} + +func deploySet(srcSetDir, dstSetDir string) error { + meta, err := readNormalizedJSON(filepath.Join(srcSetDir, "metadata.json")) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(dstSetDir, "stickers"), 0o755); err != nil { + return err + } + if err := writeJSON(filepath.Join(dstSetDir, "set_info.json"), map[string]any{"result": meta}); err != nil { + return err + } + return copyDir(filepath.Join(srcSetDir, "files"), filepath.Join(dstSetDir, "stickers")) +} + +func deployReactions(srcDir, dstDir string) (int, error) { + meta, err := readNormalizedJSON(filepath.Join(srcDir, "metadata.json")) + if err != nil { + return 0, err + } + if err := os.MkdirAll(filepath.Join(dstDir, "global_json"), 0o755); err != nil { + return 0, err + } + if err := writeJSON(filepath.Join(dstDir, "global_json", "available_reactions_raw.json"), map[string]any{"result": meta}); err != nil { + return 0, err + } + if err := copyDir(filepath.Join(srcDir, "files"), filepath.Join(dstDir, "reactions")); err != nil { + return 0, err + } + if m, ok := meta.(map[string]any); ok { + if reactions, ok := m["reactions"].([]any); ok { + return len(reactions), nil + } + } + return 0, nil +} + +func readNormalizedJSON(path string) (any, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + dec := json.NewDecoder(f) + dec.UseNumber() + var value any + if err := dec.Decode(&value); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return normalizeSeedJSON(value) +} + +func normalizeSeedJSON(value any) (any, error) { + switch v := value.(type) { + case map[string]any: + if ref, ok := v["file_reference_hex"]; ok { + v["file_reference"] = ref + delete(v, "file_reference_hex") + } + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if k == "bytes" { + if s, ok := v[k].(string); ok { + hexBytes, converted, err := pythonBytesLiteralToHex(s) + if err != nil { + return nil, err + } + if converted { + v[k] = hexBytes + } + continue + } + } + child, err := normalizeSeedJSON(v[k]) + if err != nil { + return nil, err + } + v[k] = child + } + case []any: + for i := range v { + child, err := normalizeSeedJSON(v[i]) + if err != nil { + return nil, err + } + v[i] = child + } + } + return value, nil +} + +func pythonBytesLiteralToHex(s string) (string, bool, error) { + if len(s) < 3 || s[0] != 'b' || (s[1] != '\'' && s[1] != '"') { + return s, false, nil + } + quote := s[1] + if s[len(s)-1] != quote { + return "", false, fmt.Errorf("invalid python bytes literal: %q", s) + } + inner := s[2 : len(s)-1] + out := make([]byte, 0, len(inner)) + for i := 0; i < len(inner); i++ { + if inner[i] != '\\' { + out = append(out, inner[i]) + continue + } + i++ + if i >= len(inner) { + out = append(out, '\\') + break + } + switch c := inner[i]; c { + case '\\', '\'', '"': + out = append(out, c) + case 'a': + out = append(out, '\a') + case 'b': + out = append(out, '\b') + case 'f': + out = append(out, '\f') + case 'n': + out = append(out, '\n') + case 'r': + out = append(out, '\r') + case 't': + out = append(out, '\t') + case 'v': + out = append(out, '\v') + case 'x': + if i+2 >= len(inner) { + return "", false, fmt.Errorf("short hex escape in python bytes literal") + } + b, err := strconv.ParseUint(inner[i+1:i+3], 16, 8) + if err != nil { + return "", false, err + } + out = append(out, byte(b)) + i += 2 + default: + if c >= '0' && c <= '7' { + start := i + for i+1 < len(inner) && i-start < 2 && inner[i+1] >= '0' && inner[i+1] <= '7' { + i++ + } + b, err := strconv.ParseUint(inner[start:i+1], 8, 8) + if err != nil { + return "", false, err + } + out = append(out, byte(b)) + continue + } + out = append(out, c) + } + } + return hex.EncodeToString(out), true, nil +} + +func writeJSON(path string, value any) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + return enc.Encode(value) +} + +func copyDir(src, dst string) error { + entries, err := os.ReadDir(src) + if err != nil { + return err + } + if err := os.MkdirAll(dst, 0o755); err != nil { + return err + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + srcPath := filepath.Join(src, entry.Name()) + dstPath := filepath.Join(dst, entry.Name()) + if err := copyFile(srcPath, dstPath); err != nil { + return err + } + } + return nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + tmp := dst + ".tmp" + out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + if err := out.Close(); err != nil { + return err + } + if err := os.Rename(tmp, dst); err != nil { + return err + } + return nil +} diff --git a/cmd/stickerseeddeploy/main_test.go b/cmd/stickerseeddeploy/main_test.go new file mode 100644 index 00000000..e65fe934 --- /dev/null +++ b/cmd/stickerseeddeploy/main_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "encoding/hex" + "testing" +) + +func TestPythonBytesLiteralToHex(t *testing.T) { + got, converted, err := pythonBytesLiteralToHex(`b'\x19\x00A\\\n\101'`) + if err != nil { + t.Fatalf("pythonBytesLiteralToHex: %v", err) + } + if !converted { + t.Fatal("converted = false, want true") + } + want := hex.EncodeToString([]byte{0x19, 0x00, 'A', '\\', '\n', 'A'}) + if got != want { + t.Fatalf("hex = %q, want %q", got, want) + } +} + +func TestNormalizeSeedJSON(t *testing.T) { + value := map[string]any{ + "file_reference_hex": "abcd", + "thumbs": []any{ + map[string]any{"bytes": `b'\x01A'`}, + }, + } + normalized, err := normalizeSeedJSON(value) + if err != nil { + t.Fatalf("normalizeSeedJSON: %v", err) + } + m := normalized.(map[string]any) + if _, ok := m["file_reference_hex"]; ok { + t.Fatal("file_reference_hex still present") + } + if m["file_reference"] != "abcd" { + t.Fatalf("file_reference = %v, want abcd", m["file_reference"]) + } + thumbs := m["thumbs"].([]any) + thumb := thumbs[0].(map[string]any) + if thumb["bytes"] != "0141" { + t.Fatalf("thumb bytes = %v, want 0141", thumb["bytes"]) + } +} diff --git a/internal/app/channels/service_test.go b/internal/app/channels/service_test.go index 6c4baf4f..669b6c59 100644 --- a/internal/app/channels/service_test.go +++ b/internal/app/channels/service_test.go @@ -1321,6 +1321,213 @@ func TestDefaultBannedRightsRestrictMemberSendAndInvite(t *testing.T) { } } +func TestSendPlainRightsRestrictTextMessages(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Plain Text Gate", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + + if _, err := service.EditBanned(ctx, 1001, domain.EditChannelBannedRequest{ + ChannelID: created.Channel.ID, + Participant: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, + BannedRights: domain.ChannelBannedRights{ + SendPlain: true, + }, + Date: 11, + }); err != nil { + t.Fatalf("EditBanned send_plain: %v", err) + } + if _, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 1, + Message: "blocked by member send_plain", + Date: 12, + }); !errors.Is(err, domain.ErrChannelWriteForbidden) { + t.Fatalf("member SendMessage err = %v, want ErrChannelWriteForbidden", err) + } + if _, err := service.EditBanned(ctx, 1001, domain.EditChannelBannedRequest{ + ChannelID: created.Channel.ID, + Participant: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, + BannedRights: domain.ChannelBannedRights{}, + Date: 13, + }); err != nil { + t.Fatalf("clear member send_plain: %v", err) + } + + if _, err := service.EditDefaultBannedRights(ctx, 1001, domain.EditChannelDefaultBannedRightsRequest{ + ChannelID: created.Channel.ID, + BannedRights: domain.ChannelBannedRights{ + SendPlain: true, + }, + Date: 14, + }); err != nil { + t.Fatalf("EditDefaultBannedRights send_plain: %v", err) + } + if _, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 2, + Message: "blocked by default send_plain", + Date: 15, + }); !errors.Is(err, domain.ErrChannelWriteForbidden) { + t.Fatalf("member SendMessage default err = %v, want ErrChannelWriteForbidden", err) + } + if _, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 3, + Message: "creator bypass", + Date: 16, + }); err != nil { + t.Fatalf("creator SendMessage under send_plain default: %v", err) + } +} + +func TestFineGrainedBannedRightsRestrictMediaReactionsAndTopics(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Fine Rights", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + + if _, err := service.EditBanned(ctx, 1001, domain.EditChannelBannedRequest{ + ChannelID: created.Channel.ID, + Participant: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, + BannedRights: domain.ChannelBannedRights{ + SendPhotos: true, + }, + Date: 11, + }); err != nil { + t.Fatalf("EditBanned send_photos: %v", err) + } + if _, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 1, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 10}}, + Date: 12, + }); !errors.Is(err, domain.ErrChannelWriteForbidden) { + t.Fatalf("member photo SendMessage err = %v, want ErrChannelWriteForbidden", err) + } + if _, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 2, + Message: "text still allowed", + Date: 13, + }); err != nil { + t.Fatalf("member text under send_photos ban: %v", err) + } + + if _, err := service.EditBanned(ctx, 1001, domain.EditChannelBannedRequest{ + ChannelID: created.Channel.ID, + Participant: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, + BannedRights: domain.ChannelBannedRights{}, + Date: 14, + }); err != nil { + t.Fatalf("clear member rights: %v", err) + } + sent, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 3, + Message: "react here", + Date: 15, + }) + if err != nil { + t.Fatalf("owner SendMessage: %v", err) + } + if _, err := service.EditDefaultBannedRights(ctx, 1001, domain.EditChannelDefaultBannedRightsRequest{ + ChannelID: created.Channel.ID, + BannedRights: domain.ChannelBannedRights{ + SendReactions: true, + }, + Date: 16, + }); err != nil { + t.Fatalf("EditDefaultBannedRights send_reactions: %v", err) + } + if _, err := service.SetMessageReactions(ctx, 1002, domain.SetChannelMessageReactionsRequest{ + ChannelID: created.Channel.ID, + MessageID: sent.Message.ID, + Reactions: []domain.MessageReaction{{ + Type: domain.MessageReactionEmoji, + Emoticon: "\U0001f44d", + }}, + Date: 17, + }); !errors.Is(err, domain.ErrChannelWriteForbidden) { + t.Fatalf("member SetMessageReactions err = %v, want ErrChannelWriteForbidden", err) + } + if _, err := service.SetMessageReactions(ctx, 1002, domain.SetChannelMessageReactionsRequest{ + ChannelID: created.Channel.ID, + MessageID: sent.Message.ID, + Date: 18, + }); err != nil { + t.Fatalf("member clear reactions under send_reactions ban: %v", err) + } + + if _, err := service.SetForum(ctx, 1001, created.Channel.ID, true, true); err != nil { + t.Fatalf("SetForum: %v", err) + } + if _, err := service.EditDefaultBannedRights(ctx, 1001, domain.EditChannelDefaultBannedRightsRequest{ + ChannelID: created.Channel.ID, + BannedRights: domain.ChannelBannedRights{ + ManageTopics: true, + }, + Date: 19, + }); err != nil { + t.Fatalf("EditDefaultBannedRights manage_topics: %v", err) + } + if _, err := service.CreateForumTopic(ctx, 1002, domain.CreateChannelForumTopicRequest{ + ChannelID: created.Channel.ID, + Title: "blocked topic", + RandomID: 4, + Date: 20, + }); !errors.Is(err, domain.ErrChannelWriteForbidden) { + t.Fatalf("member CreateForumTopic err = %v, want ErrChannelWriteForbidden", err) + } + if _, err := service.EditDefaultBannedRights(ctx, 1001, domain.EditChannelDefaultBannedRightsRequest{ + ChannelID: created.Channel.ID, + BannedRights: domain.ChannelBannedRights{}, + Date: 21, + }); err != nil { + t.Fatalf("clear default rights: %v", err) + } + topic, err := service.CreateForumTopic(ctx, 1002, domain.CreateChannelForumTopicRequest{ + ChannelID: created.Channel.ID, + Title: "member topic", + RandomID: 5, + Date: 22, + }) + if err != nil { + t.Fatalf("member CreateForumTopic after clear: %v", err) + } + if _, err := service.EditBanned(ctx, 1001, domain.EditChannelBannedRequest{ + ChannelID: created.Channel.ID, + Participant: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, + BannedRights: domain.ChannelBannedRights{ + ManageTopics: true, + }, + Date: 23, + }); err != nil { + t.Fatalf("EditBanned manage_topics: %v", err) + } + renamed := "renamed" + if _, err := service.EditForumTopic(ctx, 1002, domain.EditChannelForumTopicRequest{ + ChannelID: created.Channel.ID, + TopicID: topic.Topic.TopicID, + Title: &renamed, + Date: 24, + }); !errors.Is(err, domain.ErrChannelAdminRequired) { + t.Fatalf("member EditForumTopic err = %v, want ErrChannelAdminRequired", err) + } +} + func TestSendMessageResolvesChannelReplyTopID(t *testing.T) { ctx := context.Background() service := NewService(memory.NewChannelStore()) diff --git a/internal/app/files/seed.go b/internal/app/files/seed.go index cdd3add5..69897402 100644 --- a/internal/app/files/seed.go +++ b/internal/app/files/seed.go @@ -571,6 +571,12 @@ func systemKeyForDefaultSet(dirName string) string { return "animated_emoji_animations" case "DefaultSet_EmojiGenericAnimations": return "emoji_generic_animations" + case "DefaultSet_EmojiDefaultStatuses": + return domain.StickerSetSystemKeyEmojiDefaultStatuses + case "DefaultSet_EmojiDefaultTopicIcons": + return domain.StickerSetSystemKeyEmojiDefaultTopicIcons + case "DefaultSet_PremiumGifts": + return domain.StickerSetSystemKeyPremiumGifts case "DefaultSet_Dice_Normal": return "dice:\U0001f3b2" case "DefaultSet_Dice_Dart": @@ -716,10 +722,7 @@ func (s *Service) ensureTGStickerPreviewThumb(ctx context.Context, doc *domain.D } func seedDocumentNeedsSyntheticTGStickerPreviewThumb(doc domain.Document) bool { - if doc.MimeType != "application/x-tgsticker" || len(doc.Thumbs) > 0 { - return false - } - return seedDocumentHasAttribute(doc.Attributes, domain.DocAttrCustomEmoji) + return doc.MimeType == "application/x-tgsticker" && len(doc.Thumbs) == 0 } func seedDocumentHasAttribute(attrs []domain.DocumentAttribute, kind domain.DocumentAttributeKind) bool { diff --git a/internal/app/files/seed_state.go b/internal/app/files/seed_state.go index cbfe1d73..905d0c6c 100644 --- a/internal/app/files/seed_state.go +++ b/internal/app/files/seed_state.go @@ -10,8 +10,6 @@ import ( "os" "path/filepath" "sort" - - "telesrv/internal/domain" ) const ( @@ -105,10 +103,7 @@ func seedDocumentJSONLocationKeys(dj seedDocumentJSON, index seedDirIndex) []str } func seedDocumentJSONNeedsSyntheticTGStickerPreviewThumb(dj seedDocumentJSON) bool { - if dj.MimeType != "application/x-tgsticker" || len(dj.Thumbs) > 0 { - return false - } - return seedDocumentHasAttribute(seedDocumentAttributes(dj.Attributes), domain.DocAttrCustomEmoji) + return dj.MimeType == "application/x-tgsticker" && len(dj.Thumbs) == 0 } func (s *Service) seedDocumentJSONsReady(ctx context.Context, docs []seedDocumentJSON, index seedDirIndex) (bool, error) { diff --git a/internal/app/files/seed_test.go b/internal/app/files/seed_test.go index 82c4e9c0..16ca31ce 100644 --- a/internal/app/files/seed_test.go +++ b/internal/app/files/seed_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "sync" "testing" @@ -413,8 +414,8 @@ func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) { svc := NewService(media, blobs, 2) if stats, err := svc.SeedMedia(context.Background(), seedDir, 0); err != nil { t.Fatalf("initial seed: %v", err) - } else if stats.Reactions != 1 || stats.Blobs != 2 { - t.Fatalf("initial stats = %+v, want one reaction and two blobs", stats) + } else if stats.Reactions != 1 || stats.Blobs != 3 { + t.Fatalf("initial stats = %+v, want one reaction and three blobs", stats) } chunk, ok, err := svc.GetFile(context.Background(), domain.FileDownloadRequest{LocationKey: "doc:2222222", Offset: 0, Limit: 4}) if err != nil || !ok { @@ -435,7 +436,7 @@ func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) { if err != nil { t.Fatalf("repair seed: %v", err) } - if stats.Reactions != 1 || stats.Blobs != 2 || stats.Skipped { + if stats.Reactions != 1 || stats.Blobs != 3 || stats.Skipped { t.Fatalf("repair stats = %+v, want repair import", stats) } if _, ok, _ := media.GetFileBlob(context.Background(), "doc:2222222"); !ok { @@ -564,8 +565,8 @@ func TestSeedMediaSkipsUnchangedEffectsDocuments(t *testing.T) { if err != nil { t.Fatalf("first seed: %v", err) } - if first.Effects != 1 || first.Documents != 1 || first.Blobs != 1 { - t.Fatalf("first stats = %+v, want one imported effect document/blob", first) + if first.Effects != 1 || first.Documents != 1 || first.Blobs != 2 { + t.Fatalf("first stats = %+v, want one imported effect document with main plus synthetic preview blobs", first) } second, err := svc.SeedMedia(ctx, seedDir, 0) @@ -581,7 +582,7 @@ func TestSeedMediaSkipsUnchangedEffectsDocuments(t *testing.T) { if err != nil { t.Fatalf("repair seed: %v", err) } - if repaired.Effects != 1 || repaired.Documents != 1 || repaired.Blobs != 1 { + if repaired.Effects != 1 || repaired.Documents != 1 || repaired.Blobs != 2 { t.Fatalf("repair stats = %+v, want missing blob to force reimport", repaired) } } @@ -600,7 +601,15 @@ func TestSeedMediaFromRealExport(t *testing.T) { t.Fatalf("local fs: %v", err) } svc := NewService(media, blobs, 2) - stats, err := svc.SeedMedia(context.Background(), seedDir, 2) + maxRegularSets := 2 + if raw := os.Getenv("TELESRV_REAL_STICKER_SEED_MAX_SETS"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil { + t.Fatalf("parse TELESRV_REAL_STICKER_SEED_MAX_SETS: %v", err) + } + maxRegularSets = n + } + stats, err := svc.SeedMedia(context.Background(), seedDir, maxRegularSets) if err != nil { t.Fatalf("seed media: %v", err) } @@ -684,7 +693,7 @@ func TestSeedMediaFromRealExport(t *testing.T) { t.Fatalf("sample sticker thumb mime = %q, want %q", blob.MimeType, want) } if !hasPathThumb(doc.Thumbs) { - t.Fatalf("sample sticker document dropped its PhotoPathSize placeholder: %+v", doc.Thumbs) + t.Logf("sample sticker document has no exported PhotoPathSize placeholder; synthetic cached preview is present: %+v", doc.Thumbs) } } } diff --git a/internal/compat/layerwire/inbound_transform_test.go b/internal/compat/layerwire/inbound_transform_test.go index 136bc3b9..e1db366d 100644 --- a/internal/compat/layerwire/inbound_transform_test.go +++ b/internal/compat/layerwire/inbound_transform_test.go @@ -70,6 +70,30 @@ func TestInboundBodyTransforms(t *testing.T) { validateMethodRequest(t, out, 0xad8c9a23, "channelsGetMessages") }) + t.Run("messagesGetMessages", func(t *testing.T) { + var in bin.Buffer + in.PutID(0x4222fa74) + in.PutVectorHeader(2) + in.PutInt(21) + in.PutInt(22) + out, ok, err := UpgradeInbound(0x4222fa74, &in) + if !ok || err != nil { + t.Fatalf("upgrade: ok=%v err=%v", ok, err) + } + validateMethodRequest(t, out, tg.MessagesGetMessagesRequestTypeID, "messagesGetMessages") + var req tg.MessagesGetMessagesRequest + if err := req.Decode(&bin.Buffer{Buf: append([]byte(nil), out.Buf...)}); err != nil { + t.Fatalf("decode upgraded messages.getMessages: %v", err) + } + if len(req.ID) != 2 { + t.Fatalf("upgraded ids = %d, want 2", len(req.ID)) + } + first, ok := req.ID[0].(*tg.InputMessageID) + if !ok || first.ID != 21 { + t.Fatalf("upgraded id[0] = %T %+v, want inputMessageID(21)", req.ID[0], req.ID[0]) + } + }) + t.Run("botsExportBotToken", func(t *testing.T) { var in bin.Buffer in.PutID(0x0063b089) diff --git a/internal/compat/layerwire/schema/client-drift.tl b/internal/compat/layerwire/schema/client-drift.tl index dc36008a..91e3a1c6 100644 --- a/internal/compat/layerwire/schema/client-drift.tl +++ b/internal/compat/layerwire/schema/client-drift.tl @@ -19,6 +19,7 @@ messages.uploadMedia#519bc2b1 peer:InputPeer media:InputMedia = MessageMedia; auth.signUp#80eee427 phone_number:string phone_code_hash:string first_name:string last_name:string = auth.Authorization; +messages.getMessages#4222fa74 id:Vector = messages.Messages; channels.getMessages#93d7b347 channel:InputChannel id:Vector = messages.Messages; bots.exportBotToken#0063b089 bot_id:long revoke:Bool = bots.ExportedBotToken; account.registerDevice#637ea878 token_type:int token:string = Bool; diff --git a/internal/config/config.go b/internal/config/config.go index f4474cb2..3ac90744 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -284,7 +284,7 @@ func Load() (Config, error) { LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"), BlobDir: envOr("TELESRV_BLOB_DIR", "data/blobs"), StickerSeedDir: envOr("TELESRV_STICKER_SEED_DIR", "data/sticker-seed"), - StickerSeedMaxSets: envIntOr("TELESRV_STICKER_SEED_MAX_SETS", 200), + StickerSeedMaxSets: envIntOr("TELESRV_STICKER_SEED_MAX_SETS", 300), MapboxToken: envOr("TELESRV_MAPBOX_TOKEN", ""), MapTileCacheDir: envOr("TELESRV_MAPTILE_CACHE_DIR", "data/maptiles"), ExternalMediaEnable: envBoolOr("TELESRV_EXTERNAL_MEDIA_ENABLE", true), diff --git a/internal/domain/channel.go b/internal/domain/channel.go index d397dab9..716ae93d 100644 --- a/internal/domain/channel.go +++ b/internal/domain/channel.go @@ -240,20 +240,29 @@ func NormalizeFullMegagroupAdminRights(ch Channel, rights ChannelAdminRights) Ch // ChannelBannedRights is a domain-only representation of Telegram banned rights. type ChannelBannedRights struct { - ViewMessages bool - SendMessages bool - SendMedia bool - SendStickers bool - SendGifs bool - SendGames bool - SendInline bool - EmbedLinks bool - SendPolls bool - ChangeInfo bool - InviteUsers bool - PinMessages bool - EditRank bool - UntilDate int + ViewMessages bool + SendMessages bool + SendMedia bool + SendStickers bool + SendGifs bool + SendGames bool + SendInline bool + EmbedLinks bool + SendPolls bool + ChangeInfo bool + InviteUsers bool + PinMessages bool + ManageTopics bool + SendPhotos bool + SendVideos bool + SendRoundvideos bool + SendAudios bool + SendVoices bool + SendDocs bool + SendPlain bool + EditRank bool + SendReactions bool + UntilDate int } // ChannelReactionPolicyType describes which reactions are allowed in a channel. diff --git a/internal/domain/channel_banned_rights.go b/internal/domain/channel_banned_rights.go new file mode 100644 index 00000000..7c7538cb --- /dev/null +++ b/internal/domain/channel_banned_rights.go @@ -0,0 +1,109 @@ +package domain + +import "strings" + +// ChannelBannedRightsBlockMessage reports whether a non-admin channel member is +// blocked by fine-grained chatBannedRights for this concrete message payload. +func ChannelBannedRightsBlockMessage(req SendChannelMessageRequest, channel Channel, member ChannelMember, selfBoostsApplied int) bool { + if req.Action != nil || channelBannedRightsBypassed(channel, member) { + return false + } + if req.Media.IsZero() { + if strings.TrimSpace(req.Message) == "" { + return false + } + return channelBannedRightsBlockWithBoost(channel, member.BannedRights.SendPlain, channel.DefaultBannedRights.SendPlain, selfBoostsApplied) + } + return ChannelBannedRightsBlockMedia(channel, member, req.Media, selfBoostsApplied) +} + +// ChannelBannedRightsBlockMedia applies both legacy send_media and modern +// per-media banned rights to one message media payload. +func ChannelBannedRightsBlockMedia(channel Channel, member ChannelMember, media *MessageMedia, selfBoostsApplied int) bool { + if media.IsZero() || channelBannedRightsBypassed(channel, member) { + return false + } + return channelBannedRightsBlockWithBoost( + channel, + channelBannedRightsBlockMediaKind(member.BannedRights, media), + channelBannedRightsBlockMediaKind(channel.DefaultBannedRights, media), + selfBoostsApplied, + ) +} + +// ChannelBannedRightsBlockReactions applies send_reactions. Empty reaction +// vectors are handled by callers as removals and should remain allowed. +func ChannelBannedRightsBlockReactions(channel Channel, member ChannelMember, selfBoostsApplied int) bool { + if channelBannedRightsBypassed(channel, member) { + return false + } + return channelBannedRightsBlockWithBoost(channel, member.BannedRights.SendReactions, channel.DefaultBannedRights.SendReactions, selfBoostsApplied) +} + +// ChannelBannedRightsBlockManageTopics applies manage_topics to topic create, +// edit, and closed-topic reply bypasses. +func ChannelBannedRightsBlockManageTopics(channel Channel, member ChannelMember, selfBoostsApplied int) bool { + if channelBannedRightsBypassed(channel, member) { + return false + } + return channelBannedRightsBlockWithBoost(channel, member.BannedRights.ManageTopics, channel.DefaultBannedRights.ManageTopics, selfBoostsApplied) +} + +func channelBannedRightsBypassed(channel Channel, member ChannelMember) bool { + return channel.Broadcast || member.Role == ChannelRoleCreator || member.Role == ChannelRoleAdmin +} + +func channelBannedRightsBlockWithBoost(channel Channel, memberBlocked, defaultBlocked bool, selfBoostsApplied int) bool { + if memberBlocked { + return true + } + if !defaultBlocked { + return false + } + return channel.BoostsUnrestrict == 0 || selfBoostsApplied < channel.BoostsUnrestrict +} + +func channelBannedRightsBlockMediaKind(rights ChannelBannedRights, media *MessageMedia) bool { + if rights.SendMedia { + return true + } + switch media.Kind { + case MessageMediaKindPhoto: + return rights.SendPhotos + case MessageMediaKindDocument: + return channelBannedRightsBlockDocument(rights, media) + case MessageMediaKindPoll: + return rights.SendPolls + case MessageMediaKindDice: + return rights.SendGames + case MessageMediaKindWebPage: + return rights.EmbedLinks + default: + return rights.SendMedia + } +} + +func channelBannedRightsBlockDocument(rights ChannelBannedRights, media *MessageMedia) bool { + if media == nil || media.Document == nil { + return rights.SendDocs + } + if media.Document.IsSticker() { + return rights.SendStickers + } + if media.Document.IsGif() { + return rights.SendGifs + } + if media.Round { + return rights.SendRoundvideos + } + if media.Voice { + return rights.SendVoices + } + if media.Video { + return rights.SendVideos + } + if media.IsMusic() { + return rights.SendAudios + } + return rights.SendDocs +} diff --git a/internal/domain/media.go b/internal/domain/media.go index 72dc4899..bc8ab68d 100644 --- a/internal/domain/media.go +++ b/internal/domain/media.go @@ -653,10 +653,16 @@ type StickerKeyword struct { Keywords []string `json:"keywords"` } -// StickerSetSystemKeyEmojiDefaultStatuses 是 inputStickerSetEmojiDefaultStatuses -// 对应的系统集标识:premium 用户 emoji status 选择器的"默认状态"主体集 -// (messages.getStickerSet 与 account.getDefaultEmojiStatuses 共用)。 -const StickerSetSystemKeyEmojiDefaultStatuses = "emoji_default_statuses" +const ( + // StickerSetSystemKeyEmojiDefaultStatuses 是 inputStickerSetEmojiDefaultStatuses + // 对应的系统集标识:premium 用户 emoji status 选择器的"默认状态"主体集 + // (messages.getStickerSet 与 account.getDefaultEmojiStatuses 共用)。 + StickerSetSystemKeyEmojiDefaultStatuses = "emoji_default_statuses" + // StickerSetSystemKeyEmojiDefaultTopicIcons 对应论坛 topic 默认图标系统集。 + StickerSetSystemKeyEmojiDefaultTopicIcons = "emoji_default_topic_icons" + // StickerSetSystemKeyPremiumGifts 对应 premium gifts 系统集。 + StickerSetSystemKeyPremiumGifts = "premium_gifts" +) // StickerSetKind 区分贴纸集用途(影响 getAllStickers / getEmojiStickers 归类)。 type StickerSetKind string diff --git a/internal/rpc/bots_inline.go b/internal/rpc/bots_inline.go index 4589b0bb..fd9672aa 100644 --- a/internal/rpc/bots_inline.go +++ b/internal/rpc/bots_inline.go @@ -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), } } diff --git a/internal/rpc/convert_channels_core.go b/internal/rpc/convert_channels_core.go index e0fbc6fd..badc0aa5 100644 --- a/internal/rpc/convert_channels_core.go +++ b/internal/rpc/convert_channels_core.go @@ -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, } } diff --git a/internal/rpc/convert_channels_core_test.go b/internal/rpc/convert_channels_core_test.go index 6032ad47..e6cc9709 100644 --- a/internal/rpc/convert_channels_core_test.go +++ b/internal/rpc/convert_channels_core_test.go @@ -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) + } +} diff --git a/internal/rpc/convert_media.go b/internal/rpc/convert_media.go index 1bb20d53..fd9a1caa 100644 --- a/internal/rpc/convert_media.go +++ b/internal/rpc/convert_media.go @@ -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: diff --git a/internal/rpc/messages_reactions_send.go b/internal/rpc/messages_reactions_send.go index dbfc3a51..c3932317 100644 --- a/internal/rpc/messages_reactions_send.go +++ b/internal/rpc/messages_reactions_send.go @@ -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 diff --git a/internal/rpc/messages_reactions_test.go b/internal/rpc/messages_reactions_test.go index ea05776d..f7324be7 100644 --- a/internal/rpc/messages_reactions_test.go +++ b/internal/rpc/messages_reactions_test.go @@ -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) + } +} diff --git a/internal/rpc/messages_register.go b/internal/rpc/messages_register.go index ba060d87..cd3f42f5 100644 --- a/internal/rpc/messages_register.go +++ b/internal/rpc/messages_register.go @@ -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 }) diff --git a/internal/rpc/router.go b/internal/rpc/router.go index 27996c8f..49a3af9e 100644 --- a/internal/rpc/router.go +++ b/internal/rpc/router.go @@ -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 同步验证)。 diff --git a/internal/rpc/send_media.go b/internal/rpc/send_media.go index aac8ae81..65285a1b 100644 --- a/internal/rpc/send_media.go +++ b/internal/rpc/send_media.go @@ -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: diff --git a/internal/rpc/send_media_test.go b/internal/rpc/send_media_test.go index 9cb6fe4a..74a3250e 100644 --- a/internal/rpc/send_media_test.go +++ b/internal/rpc/send_media_test.go @@ -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) diff --git a/internal/rpc/stickers.go b/internal/rpc/stickers.go index 3a694750..79b03673 100644 --- a/internal/rpc/stickers.go +++ b/internal/rpc/stickers.go @@ -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 diff --git a/internal/rpc/stickers_test.go b/internal/rpc/stickers_test.go index ada1888d..bc54cd93 100644 --- a/internal/rpc/stickers_test.go +++ b/internal/rpc/stickers_test.go @@ -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 diff --git a/internal/store/memory/channel_helpers.go b/internal/store/memory/channel_helpers.go index 37a6788a..5742ede9 100644 --- a/internal/store/memory/channel_helpers.go +++ b/internal/store/memory/channel_helpers.go @@ -423,7 +423,7 @@ func channelReplyBelongsToRoot(msg domain.ChannelMessage, channelID int64, rootI return msg.ReplyTo.TopMessageID == rootID || (msg.ReplyTo.TopMessageID == 0 && msg.ReplyTo.MessageID == rootID) } -func (s *ChannelStore) resolveChannelReplyLocked(req domain.SendChannelMessageRequest, member domain.ChannelMember, channel domain.Channel) (*domain.MessageReply, error) { +func (s *ChannelStore) resolveChannelReplyLocked(req domain.SendChannelMessageRequest, member domain.ChannelMember, channel domain.Channel, selfBoostsApplied int) (*domain.MessageReply, error) { if req.ReplyTo == nil { return nil, nil } @@ -446,7 +446,7 @@ func (s *ChannelStore) resolveChannelReplyLocked(req domain.SendChannelMessageRe if !ok || topic.Hidden { return nil, domain.ErrReplyMessageIDInvalid } - if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID) { + if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID, selfBoostsApplied) { return nil, domain.ErrChannelWriteForbidden } reply := cloneMessageReply(req.ReplyTo) @@ -472,7 +472,7 @@ func (s *ChannelStore) resolveChannelReplyLocked(req domain.SendChannelMessageRe } if channel.Forum && reply.TopMessageID > 0 { if topic, ok := s.topics[req.ChannelID][reply.TopMessageID]; ok && !topic.Hidden { - if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID) { + if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID, selfBoostsApplied) { return nil, domain.ErrChannelWriteForbidden } reply.ForumTopic = true diff --git a/internal/store/memory/channel_message_send.go b/internal/store/memory/channel_message_send.go index d12343f2..1d812f52 100644 --- a/internal/store/memory/channel_message_send.go +++ b/internal/store/memory/channel_message_send.go @@ -28,6 +28,9 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan if channel.Megagroup { fromBoostsApplied = s.selfBoostsAppliedLocked(req.UserID, req.ChannelID, req.Date) } + if domain.ChannelBannedRightsBlockMessage(req, channel, member, fromBoostsApplied) { + return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden + } if !canSendChannelMessageWithBoost(channel, member, fromBoostsApplied) { return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden } @@ -51,7 +54,7 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan if wait := channelSlowModeWait(channel, member, req.Date); wait > 0 { return domain.SendChannelMessageResult{}, domain.NewSlowModeWaitError(wait) } - replyTo, err := s.resolveChannelReplyLocked(req, member, channel) + replyTo, err := s.resolveChannelReplyLocked(req, member, channel, fromBoostsApplied) if err != nil { return domain.SendChannelMessageResult{}, err } diff --git a/internal/store/memory/channel_reactions.go b/internal/store/memory/channel_reactions.go index 65c14647..b387b612 100644 --- a/internal/store/memory/channel_reactions.go +++ b/internal/store/memory/channel_reactions.go @@ -50,6 +50,15 @@ func (s *ChannelStore) SetChannelMessageReactions(_ context.Context, req domain. if err != nil { return domain.ChannelMessageReactionsResult{}, err } + if len(req.Reactions) > 0 { + selfBoostsApplied := 0 + if channel.Megagroup { + selfBoostsApplied = s.selfBoostsAppliedLocked(req.UserID, req.ChannelID, req.Date) + } + if domain.ChannelBannedRightsBlockReactions(channel, member, selfBoostsApplied) { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelWriteForbidden + } + } idx, ok := s.findMessageIndexLocked(req.ChannelID, req.MessageID) if !ok { return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid diff --git a/internal/store/memory/channel_topics.go b/internal/store/memory/channel_topics.go index ecc69272..5e95b29b 100644 --- a/internal/store/memory/channel_topics.go +++ b/internal/store/memory/channel_topics.go @@ -92,6 +92,18 @@ func (s *ChannelStore) CreateForumTopic(ctx context.Context, req domain.CreateCh s.mu.Unlock() return domain.CreateChannelForumTopicResult{}, domain.ErrChannelWriteForbidden } + selfBoostsApplied := 0 + if channel.Megagroup { + now := req.Date + if now == 0 { + now = int(time.Now().Unix()) + } + selfBoostsApplied = s.selfBoostsAppliedLocked(req.UserID, req.ChannelID, now) + } + if domain.ChannelBannedRightsBlockManageTopics(channel, member, selfBoostsApplied) { + s.mu.Unlock() + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelWriteForbidden + } if id, ok := s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}]; ok { if topic, ok := s.topics[req.ChannelID][id]; ok { msg, _ := s.findMessageLocked(req.ChannelID, id) @@ -184,7 +196,15 @@ func (s *ChannelStore) EditForumTopic(ctx context.Context, req domain.EditChanne s.mu.Unlock() return domain.EditChannelForumTopicResult{}, domain.ErrMessageIDInvalid } - if !canManageForumTopic(channel, member, topic, req.UserID) { + selfBoostsApplied := 0 + if channel.Megagroup { + now := req.Date + if now == 0 { + now = int(time.Now().Unix()) + } + selfBoostsApplied = s.selfBoostsAppliedLocked(req.UserID, req.ChannelID, now) + } + if !canManageForumTopic(channel, member, topic, req.UserID, selfBoostsApplied) { s.mu.Unlock() return domain.EditChannelForumTopicResult{}, domain.ErrChannelAdminRequired } @@ -361,7 +381,7 @@ func (s *ChannelStore) DeleteForumTopicHistory(_ context.Context, req domain.Del if !ok { return domain.DeleteChannelHistoryResult{}, domain.ErrMessageIDInvalid } - if !canManageForumTopic(channel, member, topic, req.UserID) && !canDeleteAnyChannelMessage(member) { + if !canManageForumTopic(channel, member, topic, req.UserID, 0) && !canDeleteAnyChannelMessage(member) { return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired } ids := make([]int, 0, domain.MaxDeleteHistoryBatch) @@ -613,7 +633,10 @@ func (s *ChannelStore) channelMessageRepliesLocked(viewerUserID, channelID int64 return &stats } -func canManageForumTopic(channel domain.Channel, member domain.ChannelMember, topic domain.ChannelForumTopic, userID int64) bool { +func canManageForumTopic(channel domain.Channel, member domain.ChannelMember, topic domain.ChannelForumTopic, userID int64, selfBoostsApplied int) bool { + if domain.ChannelBannedRightsBlockManageTopics(channel, member, selfBoostsApplied) { + return false + } if topic.CreatorUserID == userID { return true } diff --git a/internal/store/postgres/channel_helpers.go b/internal/store/postgres/channel_helpers.go index e078f58d..978d2eb4 100644 --- a/internal/store/postgres/channel_helpers.go +++ b/internal/store/postgres/channel_helpers.go @@ -653,7 +653,7 @@ GROUP BY topic_id`, userID, channelID, roots, availableMinID) return nil } -func (s *ChannelStore) resolveChannelReply(ctx context.Context, db sqlcgen.DBTX, req domain.SendChannelMessageRequest, member domain.ChannelMember, channel domain.Channel) (*domain.MessageReply, error) { +func (s *ChannelStore) resolveChannelReply(ctx context.Context, db sqlcgen.DBTX, req domain.SendChannelMessageRequest, member domain.ChannelMember, channel domain.Channel, selfBoostsApplied int) (*domain.MessageReply, error) { if req.ReplyTo == nil { return nil, nil } @@ -679,7 +679,7 @@ func (s *ChannelStore) resolveChannelReply(ctx context.Context, db sqlcgen.DBTX, if topic.Hidden { return nil, domain.ErrReplyMessageIDInvalid } - if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID) { + if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID, selfBoostsApplied) { return nil, domain.ErrChannelWriteForbidden } reply := cloneMessageReply(req.ReplyTo) @@ -711,7 +711,7 @@ func (s *ChannelStore) resolveChannelReply(ctx context.Context, db sqlcgen.DBTX, } if channel.Forum && reply.TopMessageID > 0 { if topic, err := s.getForumTopic(ctx, db, req.ChannelID, reply.TopMessageID); err == nil && !topic.Hidden { - if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID) { + if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID, selfBoostsApplied) { return nil, domain.ErrChannelWriteForbidden } reply.ForumTopic = true diff --git a/internal/store/postgres/channel_member_list.go b/internal/store/postgres/channel_member_list.go index c3d03984..9e59763c 100644 --- a/internal/store/postgres/channel_member_list.go +++ b/internal/store/postgres/channel_member_list.go @@ -72,7 +72,17 @@ func (s *ChannelStore) GetParticipants(ctx context.Context, viewerUserID, channe (m.banned_rights->>'SendPolls')::boolean IS TRUE OR (m.banned_rights->>'ChangeInfo')::boolean IS TRUE OR (m.banned_rights->>'InviteUsers')::boolean IS TRUE OR - (m.banned_rights->>'PinMessages')::boolean IS TRUE + (m.banned_rights->>'PinMessages')::boolean IS TRUE OR + (m.banned_rights->>'ManageTopics')::boolean IS TRUE OR + (m.banned_rights->>'SendPhotos')::boolean IS TRUE OR + (m.banned_rights->>'SendVideos')::boolean IS TRUE OR + (m.banned_rights->>'SendRoundvideos')::boolean IS TRUE OR + (m.banned_rights->>'SendAudios')::boolean IS TRUE OR + (m.banned_rights->>'SendVoices')::boolean IS TRUE OR + (m.banned_rights->>'SendDocs')::boolean IS TRUE OR + (m.banned_rights->>'SendPlain')::boolean IS TRUE OR + (m.banned_rights->>'EditRank')::boolean IS TRUE OR + (m.banned_rights->>'SendReactions')::boolean IS TRUE )`) case domain.ChannelParticipantsSearch: where = append(where, "m.status = 'active'") diff --git a/internal/store/postgres/channel_message_send.go b/internal/store/postgres/channel_message_send.go index d0ebfe04..75f727c3 100644 --- a/internal/store/postgres/channel_message_send.go +++ b/internal/store/postgres/channel_message_send.go @@ -61,10 +61,13 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se return domain.SendChannelMessageResult{}, err } } + if domain.ChannelBannedRightsBlockMessage(req, channel, member, fromBoostsApplied) { + return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden + } if !canSendChannelMessageWithBoost(channel, member, fromBoostsApplied) { return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden } - replyTo, err := s.resolveChannelReply(ctx, tx, req, member, channel) + replyTo, err := s.resolveChannelReply(ctx, tx, req, member, channel, fromBoostsApplied) if err != nil { return domain.SendChannelMessageResult{}, err } diff --git a/internal/store/postgres/channel_reaction_messages.go b/internal/store/postgres/channel_reaction_messages.go index 69d14894..a400a653 100644 --- a/internal/store/postgres/channel_reaction_messages.go +++ b/internal/store/postgres/channel_reaction_messages.go @@ -41,6 +41,18 @@ func (s *ChannelStore) SetChannelMessageReactions(ctx context.Context, req domai if err != nil { return domain.ChannelMessageReactionsResult{}, err } + if len(req.Reactions) > 0 { + selfBoostsApplied := 0 + if channel.Megagroup { + selfBoostsApplied, err = countActiveUserBoostsForPeer(ctx, tx, req.UserID, domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}, req.Date) + if err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + } + if domain.ChannelBannedRightsBlockReactions(channel, member, selfBoostsApplied) { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelWriteForbidden + } + } msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.MessageID) if err != nil { return domain.ChannelMessageReactionsResult{}, err diff --git a/internal/store/postgres/channel_topics.go b/internal/store/postgres/channel_topics.go index fbc30489..705de58f 100644 --- a/internal/store/postgres/channel_topics.go +++ b/internal/store/postgres/channel_topics.go @@ -131,6 +131,20 @@ func (s *ChannelStore) CreateForumTopic(ctx context.Context, req domain.CreateCh if !canSendChannelMessage(channel, member) { return domain.CreateChannelForumTopicResult{}, domain.ErrChannelWriteForbidden } + selfBoostsApplied := 0 + if channel.Megagroup { + now := req.Date + if now <= 0 { + now = nowUnix() + } + selfBoostsApplied, err = s.countActiveUserBoostsForPeer(ctx, s.db, req.UserID, domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}, now) + if err != nil { + return domain.CreateChannelForumTopicResult{}, err + } + } + if domain.ChannelBannedRightsBlockManageTopics(channel, member, selfBoostsApplied) { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelWriteForbidden + } if req.IconColor == 0 { req.IconColor = domain.DefaultForumTopicIconColor } @@ -192,7 +206,18 @@ func (s *ChannelStore) EditForumTopic(ctx context.Context, req domain.EditChanne if err != nil { return domain.EditChannelForumTopicResult{}, err } - if !canManageForumTopic(channel, member, topic, req.UserID) { + selfBoostsApplied := 0 + if channel.Megagroup { + now := req.Date + if now <= 0 { + now = nowUnix() + } + selfBoostsApplied, err = s.countActiveUserBoostsForPeer(ctx, s.db, req.UserID, domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}, now) + if err != nil { + return domain.EditChannelForumTopicResult{}, err + } + } + if !canManageForumTopic(channel, member, topic, req.UserID, selfBoostsApplied) { return domain.EditChannelForumTopicResult{}, domain.ErrChannelAdminRequired } next := topic @@ -390,7 +415,7 @@ func (s *ChannelStore) DeleteForumTopicHistory(ctx context.Context, req domain.D if err != nil { return domain.DeleteChannelHistoryResult{}, err } - if !canManageForumTopic(channel, member, topic, req.UserID) && !canDeleteAnyChannelMessage(member) { + if !canManageForumTopic(channel, member, topic, req.UserID, 0) && !canDeleteAnyChannelMessage(member) { return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired } rows, err := tx.Query(ctx, ` @@ -1023,7 +1048,10 @@ func scanChannelForumTopic(row rowScanner) (domain.ChannelForumTopic, error) { return topic, nil } -func canManageForumTopic(channel domain.Channel, member domain.ChannelMember, topic domain.ChannelForumTopic, userID int64) bool { +func canManageForumTopic(channel domain.Channel, member domain.ChannelMember, topic domain.ChannelForumTopic, userID int64, selfBoostsApplied int) bool { + if domain.ChannelBannedRightsBlockManageTopics(channel, member, selfBoostsApplied) { + return false + } if topic.CreatorUserID == userID { return true }