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

@ -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
}

View file

@ -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"])
}
}

View file

@ -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())

View file

@ -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 {

View file

@ -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) {

View file

@ -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)
}
}
}

View file

@ -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)

View file

@ -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<int> = messages.Messages;
channels.getMessages#93d7b347 channel:InputChannel id:Vector<int> = messages.Messages;
bots.exportBotToken#0063b089 bot_id:long revoke:Bool = bots.ExportedBotToken;
account.registerDevice#637ea878 token_type:int token:string = Bool;

View file

@ -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),

View file

@ -252,7 +252,16 @@ type ChannelBannedRights struct {
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
}

View file

@ -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
}

View file

@ -653,10 +653,16 @@ type StickerKeyword struct {
Keywords []string `json:"keywords"`
}
const (
// StickerSetSystemKeyEmojiDefaultStatuses 是 inputStickerSetEmojiDefaultStatuses
// 对应的系统集标识premium 用户 emoji status 选择器的"默认状态"主体集
// messages.getStickerSet 与 account.getDefaultEmojiStatuses 共用)。
const StickerSetSystemKeyEmojiDefaultStatuses = "emoji_default_statuses"
StickerSetSystemKeyEmojiDefaultStatuses = "emoji_default_statuses"
// StickerSetSystemKeyEmojiDefaultTopicIcons 对应论坛 topic 默认图标系统集。
StickerSetSystemKeyEmojiDefaultTopicIcons = "emoji_default_topic_icons"
// StickerSetSystemKeyPremiumGifts 对应 premium gifts 系统集。
StickerSetSystemKeyPremiumGifts = "premium_gifts"
)
// StickerSetKind 区分贴纸集用途(影响 getAllStickers / getEmojiStickers 归类)。
type StickerSetKind string

View file

@ -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

@ -820,7 +820,16 @@ func tgChatBannedRights(rights domain.ChannelBannedRights) tg.ChatBannedRights {
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,
}
}
@ -847,7 +856,16 @@ func domainChannelBannedRights(rights tg.ChatBannedRights) domain.ChannelBannedR
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

@ -128,6 +128,7 @@ type Router struct {
emojiStickers *emojiStickerIndex
notifySettings *notifySettingsCache
stickerCatalog *stickerCatalogCache
transientPrivateBigReactions transientPrivateBigReactionCache
accountSettings *accountSettingsCache
// webPageResolveSem 是链接预览异步解析的并发信号量(有界):发送后把 pending 占位
// 解析为卡片并就地替换。满则丢弃任务(消息留 pending。nil=未启用(测试可直接调

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

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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'")

View file

@ -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
}

View file

@ -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

View file

@ -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
}