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

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

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"`
}
// 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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
}