Merge pull request #3 from iamxvbaba/codex/channel-rights-hardening
[codex] Harden channel rights and sticker compatibility
This commit is contained in:
commit
49e2cb2c95
33 changed files with 1520 additions and 130 deletions
378
cmd/stickerseeddeploy/main.go
Normal file
378
cmd/stickerseeddeploy/main.go
Normal 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
|
||||
}
|
||||
45
cmd/stickerseeddeploy/main_test.go
Normal file
45
cmd/stickerseeddeploy/main_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
109
internal/domain/channel_banned_rights.go
Normal file
109
internal/domain/channel_banned_rights.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 同步验证)。
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue