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) {
|
func TestSendMessageResolvesChannelReplyTopID(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
service := NewService(memory.NewChannelStore())
|
service := NewService(memory.NewChannelStore())
|
||||||
|
|
|
||||||
|
|
@ -571,6 +571,12 @@ func systemKeyForDefaultSet(dirName string) string {
|
||||||
return "animated_emoji_animations"
|
return "animated_emoji_animations"
|
||||||
case "DefaultSet_EmojiGenericAnimations":
|
case "DefaultSet_EmojiGenericAnimations":
|
||||||
return "emoji_generic_animations"
|
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":
|
case "DefaultSet_Dice_Normal":
|
||||||
return "dice:\U0001f3b2"
|
return "dice:\U0001f3b2"
|
||||||
case "DefaultSet_Dice_Dart":
|
case "DefaultSet_Dice_Dart":
|
||||||
|
|
@ -716,10 +722,7 @@ func (s *Service) ensureTGStickerPreviewThumb(ctx context.Context, doc *domain.D
|
||||||
}
|
}
|
||||||
|
|
||||||
func seedDocumentNeedsSyntheticTGStickerPreviewThumb(doc domain.Document) bool {
|
func seedDocumentNeedsSyntheticTGStickerPreviewThumb(doc domain.Document) bool {
|
||||||
if doc.MimeType != "application/x-tgsticker" || len(doc.Thumbs) > 0 {
|
return doc.MimeType == "application/x-tgsticker" && len(doc.Thumbs) == 0
|
||||||
return false
|
|
||||||
}
|
|
||||||
return seedDocumentHasAttribute(doc.Attributes, domain.DocAttrCustomEmoji)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func seedDocumentHasAttribute(attrs []domain.DocumentAttribute, kind domain.DocumentAttributeKind) bool {
|
func seedDocumentHasAttribute(attrs []domain.DocumentAttribute, kind domain.DocumentAttributeKind) bool {
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"telesrv/internal/domain"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -105,10 +103,7 @@ func seedDocumentJSONLocationKeys(dj seedDocumentJSON, index seedDirIndex) []str
|
||||||
}
|
}
|
||||||
|
|
||||||
func seedDocumentJSONNeedsSyntheticTGStickerPreviewThumb(dj seedDocumentJSON) bool {
|
func seedDocumentJSONNeedsSyntheticTGStickerPreviewThumb(dj seedDocumentJSON) bool {
|
||||||
if dj.MimeType != "application/x-tgsticker" || len(dj.Thumbs) > 0 {
|
return dj.MimeType == "application/x-tgsticker" && len(dj.Thumbs) == 0
|
||||||
return false
|
|
||||||
}
|
|
||||||
return seedDocumentHasAttribute(seedDocumentAttributes(dj.Attributes), domain.DocAttrCustomEmoji)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) seedDocumentJSONsReady(ctx context.Context, docs []seedDocumentJSON, index seedDirIndex) (bool, error) {
|
func (s *Service) seedDocumentJSONsReady(ctx context.Context, docs []seedDocumentJSON, index seedDirIndex) (bool, error) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -413,8 +414,8 @@ func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) {
|
||||||
svc := NewService(media, blobs, 2)
|
svc := NewService(media, blobs, 2)
|
||||||
if stats, err := svc.SeedMedia(context.Background(), seedDir, 0); err != nil {
|
if stats, err := svc.SeedMedia(context.Background(), seedDir, 0); err != nil {
|
||||||
t.Fatalf("initial seed: %v", err)
|
t.Fatalf("initial seed: %v", err)
|
||||||
} else if stats.Reactions != 1 || stats.Blobs != 2 {
|
} else if stats.Reactions != 1 || stats.Blobs != 3 {
|
||||||
t.Fatalf("initial stats = %+v, want one reaction and two blobs", stats)
|
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})
|
chunk, ok, err := svc.GetFile(context.Background(), domain.FileDownloadRequest{LocationKey: "doc:2222222", Offset: 0, Limit: 4})
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
|
|
@ -435,7 +436,7 @@ func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("repair seed: %v", err)
|
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)
|
t.Fatalf("repair stats = %+v, want repair import", stats)
|
||||||
}
|
}
|
||||||
if _, ok, _ := media.GetFileBlob(context.Background(), "doc:2222222"); !ok {
|
if _, ok, _ := media.GetFileBlob(context.Background(), "doc:2222222"); !ok {
|
||||||
|
|
@ -564,8 +565,8 @@ func TestSeedMediaSkipsUnchangedEffectsDocuments(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first seed: %v", err)
|
t.Fatalf("first seed: %v", err)
|
||||||
}
|
}
|
||||||
if first.Effects != 1 || first.Documents != 1 || first.Blobs != 1 {
|
if first.Effects != 1 || first.Documents != 1 || first.Blobs != 2 {
|
||||||
t.Fatalf("first stats = %+v, want one imported effect document/blob", first)
|
t.Fatalf("first stats = %+v, want one imported effect document with main plus synthetic preview blobs", first)
|
||||||
}
|
}
|
||||||
|
|
||||||
second, err := svc.SeedMedia(ctx, seedDir, 0)
|
second, err := svc.SeedMedia(ctx, seedDir, 0)
|
||||||
|
|
@ -581,7 +582,7 @@ func TestSeedMediaSkipsUnchangedEffectsDocuments(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("repair seed: %v", err)
|
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)
|
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)
|
t.Fatalf("local fs: %v", err)
|
||||||
}
|
}
|
||||||
svc := NewService(media, blobs, 2)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("seed media: %v", err)
|
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)
|
t.Fatalf("sample sticker thumb mime = %q, want %q", blob.MimeType, want)
|
||||||
}
|
}
|
||||||
if !hasPathThumb(doc.Thumbs) {
|
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")
|
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) {
|
t.Run("botsExportBotToken", func(t *testing.T) {
|
||||||
var in bin.Buffer
|
var in bin.Buffer
|
||||||
in.PutID(0x0063b089)
|
in.PutID(0x0063b089)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
|
|
||||||
messages.uploadMedia#519bc2b1 peer:InputPeer media:InputMedia = MessageMedia;
|
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;
|
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;
|
channels.getMessages#93d7b347 channel:InputChannel id:Vector<int> = messages.Messages;
|
||||||
bots.exportBotToken#0063b089 bot_id:long revoke:Bool = bots.ExportedBotToken;
|
bots.exportBotToken#0063b089 bot_id:long revoke:Bool = bots.ExportedBotToken;
|
||||||
account.registerDevice#637ea878 token_type:int token:string = Bool;
|
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"),
|
LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"),
|
||||||
BlobDir: envOr("TELESRV_BLOB_DIR", "data/blobs"),
|
BlobDir: envOr("TELESRV_BLOB_DIR", "data/blobs"),
|
||||||
StickerSeedDir: envOr("TELESRV_STICKER_SEED_DIR", "data/sticker-seed"),
|
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", ""),
|
MapboxToken: envOr("TELESRV_MAPBOX_TOKEN", ""),
|
||||||
MapTileCacheDir: envOr("TELESRV_MAPTILE_CACHE_DIR", "data/maptiles"),
|
MapTileCacheDir: envOr("TELESRV_MAPTILE_CACHE_DIR", "data/maptiles"),
|
||||||
ExternalMediaEnable: envBoolOr("TELESRV_EXTERNAL_MEDIA_ENABLE", true),
|
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.
|
// ChannelBannedRights is a domain-only representation of Telegram banned rights.
|
||||||
type ChannelBannedRights struct {
|
type ChannelBannedRights struct {
|
||||||
ViewMessages bool
|
ViewMessages bool
|
||||||
SendMessages bool
|
SendMessages bool
|
||||||
SendMedia bool
|
SendMedia bool
|
||||||
SendStickers bool
|
SendStickers bool
|
||||||
SendGifs bool
|
SendGifs bool
|
||||||
SendGames bool
|
SendGames bool
|
||||||
SendInline bool
|
SendInline bool
|
||||||
EmbedLinks bool
|
EmbedLinks bool
|
||||||
SendPolls bool
|
SendPolls bool
|
||||||
ChangeInfo bool
|
ChangeInfo bool
|
||||||
InviteUsers bool
|
InviteUsers bool
|
||||||
PinMessages bool
|
PinMessages bool
|
||||||
EditRank bool
|
ManageTopics bool
|
||||||
UntilDate int
|
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.
|
// 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"`
|
Keywords []string `json:"keywords"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StickerSetSystemKeyEmojiDefaultStatuses 是 inputStickerSetEmojiDefaultStatuses
|
const (
|
||||||
// 对应的系统集标识:premium 用户 emoji status 选择器的"默认状态"主体集
|
// StickerSetSystemKeyEmojiDefaultStatuses 是 inputStickerSetEmojiDefaultStatuses
|
||||||
// (messages.getStickerSet 与 account.getDefaultEmojiStatuses 共用)。
|
// 对应的系统集标识:premium 用户 emoji status 选择器的"默认状态"主体集
|
||||||
const StickerSetSystemKeyEmojiDefaultStatuses = "emoji_default_statuses"
|
// (messages.getStickerSet 与 account.getDefaultEmojiStatuses 共用)。
|
||||||
|
StickerSetSystemKeyEmojiDefaultStatuses = "emoji_default_statuses"
|
||||||
|
// StickerSetSystemKeyEmojiDefaultTopicIcons 对应论坛 topic 默认图标系统集。
|
||||||
|
StickerSetSystemKeyEmojiDefaultTopicIcons = "emoji_default_topic_icons"
|
||||||
|
// StickerSetSystemKeyPremiumGifts 对应 premium gifts 系统集。
|
||||||
|
StickerSetSystemKeyPremiumGifts = "premium_gifts"
|
||||||
|
)
|
||||||
|
|
||||||
// StickerSetKind 区分贴纸集用途(影响 getAllStickers / getEmojiStickers 归类)。
|
// StickerSetKind 区分贴纸集用途(影响 getAllStickers / getEmojiStickers 归类)。
|
||||||
type StickerSetKind string
|
type StickerSetKind string
|
||||||
|
|
|
||||||
|
|
@ -20,27 +20,27 @@ const (
|
||||||
botInlineCacheMaxEntries = 256
|
botInlineCacheMaxEntries = 256
|
||||||
)
|
)
|
||||||
|
|
||||||
func botInlineDisabledErr() error { return tgerr.New(400, "BOT_INLINE_DISABLED") }
|
func botInlineDisabledErr() error { return tgerr.New(400, "BOT_INLINE_DISABLED") }
|
||||||
func botInlineGeoNotAllowedErr() error { return tgerr.New(400, "BOT_INLINE_GEO_NOT_ALLOWED") }
|
func botInlineGeoNotAllowedErr() error { return tgerr.New(400, "BOT_INLINE_GEO_NOT_ALLOWED") }
|
||||||
func botWebviewDisabledErr() error { return tgerr.New(400, "BOT_WEBVIEW_DISABLED") }
|
func botWebviewDisabledErr() error { return tgerr.New(400, "BOT_WEBVIEW_DISABLED") }
|
||||||
func queryIDInvalidErr() error { return tgerr.New(400, "QUERY_ID_INVALID") }
|
func queryIDInvalidErr() error { return tgerr.New(400, "QUERY_ID_INVALID") }
|
||||||
func queryIDEmptyErr() error { return tgerr.New(400, "QUERY_ID_EMPTY") }
|
func queryIDEmptyErr() error { return tgerr.New(400, "QUERY_ID_EMPTY") }
|
||||||
func resultIDEmptyErr() error { return tgerr.New(400, "RESULT_ID_EMPTY") }
|
func resultIDEmptyErr() error { return tgerr.New(400, "RESULT_ID_EMPTY") }
|
||||||
func resultIDInvalidErr() error { return tgerr.New(400, "RESULT_ID_INVALID") }
|
func resultIDInvalidErr() error { return tgerr.New(400, "RESULT_ID_INVALID") }
|
||||||
func resultIDDuplicateErr() error { return tgerr.New(400, "RESULT_ID_DUPLICATE") }
|
func resultIDDuplicateErr() error { return tgerr.New(400, "RESULT_ID_DUPLICATE") }
|
||||||
func resultTypeInvalidErr() error { return tgerr.New(400, "RESULT_TYPE_INVALID") }
|
func resultTypeInvalidErr() error { return tgerr.New(400, "RESULT_TYPE_INVALID") }
|
||||||
func resultsTooMuchErr() error { return tgerr.New(400, "RESULTS_TOO_MUCH") }
|
func resultsTooMuchErr() error { return tgerr.New(400, "RESULTS_TOO_MUCH") }
|
||||||
func sendMessageTypeInvalidErr() error { return tgerr.New(400, "SEND_MESSAGE_TYPE_INVALID") }
|
func sendMessageTypeInvalidErr() error { return tgerr.New(400, "SEND_MESSAGE_TYPE_INVALID") }
|
||||||
func nextOffsetInvalidErr() error { return tgerr.New(400, "NEXT_OFFSET_INVALID") }
|
func nextOffsetInvalidErr() error { return tgerr.New(400, "NEXT_OFFSET_INVALID") }
|
||||||
func startParamEmptyErr() error { return tgerr.New(400, "START_PARAM_EMPTY") }
|
func startParamEmptyErr() error { return tgerr.New(400, "START_PARAM_EMPTY") }
|
||||||
func switchPmTextEmptyErr() error { return tgerr.New(400, "SWITCH_PM_TEXT_EMPTY") }
|
func switchPmTextEmptyErr() error { return tgerr.New(400, "SWITCH_PM_TEXT_EMPTY") }
|
||||||
func switchWebviewInvalidErr() error { return tgerr.New(400, "SWITCH_WEBVIEW_URL_INVALID") }
|
func switchWebviewInvalidErr() error { return tgerr.New(400, "SWITCH_WEBVIEW_URL_INVALID") }
|
||||||
func inlineResultExpiredErr() error { return tgerr.New(400, "INLINE_RESULT_EXPIRED") }
|
func inlineResultExpiredErr() error { return tgerr.New(400, "INLINE_RESULT_EXPIRED") }
|
||||||
func webDocumentInvalidErr() error { return tgerr.New(400, "WEBDOCUMENT_INVALID") }
|
func webDocumentInvalidErr() error { return tgerr.New(400, "WEBDOCUMENT_INVALID") }
|
||||||
func webDocumentMimeInvalidErr() error { return tgerr.New(400, "WEBDOCUMENT_MIME_INVALID") }
|
func webDocumentMimeInvalidErr() error { return tgerr.New(400, "WEBDOCUMENT_MIME_INVALID") }
|
||||||
func webDocumentSizeTooBigErr() error { return tgerr.New(400, "WEBDOCUMENT_SIZE_TOO_BIG") }
|
func webDocumentSizeTooBigErr() error { return tgerr.New(400, "WEBDOCUMENT_SIZE_TOO_BIG") }
|
||||||
func webDocumentURLEmptyErr() error { return tgerr.New(400, "WEBDOCUMENT_URL_EMPTY") }
|
func webDocumentURLEmptyErr() error { return tgerr.New(400, "WEBDOCUMENT_URL_EMPTY") }
|
||||||
func webDocumentURLInvalidErr() error { return tgerr.New(400, "WEBDOCUMENT_URL_INVALID") }
|
func webDocumentURLInvalidErr() error { return tgerr.New(400, "WEBDOCUMENT_URL_INVALID") }
|
||||||
|
|
||||||
func (r *Router) onMessagesGetInlineBotResults(ctx context.Context, req *tg.MessagesGetInlineBotResultsRequest) (*tg.MessagesBotResults, error) {
|
func (r *Router) onMessagesGetInlineBotResults(ctx context.Context, req *tg.MessagesGetInlineBotResultsRequest) (*tg.MessagesBotResults, error) {
|
||||||
userID, _, err := r.currentUserID(ctx)
|
userID, _, err := r.currentUserID(ctx)
|
||||||
|
|
@ -1132,7 +1132,7 @@ func tgInlineWebDocument(in domain.BotInlineWebDocument) tg.WebDocumentClass {
|
||||||
AccessHash: in.AccessHash,
|
AccessHash: in.AccessHash,
|
||||||
Size: in.Size,
|
Size: in.Size,
|
||||||
MimeType: in.MimeType,
|
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 {
|
func tgChatBannedRights(rights domain.ChannelBannedRights) tg.ChatBannedRights {
|
||||||
return tg.ChatBannedRights{
|
return tg.ChatBannedRights{
|
||||||
ViewMessages: rights.ViewMessages,
|
ViewMessages: rights.ViewMessages,
|
||||||
SendMessages: rights.SendMessages,
|
SendMessages: rights.SendMessages,
|
||||||
SendMedia: rights.SendMedia,
|
SendMedia: rights.SendMedia,
|
||||||
SendStickers: rights.SendStickers,
|
SendStickers: rights.SendStickers,
|
||||||
SendGifs: rights.SendGifs,
|
SendGifs: rights.SendGifs,
|
||||||
SendGames: rights.SendGames,
|
SendGames: rights.SendGames,
|
||||||
SendInline: rights.SendInline,
|
SendInline: rights.SendInline,
|
||||||
EmbedLinks: rights.EmbedLinks,
|
EmbedLinks: rights.EmbedLinks,
|
||||||
SendPolls: rights.SendPolls,
|
SendPolls: rights.SendPolls,
|
||||||
ChangeInfo: rights.ChangeInfo,
|
ChangeInfo: rights.ChangeInfo,
|
||||||
InviteUsers: rights.InviteUsers,
|
InviteUsers: rights.InviteUsers,
|
||||||
PinMessages: rights.PinMessages,
|
PinMessages: rights.PinMessages,
|
||||||
EditRank: rights.EditRank,
|
ManageTopics: rights.ManageTopics,
|
||||||
UntilDate: rights.UntilDate,
|
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 {
|
func domainChannelBannedRights(rights tg.ChatBannedRights) domain.ChannelBannedRights {
|
||||||
return domain.ChannelBannedRights{
|
return domain.ChannelBannedRights{
|
||||||
ViewMessages: rights.ViewMessages,
|
ViewMessages: rights.ViewMessages,
|
||||||
SendMessages: rights.SendMessages,
|
SendMessages: rights.SendMessages,
|
||||||
SendMedia: rights.SendMedia,
|
SendMedia: rights.SendMedia,
|
||||||
SendStickers: rights.SendStickers,
|
SendStickers: rights.SendStickers,
|
||||||
SendGifs: rights.SendGifs,
|
SendGifs: rights.SendGifs,
|
||||||
SendGames: rights.SendGames,
|
SendGames: rights.SendGames,
|
||||||
SendInline: rights.SendInline,
|
SendInline: rights.SendInline,
|
||||||
EmbedLinks: rights.EmbedLinks,
|
EmbedLinks: rights.EmbedLinks,
|
||||||
SendPolls: rights.SendPolls,
|
SendPolls: rights.SendPolls,
|
||||||
ChangeInfo: rights.ChangeInfo,
|
ChangeInfo: rights.ChangeInfo,
|
||||||
InviteUsers: rights.InviteUsers,
|
InviteUsers: rights.InviteUsers,
|
||||||
PinMessages: rights.PinMessages,
|
PinMessages: rights.PinMessages,
|
||||||
EditRank: rights.EditRank,
|
ManageTopics: rights.ManageTopics,
|
||||||
UntilDate: rights.UntilDate,
|
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)
|
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"
|
"telesrv/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const mimeApplicationXTGSticker = "application/x-tgsticker"
|
||||||
|
|
||||||
// 本文件集中 domain media 值对象 → tg.* 的转换;tg.* 只在 rpc 层出现。
|
// 本文件集中 domain media 值对象 → tg.* 的转换;tg.* 只在 rpc 层出现。
|
||||||
// 供 reaction / sticker 资源 RPC 与消息 media 共用。
|
// 供 reaction / sticker 资源 RPC 与消息 media 共用。
|
||||||
|
|
||||||
|
|
@ -27,9 +29,13 @@ func tgMessageMedia(m *domain.MessageMedia) tg.MessageMediaClass {
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
case domain.MessageMediaKindDocument:
|
case domain.MessageMediaKindDocument:
|
||||||
|
nopremium := m.Nopremium
|
||||||
|
if m.Document != nil && m.Document.IsSticker() {
|
||||||
|
nopremium = true
|
||||||
|
}
|
||||||
out := &tg.MessageMediaDocument{
|
out := &tg.MessageMediaDocument{
|
||||||
Spoiler: m.Spoiler,
|
Spoiler: m.Spoiler,
|
||||||
Nopremium: m.Nopremium,
|
Nopremium: nopremium,
|
||||||
Voice: m.Voice,
|
Voice: m.Voice,
|
||||||
Round: m.Round,
|
Round: m.Round,
|
||||||
Video: m.Video,
|
Video: m.Video,
|
||||||
|
|
@ -267,9 +273,9 @@ func tgDocument(d domain.Document) tg.DocumentClass {
|
||||||
Date: d.Date,
|
Date: d.Date,
|
||||||
MimeType: d.MimeType,
|
MimeType: d.MimeType,
|
||||||
Size: d.Size,
|
Size: d.Size,
|
||||||
Thumbs: tgDocumentThumbs(d.Thumbs),
|
Thumbs: tgDocumentThumbs(d.MimeType, d.Thumbs),
|
||||||
DCID: d.DCID,
|
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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func tgDocumentThumbs(sizes []domain.PhotoSize) []tg.PhotoSizeClass {
|
func tgDocumentThumbs(mimeType string, sizes []domain.PhotoSize) []tg.PhotoSizeClass {
|
||||||
if len(sizes) == 0 {
|
if len(sizes) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := make([]tg.PhotoSizeClass, 0, len(sizes))
|
out := make([]tg.PhotoSizeClass, 0, len(sizes))
|
||||||
for _, s := range sizes {
|
for _, s := range sizes {
|
||||||
|
if isSeedSyntheticTGStickerPreviewThumb(mimeType, s) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if s.Kind == domain.PhotoSizeKindCached && len(s.Bytes) > 0 {
|
if s.Kind == domain.PhotoSizeKindCached && len(s.Bytes) > 0 {
|
||||||
size := s.Size
|
size := s.Size
|
||||||
if size == 0 {
|
if size == 0 {
|
||||||
|
|
@ -302,6 +311,17 @@ func tgDocumentThumbs(sizes []domain.PhotoSize) []tg.PhotoSizeClass {
|
||||||
return compactPhotoSizeClasses(out)
|
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 {
|
func tgPhotoSizes(sizes []domain.PhotoSize) []tg.PhotoSizeClass {
|
||||||
if len(sizes) == 0 {
|
if len(sizes) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -408,15 +428,19 @@ func compactPhotoSizeClasses(in []tg.PhotoSizeClass) []tg.PhotoSizeClass {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func tgDocumentAttributes(attrs []domain.DocumentAttribute) []tg.DocumentAttributeClass {
|
func tgDocumentAttributes(mimeType string, attrs []domain.DocumentAttribute) []tg.DocumentAttributeClass {
|
||||||
out := make([]tg.DocumentAttributeClass, 0, len(attrs))
|
out := make([]tg.DocumentAttributeClass, 0, len(attrs)+1)
|
||||||
|
hasAnimated := false
|
||||||
|
hasStickerLike := false
|
||||||
for _, a := range attrs {
|
for _, a := range attrs {
|
||||||
switch a.Kind {
|
switch a.Kind {
|
||||||
case domain.DocAttrImageSize:
|
case domain.DocAttrImageSize:
|
||||||
out = append(out, &tg.DocumentAttributeImageSize{W: a.W, H: a.H})
|
out = append(out, &tg.DocumentAttributeImageSize{W: a.W, H: a.H})
|
||||||
case domain.DocAttrAnimated:
|
case domain.DocAttrAnimated:
|
||||||
|
hasAnimated = true
|
||||||
out = append(out, &tg.DocumentAttributeAnimated{})
|
out = append(out, &tg.DocumentAttributeAnimated{})
|
||||||
case domain.DocAttrSticker:
|
case domain.DocAttrSticker:
|
||||||
|
hasStickerLike = true
|
||||||
out = append(out, &tg.DocumentAttributeSticker{
|
out = append(out, &tg.DocumentAttributeSticker{
|
||||||
Mask: a.Mask,
|
Mask: a.Mask,
|
||||||
Alt: a.Alt,
|
Alt: a.Alt,
|
||||||
|
|
@ -444,6 +468,7 @@ func tgDocumentAttributes(attrs []domain.DocumentAttribute) []tg.DocumentAttribu
|
||||||
case domain.DocAttrFilename:
|
case domain.DocAttrFilename:
|
||||||
out = append(out, &tg.DocumentAttributeFilename{FileName: a.FileName})
|
out = append(out, &tg.DocumentAttributeFilename{FileName: a.FileName})
|
||||||
case domain.DocAttrCustomEmoji:
|
case domain.DocAttrCustomEmoji:
|
||||||
|
hasStickerLike = true
|
||||||
out = append(out, &tg.DocumentAttributeCustomEmoji{
|
out = append(out, &tg.DocumentAttributeCustomEmoji{
|
||||||
Free: a.Free,
|
Free: a.Free,
|
||||||
TextColor: a.TextColor,
|
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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -671,6 +699,12 @@ func stickerSetRefFromInput(input tg.InputStickerSetClass) (domain.StickerSetRef
|
||||||
return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "emoji_generic_animations"}, true
|
return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "emoji_generic_animations"}, true
|
||||||
case *tg.InputStickerSetEmojiDefaultStatuses:
|
case *tg.InputStickerSetEmojiDefaultStatuses:
|
||||||
return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: domain.StickerSetSystemKeyEmojiDefaultStatuses}, true
|
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:
|
case *tg.InputStickerSetDice:
|
||||||
return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "dice:" + in.Emoticon}, true
|
return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "dice:" + in.Emoticon}, true
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package rpc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/gotd/td/tg"
|
"github.com/gotd/td/tg"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
@ -51,6 +53,20 @@ func (r *Router) onMessagesSendReaction(ctx context.Context, req *tg.MessagesSen
|
||||||
return updates, nil
|
return updates, nil
|
||||||
}
|
}
|
||||||
if peer.Type == domain.PeerTypeUser && r.deps.Messages != 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{
|
res, err := r.deps.Messages.SetMessageReactions(ctx, userID, domain.SetPrivateMessageReactionsRequest{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Peer: peer,
|
Peer: peer,
|
||||||
|
|
@ -64,6 +80,9 @@ func (r *Router) onMessagesSendReaction(ctx context.Context, req *tg.MessagesSen
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, messageReactionErr(err)
|
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 {
|
if err := r.recordMessageReactionUse(ctx, userID, reactions, req.GetAddToRecent(), date); err != nil {
|
||||||
return nil, internalErr()
|
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
|
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 {
|
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 {
|
if len(reactions) == 0 || r.deps.Channels == nil {
|
||||||
return 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)
|
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.OnMessagesGetMaskStickers(r.onMessagesGetMaskStickers)
|
||||||
d.OnMessagesGetFeaturedStickers(r.onMessagesGetFeaturedStickers)
|
d.OnMessagesGetFeaturedStickers(r.onMessagesGetFeaturedStickers)
|
||||||
d.OnMessagesGetFeaturedEmojiStickers(r.onMessagesGetFeaturedEmojiStickers)
|
d.OnMessagesGetFeaturedEmojiStickers(r.onMessagesGetFeaturedEmojiStickers)
|
||||||
|
d.OnMessagesGetOldFeaturedStickers(r.onMessagesGetOldFeaturedStickers)
|
||||||
d.OnMessagesGetRecentStickers(r.onMessagesGetRecentStickers)
|
d.OnMessagesGetRecentStickers(r.onMessagesGetRecentStickers)
|
||||||
d.OnMessagesGetFavedStickers(r.onMessagesGetFavedStickers)
|
d.OnMessagesGetFavedStickers(r.onMessagesGetFavedStickers)
|
||||||
d.OnMessagesGetSavedGifs(r.onMessagesGetSavedGifs)
|
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) {
|
d.OnMessagesGetEmojiStatusGroups(func(ctx context.Context, hash int) (tg.MessagesEmojiGroupsClass, error) {
|
||||||
return tdesktop.EmojiStatusGroups(), nil
|
return tdesktop.EmojiStatusGroups(), nil
|
||||||
})
|
})
|
||||||
d.OnMessagesGetEmojiStickerGroups(func(ctx context.Context, hash int) (tg.MessagesEmojiGroupsClass, error) {
|
d.OnMessagesGetEmojiStickerGroups(r.onMessagesGetEmojiStickerGroups)
|
||||||
// 自定义 emoji 贴纸的分类(Premium);telesrv 未 seed custom-emoji 集,保持空。
|
|
||||||
return &tg.MessagesEmojiGroupsNotModified{}, nil
|
|
||||||
})
|
|
||||||
d.OnMessagesGetEmojiProfilePhotoGroups(func(ctx context.Context, hash int) (tg.MessagesEmojiGroupsClass, error) {
|
d.OnMessagesGetEmojiProfilePhotoGroups(func(ctx context.Context, hash int) (tg.MessagesEmojiGroupsClass, error) {
|
||||||
return tdesktop.EmojiProfilePhotoGroups(), nil
|
return tdesktop.EmojiProfilePhotoGroups(), nil
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -117,18 +117,19 @@ type Router struct {
|
||||||
// updateStatus 高频续期时数秒内只落一次 DB。
|
// updateStatus 高频续期时数秒内只落一次 DB。
|
||||||
lastSeenPersist sync.Map // userID(int64) -> int64(unix)
|
lastSeenPersist sync.Map // userID(int64) -> int64(unix)
|
||||||
// tempKeyResolveCache 缓存 rawTempKeyID -> resolved perm(带过期),容量有界。
|
// tempKeyResolveCache 缓存 rawTempKeyID -> resolved perm(带过期),容量有界。
|
||||||
tempKeyResolveCache *tempKeyResolveCache
|
tempKeyResolveCache *tempKeyResolveCache
|
||||||
storyProjectionCache *storyProjectionCache
|
storyProjectionCache *storyProjectionCache
|
||||||
storyPinnedCache *storyPinnedAvailableCache
|
storyPinnedCache *storyPinnedAvailableCache
|
||||||
storyPinnedListCache *storyPinnedStoriesCache
|
storyPinnedListCache *storyPinnedStoriesCache
|
||||||
channelFullBotCache *channelFullBotInfoCache
|
channelFullBotCache *channelFullBotInfoCache
|
||||||
userFullProjectionCache *userFullProjectionCache
|
userFullProjectionCache *userFullProjectionCache
|
||||||
peerSettingsProjectionCache *peerSettingsProjectionCache
|
peerSettingsProjectionCache *peerSettingsProjectionCache
|
||||||
channelFullProjectionCache *channelFullProjectionCache
|
channelFullProjectionCache *channelFullProjectionCache
|
||||||
emojiStickers *emojiStickerIndex
|
emojiStickers *emojiStickerIndex
|
||||||
notifySettings *notifySettingsCache
|
notifySettings *notifySettingsCache
|
||||||
stickerCatalog *stickerCatalogCache
|
stickerCatalog *stickerCatalogCache
|
||||||
accountSettings *accountSettingsCache
|
transientPrivateBigReactions transientPrivateBigReactionCache
|
||||||
|
accountSettings *accountSettingsCache
|
||||||
// webPageResolveSem 是链接预览异步解析的并发信号量(有界):发送后把 pending 占位
|
// webPageResolveSem 是链接预览异步解析的并发信号量(有界):发送后把 pending 占位
|
||||||
// 解析为卡片并就地替换。满则丢弃任务(消息留 pending)。nil=未启用(测试可直接调
|
// 解析为卡片并就地替换。满则丢弃任务(消息留 pending)。nil=未启用(测试可直接调
|
||||||
// resolvePendingWebPage 同步验证)。
|
// resolvePendingWebPage 同步验证)。
|
||||||
|
|
|
||||||
|
|
@ -819,6 +819,9 @@ func (r *Router) messageContactUserID(ctx context.Context, userID int64, phone s
|
||||||
// messageMediaFromDocument 由 Document 构造 MessageMedia,并从属性推导 Video/Round/Voice 标志。
|
// messageMediaFromDocument 由 Document 构造 MessageMedia,并从属性推导 Video/Round/Voice 标志。
|
||||||
func messageMediaFromDocument(doc domain.Document, spoiler bool, ttl int) *domain.MessageMedia {
|
func messageMediaFromDocument(doc domain.Document, spoiler bool, ttl int) *domain.MessageMedia {
|
||||||
media := &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Document: &doc, Spoiler: spoiler, TTLSeconds: ttl}
|
media := &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Document: &doc, Spoiler: spoiler, TTLSeconds: ttl}
|
||||||
|
if doc.IsSticker() {
|
||||||
|
media.Nopremium = true
|
||||||
|
}
|
||||||
for _, attr := range doc.Attributes {
|
for _, attr := range doc.Attributes {
|
||||||
switch attr.Kind {
|
switch attr.Kind {
|
||||||
case domain.DocAttrVideo:
|
case domain.DocAttrVideo:
|
||||||
|
|
|
||||||
|
|
@ -801,6 +801,9 @@ func TestSendMediaPrivateSticker(t *testing.T) {
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected MessageMediaDocument, got %T", msg.Media)
|
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)
|
doc, ok := media.Document.(*tg.Document)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected tg.Document, got %T", media.Document)
|
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) {
|
func TestSendMediaPrivateUploadedPhoto(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
r, owner, friend := newMediaTestRouter(t)
|
r, owner, friend := newMediaTestRouter(t)
|
||||||
|
|
|
||||||
|
|
@ -102,17 +102,11 @@ func (r *Router) onMessagesGetStickerSet(ctx context.Context, req *tg.MessagesGe
|
||||||
zap.Int("documents", len(fallbackDocs)),
|
zap.Int("documents", len(fallbackDocs)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if req.Hash != 0 && req.Hash == fallbackSet.Hash {
|
|
||||||
return &tg.MessagesStickerSetNotModified{}, nil
|
|
||||||
}
|
|
||||||
return tgMessagesStickerSet(fallbackSet, fallbackDocs), nil
|
return tgMessagesStickerSet(fallbackSet, fallbackDocs), nil
|
||||||
}
|
}
|
||||||
// 未 seed 的系统集 / 未知短名:回退兼容 stub,避免破坏客户端。
|
// 未 seed 的系统集 / 未知短名:回退兼容 stub,避免破坏客户端。
|
||||||
return tdesktop.StickerSet(req), nil
|
return tdesktop.StickerSet(req), nil
|
||||||
}
|
}
|
||||||
if req.Hash != 0 && req.Hash == set.Hash {
|
|
||||||
return &tg.MessagesStickerSetNotModified{}, nil
|
|
||||||
}
|
|
||||||
set, err = r.stickerSetWithViewerInstallState(ctx, set)
|
set, err = r.stickerSetWithViewerInstallState(ctx, set)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -191,6 +185,43 @@ func (r *Router) onMessagesGetEmojiStickers(ctx context.Context, hash int64) (tg
|
||||||
return r.allStickersForKind(ctx, hash, domain.StickerSetKindEmoji)
|
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) {
|
func (r *Router) onMessagesGetMaskStickers(ctx context.Context, hash int64) (tg.MessagesAllStickersClass, error) {
|
||||||
return r.allStickersForKind(ctx, hash, domain.StickerSetKindMasks)
|
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)
|
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 呈现。
|
// featuredStickersForKind 把已 seed 的(未归档)贴纸/emoji 集作为 trending 呈现。
|
||||||
// 性能:先用集目录 hash 比对,命中即返回 *NotModified——封面文档解析只在 cache-miss
|
// 性能:先用集目录 hash 比对,命中即返回 *NotModified——封面文档解析只在 cache-miss
|
||||||
// 时发生(一次批量 GetDocuments),避免每次请求都解析封面。
|
// 时发生(一次批量 GetDocuments),避免每次请求都解析封面。
|
||||||
|
|
@ -467,6 +505,31 @@ func featuredStickerSetsHash(sets []domain.StickerSet) int64 {
|
||||||
return int64(tdesktopCountHash(values))
|
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 {
|
func boolHashValue(v bool) int64 {
|
||||||
if v {
|
if v {
|
||||||
return 1
|
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) {
|
func TestMessagesGetStickerSetAndroidPlaceholderFallsBackToEmptyWithoutSeed(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
r := &Router{deps: Deps{Files: &fakeFiles{}}}
|
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) {
|
func TestMessagesGetMaskStickersUsesMaskCatalog(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
files := &fakeFiles{
|
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,验证目录缓存短路。
|
// countingStickerFiles 包 *fakeFiles 计数 ListStickerSets,验证目录缓存短路。
|
||||||
type countingStickerFiles struct {
|
type countingStickerFiles struct {
|
||||||
*fakeFiles
|
*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) {
|
func TestTGDocumentUsesDomainDocumentID(t *testing.T) {
|
||||||
const documentID int64 = 1382305375846410902
|
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)
|
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 {
|
if req.ReplyTo == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
@ -446,7 +446,7 @@ func (s *ChannelStore) resolveChannelReplyLocked(req domain.SendChannelMessageRe
|
||||||
if !ok || topic.Hidden {
|
if !ok || topic.Hidden {
|
||||||
return nil, domain.ErrReplyMessageIDInvalid
|
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
|
return nil, domain.ErrChannelWriteForbidden
|
||||||
}
|
}
|
||||||
reply := cloneMessageReply(req.ReplyTo)
|
reply := cloneMessageReply(req.ReplyTo)
|
||||||
|
|
@ -472,7 +472,7 @@ func (s *ChannelStore) resolveChannelReplyLocked(req domain.SendChannelMessageRe
|
||||||
}
|
}
|
||||||
if channel.Forum && reply.TopMessageID > 0 {
|
if channel.Forum && reply.TopMessageID > 0 {
|
||||||
if topic, ok := s.topics[req.ChannelID][reply.TopMessageID]; ok && !topic.Hidden {
|
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
|
return nil, domain.ErrChannelWriteForbidden
|
||||||
}
|
}
|
||||||
reply.ForumTopic = true
|
reply.ForumTopic = true
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
|
||||||
if channel.Megagroup {
|
if channel.Megagroup {
|
||||||
fromBoostsApplied = s.selfBoostsAppliedLocked(req.UserID, req.ChannelID, req.Date)
|
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) {
|
if !canSendChannelMessageWithBoost(channel, member, fromBoostsApplied) {
|
||||||
return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden
|
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 {
|
if wait := channelSlowModeWait(channel, member, req.Date); wait > 0 {
|
||||||
return domain.SendChannelMessageResult{}, domain.NewSlowModeWaitError(wait)
|
return domain.SendChannelMessageResult{}, domain.NewSlowModeWaitError(wait)
|
||||||
}
|
}
|
||||||
replyTo, err := s.resolveChannelReplyLocked(req, member, channel)
|
replyTo, err := s.resolveChannelReplyLocked(req, member, channel, fromBoostsApplied)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.SendChannelMessageResult{}, err
|
return domain.SendChannelMessageResult{}, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,15 @@ func (s *ChannelStore) SetChannelMessageReactions(_ context.Context, req domain.
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ChannelMessageReactionsResult{}, err
|
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)
|
idx, ok := s.findMessageIndexLocked(req.ChannelID, req.MessageID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid
|
return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,18 @@ func (s *ChannelStore) CreateForumTopic(ctx context.Context, req domain.CreateCh
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return domain.CreateChannelForumTopicResult{}, domain.ErrChannelWriteForbidden
|
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 id, ok := s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}]; ok {
|
||||||
if topic, ok := s.topics[req.ChannelID][id]; ok {
|
if topic, ok := s.topics[req.ChannelID][id]; ok {
|
||||||
msg, _ := s.findMessageLocked(req.ChannelID, id)
|
msg, _ := s.findMessageLocked(req.ChannelID, id)
|
||||||
|
|
@ -184,7 +196,15 @@ func (s *ChannelStore) EditForumTopic(ctx context.Context, req domain.EditChanne
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return domain.EditChannelForumTopicResult{}, domain.ErrMessageIDInvalid
|
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()
|
s.mu.Unlock()
|
||||||
return domain.EditChannelForumTopicResult{}, domain.ErrChannelAdminRequired
|
return domain.EditChannelForumTopicResult{}, domain.ErrChannelAdminRequired
|
||||||
}
|
}
|
||||||
|
|
@ -361,7 +381,7 @@ func (s *ChannelStore) DeleteForumTopicHistory(_ context.Context, req domain.Del
|
||||||
if !ok {
|
if !ok {
|
||||||
return domain.DeleteChannelHistoryResult{}, domain.ErrMessageIDInvalid
|
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
|
return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired
|
||||||
}
|
}
|
||||||
ids := make([]int, 0, domain.MaxDeleteHistoryBatch)
|
ids := make([]int, 0, domain.MaxDeleteHistoryBatch)
|
||||||
|
|
@ -613,7 +633,10 @@ func (s *ChannelStore) channelMessageRepliesLocked(viewerUserID, channelID int64
|
||||||
return &stats
|
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 {
|
if topic.CreatorUserID == userID {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -653,7 +653,7 @@ GROUP BY topic_id`, userID, channelID, roots, availableMinID)
|
||||||
return nil
|
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 {
|
if req.ReplyTo == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
@ -679,7 +679,7 @@ func (s *ChannelStore) resolveChannelReply(ctx context.Context, db sqlcgen.DBTX,
|
||||||
if topic.Hidden {
|
if topic.Hidden {
|
||||||
return nil, domain.ErrReplyMessageIDInvalid
|
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
|
return nil, domain.ErrChannelWriteForbidden
|
||||||
}
|
}
|
||||||
reply := cloneMessageReply(req.ReplyTo)
|
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 channel.Forum && reply.TopMessageID > 0 {
|
||||||
if topic, err := s.getForumTopic(ctx, db, req.ChannelID, reply.TopMessageID); err == nil && !topic.Hidden {
|
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
|
return nil, domain.ErrChannelWriteForbidden
|
||||||
}
|
}
|
||||||
reply.ForumTopic = true
|
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->>'SendPolls')::boolean IS TRUE OR
|
||||||
(m.banned_rights->>'ChangeInfo')::boolean IS TRUE OR
|
(m.banned_rights->>'ChangeInfo')::boolean IS TRUE OR
|
||||||
(m.banned_rights->>'InviteUsers')::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:
|
case domain.ChannelParticipantsSearch:
|
||||||
where = append(where, "m.status = 'active'")
|
where = append(where, "m.status = 'active'")
|
||||||
|
|
|
||||||
|
|
@ -61,10 +61,13 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se
|
||||||
return domain.SendChannelMessageResult{}, err
|
return domain.SendChannelMessageResult{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if domain.ChannelBannedRightsBlockMessage(req, channel, member, fromBoostsApplied) {
|
||||||
|
return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden
|
||||||
|
}
|
||||||
if !canSendChannelMessageWithBoost(channel, member, fromBoostsApplied) {
|
if !canSendChannelMessageWithBoost(channel, member, fromBoostsApplied) {
|
||||||
return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden
|
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 {
|
if err != nil {
|
||||||
return domain.SendChannelMessageResult{}, err
|
return domain.SendChannelMessageResult{}, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,18 @@ func (s *ChannelStore) SetChannelMessageReactions(ctx context.Context, req domai
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ChannelMessageReactionsResult{}, err
|
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)
|
msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.MessageID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ChannelMessageReactionsResult{}, err
|
return domain.ChannelMessageReactionsResult{}, err
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,20 @@ func (s *ChannelStore) CreateForumTopic(ctx context.Context, req domain.CreateCh
|
||||||
if !canSendChannelMessage(channel, member) {
|
if !canSendChannelMessage(channel, member) {
|
||||||
return domain.CreateChannelForumTopicResult{}, domain.ErrChannelWriteForbidden
|
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 {
|
if req.IconColor == 0 {
|
||||||
req.IconColor = domain.DefaultForumTopicIconColor
|
req.IconColor = domain.DefaultForumTopicIconColor
|
||||||
}
|
}
|
||||||
|
|
@ -192,7 +206,18 @@ func (s *ChannelStore) EditForumTopic(ctx context.Context, req domain.EditChanne
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.EditChannelForumTopicResult{}, err
|
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
|
return domain.EditChannelForumTopicResult{}, domain.ErrChannelAdminRequired
|
||||||
}
|
}
|
||||||
next := topic
|
next := topic
|
||||||
|
|
@ -390,7 +415,7 @@ func (s *ChannelStore) DeleteForumTopicHistory(ctx context.Context, req domain.D
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.DeleteChannelHistoryResult{}, err
|
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
|
return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired
|
||||||
}
|
}
|
||||||
rows, err := tx.Query(ctx, `
|
rows, err := tx.Query(ctx, `
|
||||||
|
|
@ -1023,7 +1048,10 @@ func scanChannelForumTopic(row rowScanner) (domain.ChannelForumTopic, error) {
|
||||||
return topic, nil
|
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 {
|
if topic.CreatorUserID == userID {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue