fix: sync latest Android and langpack updates
This commit is contained in:
parent
20b388eeaa
commit
4570df5939
20 changed files with 12384 additions and 27 deletions
|
|
@ -139,6 +139,10 @@ Recommended checks:
|
||||||
- Check server logs for no new `NOT_IMPLEMENTED`, `Unhandled RPC`, `bad_msg`,
|
- Check server logs for no new `NOT_IMPLEMENTED`, `Unhandled RPC`, `bad_msg`,
|
||||||
panic, or internal errors.
|
panic, or internal errors.
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
- [ajarshia](https://github.com/ajarshia) - Android Persian (`fa`) language pack.
|
||||||
|
|
||||||
## Repository Layout
|
## Repository Layout
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,10 @@ Start-Process $tdesktop -ArgumentList @("-workdir", "$PWD\.tdata-bob")
|
||||||
- 同一账号多 session 登录,确认当前 session 不重复 echo,其它在线 session 能收到 updates。
|
- 同一账号多 session 登录,确认当前 session 不重复 echo,其它在线 session 能收到 updates。
|
||||||
- 检查 server 日志没有新增 `NOT_IMPLEMENTED`、`Unhandled RPC`、`bad_msg`、panic 或 internal error。
|
- 检查 server 日志没有新增 `NOT_IMPLEMENTED`、`Unhandled RPC`、`bad_msg`、panic 或 internal error。
|
||||||
|
|
||||||
|
## 贡献者
|
||||||
|
|
||||||
|
- [ajarshia](https://github.com/ajarshia) - Android Persian (`fa`) 语言包。
|
||||||
|
|
||||||
## 仓库结构
|
## 仓库结构
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
11655
data/langpack/android/android_fa_v59634849.strings
Normal file
11655
data/langpack/android/android_fa_v59634849.strings
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -128,7 +128,7 @@ func (s *Service) sendServiceBotReply(ctx context.Context, botUserID, userID int
|
||||||
RecipientUserID: userID,
|
RecipientUserID: userID,
|
||||||
RandomID: s.botReplyRandomID(),
|
RandomID: s.botReplyRandomID(),
|
||||||
Message: reply.Text,
|
Message: reply.Text,
|
||||||
Entities: reply.Entities,
|
Entities: serviceBotReplyEntities(reply.Text, reply.Entities),
|
||||||
Date: int(s.now().Unix()),
|
Date: int(s.now().Unix()),
|
||||||
RecipientBlocked: blocked,
|
RecipientBlocked: blocked,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
|
|
|
||||||
182
internal/app/bots/service_bot_entities.go
Normal file
182
internal/app/bots/service_bot_entities.go
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
package bots
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type serviceBotEntitySpan struct {
|
||||||
|
start int
|
||||||
|
end int
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceBotReplyEntities(text string, explicit []domain.MessageEntity) []domain.MessageEntity {
|
||||||
|
if text == "" && len(explicit) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := append([]domain.MessageEntity(nil), explicit...)
|
||||||
|
occupied := make([]serviceBotEntitySpan, 0, len(out)+8)
|
||||||
|
for _, entity := range out {
|
||||||
|
if entity.Length <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
occupied = append(occupied, serviceBotEntitySpan{start: entity.Offset, end: entity.Offset + entity.Length})
|
||||||
|
}
|
||||||
|
appendEntity := func(entity domain.MessageEntity) {
|
||||||
|
if entity.Length <= 0 || len(out) >= domain.MaxMessageEntityCount {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
span := serviceBotEntitySpan{start: entity.Offset, end: entity.Offset + entity.Length}
|
||||||
|
if serviceBotSpanOverlaps(span, occupied) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, entity)
|
||||||
|
occupied = append(occupied, span)
|
||||||
|
}
|
||||||
|
for _, span := range serviceBotURLByteSpans(text) {
|
||||||
|
offset, length := utf16Range(text, span.start, span.end)
|
||||||
|
appendEntity(domain.MessageEntity{Type: domain.MessageEntityURL, Offset: offset, Length: length})
|
||||||
|
}
|
||||||
|
for _, span := range serviceBotCommandByteSpans(text) {
|
||||||
|
offset, length := utf16Range(text, span.start, span.end)
|
||||||
|
appendEntity(domain.MessageEntity{Type: domain.MessageEntityBotCommand, Offset: offset, Length: length})
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
if out[i].Offset != out[j].Offset {
|
||||||
|
return out[i].Offset < out[j].Offset
|
||||||
|
}
|
||||||
|
return out[i].Length < out[j].Length
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceBotURLByteSpans(text string) []serviceBotEntitySpan {
|
||||||
|
var spans []serviceBotEntitySpan
|
||||||
|
for i := 0; i < len(text); {
|
||||||
|
if !strings.HasPrefix(text[i:], "https://") && !strings.HasPrefix(text[i:], "http://") {
|
||||||
|
_, size := utf8.DecodeRuneInString(text[i:])
|
||||||
|
i += size
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start := i
|
||||||
|
i += len("http://")
|
||||||
|
if strings.HasPrefix(text[start:], "https://") {
|
||||||
|
i = start + len("https://")
|
||||||
|
}
|
||||||
|
for i < len(text) {
|
||||||
|
r, size := utf8.DecodeRuneInString(text[i:])
|
||||||
|
if unicode.IsSpace(r) || r == '<' || r == '>' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i += size
|
||||||
|
}
|
||||||
|
end := trimServiceBotURLTrailingPunctuation(text, start, i)
|
||||||
|
if end > start {
|
||||||
|
spans = append(spans, serviceBotEntitySpan{start: start, end: end})
|
||||||
|
}
|
||||||
|
if i == start {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return spans
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimServiceBotURLTrailingPunctuation(text string, start, end int) int {
|
||||||
|
for end > start {
|
||||||
|
r, size := utf8.DecodeLastRuneInString(text[start:end])
|
||||||
|
if !strings.ContainsRune(".,;:!?)]}", r) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end -= size
|
||||||
|
}
|
||||||
|
return end
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceBotCommandByteSpans(text string) []serviceBotEntitySpan {
|
||||||
|
var spans []serviceBotEntitySpan
|
||||||
|
for i := 0; i < len(text); {
|
||||||
|
r, size := utf8.DecodeRuneInString(text[i:])
|
||||||
|
if r != '/' || !serviceBotCommandStart(text, i) {
|
||||||
|
i += size
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start := i
|
||||||
|
i += size
|
||||||
|
commandStartEnd := i
|
||||||
|
for i < len(text) {
|
||||||
|
r, size = utf8.DecodeRuneInString(text[i:])
|
||||||
|
if !serviceBotCommandChar(r) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i += size
|
||||||
|
}
|
||||||
|
if i == commandStartEnd {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i < len(text) {
|
||||||
|
r, size = utf8.DecodeRuneInString(text[i:])
|
||||||
|
if r == '@' {
|
||||||
|
mentionEnd := i + size
|
||||||
|
for mentionEnd < len(text) {
|
||||||
|
r, size = utf8.DecodeRuneInString(text[mentionEnd:])
|
||||||
|
if !serviceBotCommandChar(r) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
mentionEnd += size
|
||||||
|
}
|
||||||
|
if mentionEnd > i+size {
|
||||||
|
i = mentionEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spans = append(spans, serviceBotEntitySpan{start: start, end: i})
|
||||||
|
}
|
||||||
|
return spans
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceBotCommandStart(text string, byteIndex int) bool {
|
||||||
|
if byteIndex == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
prev, _ := utf8.DecodeLastRuneInString(text[:byteIndex])
|
||||||
|
if prev == ':' || prev == '/' || prev == '@' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !serviceBotCommandChar(prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceBotCommandChar(r rune) bool {
|
||||||
|
return r == '_' || ('0' <= r && r <= '9') || ('A' <= r && r <= 'Z') || ('a' <= r && r <= 'z')
|
||||||
|
}
|
||||||
|
|
||||||
|
func utf16Range(text string, startByte, endByte int) (int, int) {
|
||||||
|
offset := 0
|
||||||
|
for _, r := range text[:startByte] {
|
||||||
|
offset += utf16RuneLen(r)
|
||||||
|
}
|
||||||
|
length := 0
|
||||||
|
for _, r := range text[startByte:endByte] {
|
||||||
|
length += utf16RuneLen(r)
|
||||||
|
}
|
||||||
|
return offset, length
|
||||||
|
}
|
||||||
|
|
||||||
|
func utf16RuneLen(r rune) int {
|
||||||
|
if r > 0xFFFF {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceBotSpanOverlaps(span serviceBotEntitySpan, occupied []serviceBotEntitySpan) bool {
|
||||||
|
for _, other := range occupied {
|
||||||
|
if span.start < other.end && other.start < span.end {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
60
internal/app/bots/service_bot_entities_test.go
Normal file
60
internal/app/bots/service_bot_entities_test.go
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
package bots
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServiceBotReplyEntitiesCommandsAndURLsUseUTF16Offsets(t *testing.T) {
|
||||||
|
text := "🙂 Send /cancel or https://telesrv.net/addstickers/fun_pack."
|
||||||
|
entities := serviceBotReplyEntities(text, nil)
|
||||||
|
|
||||||
|
assertEntity := func(typ domain.MessageEntityType, offset, length int) {
|
||||||
|
t.Helper()
|
||||||
|
for _, entity := range entities {
|
||||||
|
if entity.Type == typ && entity.Offset == offset && entity.Length == length {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("entities %+v missing %s at offset=%d length=%d", entities, typ, offset, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEntity(domain.MessageEntityBotCommand, 8, len("/cancel"))
|
||||||
|
assertEntity(domain.MessageEntityURL, 19, len("https://telesrv.net/addstickers/fun_pack"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceBotReplyEntitiesSkipCommandsInsideURLsAndExplicitEntities(t *testing.T) {
|
||||||
|
text := "Token: abc/def\nhttps://telesrv.net/addstickers/fun_pack\nUse /help"
|
||||||
|
entities := serviceBotReplyEntities(text, []domain.MessageEntity{{
|
||||||
|
Type: domain.MessageEntityCode,
|
||||||
|
Offset: len("Token: "),
|
||||||
|
Length: len("abc/def"),
|
||||||
|
}})
|
||||||
|
|
||||||
|
commandCount := 0
|
||||||
|
for _, entity := range entities {
|
||||||
|
if entity.Type == domain.MessageEntityBotCommand {
|
||||||
|
commandCount++
|
||||||
|
}
|
||||||
|
if entity.Type == domain.MessageEntityBotCommand && entity.Offset < len("Token: abc/def\nhttps://telesrv.net/") {
|
||||||
|
t.Fatalf("unexpected command entity inside code/url: %+v in %+v", entity, entities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if commandCount != 1 {
|
||||||
|
t.Fatalf("bot command entities = %d in %+v, want only /help", commandCount, entities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceBotReplyEntitiesIgnoreBareSlashBeforeEmoji(t *testing.T) {
|
||||||
|
entities := serviceBotReplyEntities("not a command /🙂 but /help is", nil)
|
||||||
|
commandCount := 0
|
||||||
|
for _, entity := range entities {
|
||||||
|
if entity.Type == domain.MessageEntityBotCommand {
|
||||||
|
commandCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if commandCount != 1 {
|
||||||
|
t.Fatalf("bot command entities = %d in %+v, want only /help", commandCount, entities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,7 +29,12 @@ func sendMessageToStickers(t *testing.T, svc *Service, messages *memory.MessageS
|
||||||
msg.From = domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
|
msg.From = domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
|
||||||
msg.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: domain.StickersBotUserID}
|
msg.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: domain.StickersBotUserID}
|
||||||
svc.respondAsStickers(owner.ID, msg)
|
svc.respondAsStickers(owner.ID, msg)
|
||||||
list, err := messages.ListByUser(context.Background(), owner.ID, domain.MessageFilter{
|
return latestStickersReply(t, messages, owner.ID).Body
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestStickersReply(t *testing.T, messages *memory.MessageStore, userID int64) domain.Message {
|
||||||
|
t.Helper()
|
||||||
|
list, err := messages.ListByUser(context.Background(), userID, domain.MessageFilter{
|
||||||
HasPeer: true,
|
HasPeer: true,
|
||||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.StickersBotUserID},
|
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.StickersBotUserID},
|
||||||
Limit: 100,
|
Limit: 100,
|
||||||
|
|
@ -44,9 +49,9 @@ func sendMessageToStickers(t *testing.T, svc *Service, messages *memory.MessageS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if latest.ID == 0 {
|
if latest.ID == 0 {
|
||||||
t.Fatalf("no Stickers reply after message %+v", msg)
|
t.Fatal("no Stickers reply")
|
||||||
}
|
}
|
||||||
return latest.Body
|
return latest
|
||||||
}
|
}
|
||||||
|
|
||||||
func newStickersBotTestService(t *testing.T) (*Service, *memory.UserStore, *memory.BotStore, *memory.MessageStore, *stickersBotFakeCreator, *stickersBotFakeInstaller) {
|
func newStickersBotTestService(t *testing.T) (*Service, *memory.UserStore, *memory.BotStore, *memory.MessageStore, *stickersBotFakeCreator, *stickersBotFakeInstaller) {
|
||||||
|
|
@ -138,6 +143,10 @@ func TestStickersBotSystemSeedStartAndCancel(t *testing.T) {
|
||||||
if reply := sendTextToStickers(t, svc, messages, owner, "/start"); !strings.Contains(reply, "/newpack") || !strings.Contains(reply, "/newemoji") || !strings.Contains(reply, "/addsticker") {
|
if reply := sendTextToStickers(t, svc, messages, owner, "/start"); !strings.Contains(reply, "/newpack") || !strings.Contains(reply, "/newemoji") || !strings.Contains(reply, "/addsticker") {
|
||||||
t.Fatalf("/start reply = %q, want help text", reply)
|
t.Fatalf("/start reply = %q, want help text", reply)
|
||||||
}
|
}
|
||||||
|
startReply := latestStickersReply(t, messages, owner.ID)
|
||||||
|
assertReplyEntityText(t, startReply, domain.MessageEntityBotCommand, "/newpack")
|
||||||
|
assertReplyEntityText(t, startReply, domain.MessageEntityBotCommand, "/newemoji")
|
||||||
|
assertReplyEntityText(t, startReply, domain.MessageEntityBotCommand, "/addsticker")
|
||||||
sendTextToStickers(t, svc, messages, owner, "/newpack")
|
sendTextToStickers(t, svc, messages, owner, "/newpack")
|
||||||
if reply := sendTextToStickers(t, svc, messages, owner, "/cancel"); !strings.Contains(reply, "Cancelled") {
|
if reply := sendTextToStickers(t, svc, messages, owner, "/cancel"); !strings.Contains(reply, "Cancelled") {
|
||||||
t.Fatalf("/cancel reply = %q, want cancelled", reply)
|
t.Fatalf("/cancel reply = %q, want cancelled", reply)
|
||||||
|
|
@ -190,6 +199,8 @@ func TestStickersBotPublishStickerPack(t *testing.T) {
|
||||||
if !strings.Contains(reply, "https://telesrv.net/addstickers/fresh_pack") {
|
if !strings.Contains(reply, "https://telesrv.net/addstickers/fresh_pack") {
|
||||||
t.Fatalf("publish reply = %q, want addstickers link", reply)
|
t.Fatalf("publish reply = %q, want addstickers link", reply)
|
||||||
}
|
}
|
||||||
|
publishReply := latestStickersReply(t, messages, owner.ID)
|
||||||
|
assertReplyEntityText(t, publishReply, domain.MessageEntityURL, "https://telesrv.net/addstickers/fresh_pack")
|
||||||
if len(creator.created) != 1 {
|
if len(creator.created) != 1 {
|
||||||
t.Fatalf("created requests = %d, want 1", len(creator.created))
|
t.Fatalf("created requests = %d, want 1", len(creator.created))
|
||||||
}
|
}
|
||||||
|
|
@ -454,6 +465,22 @@ func botCommandExists(commands []domain.BotCommand, want string) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertReplyEntityText(t *testing.T, msg domain.Message, typ domain.MessageEntityType, want string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, entity := range msg.Entities {
|
||||||
|
if entity.Type != typ {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entity.Offset < 0 || entity.Length < 0 || entity.Offset+entity.Length > len(msg.Body) {
|
||||||
|
t.Fatalf("entity %+v out of ASCII bounds for %q", entity, msg.Body)
|
||||||
|
}
|
||||||
|
if got := msg.Body[entity.Offset : entity.Offset+entity.Length]; got == want {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("message %q entities %+v missing %s entity for %q", msg.Body, msg.Entities, typ, want)
|
||||||
|
}
|
||||||
|
|
||||||
type stickersBotFakeCreator struct {
|
type stickersBotFakeCreator struct {
|
||||||
created []domain.CreateStickerSetRequest
|
created []domain.CreateStickerSetRequest
|
||||||
sets []domain.StickerSet
|
sets []domain.StickerSet
|
||||||
|
|
|
||||||
|
|
@ -96,3 +96,27 @@ func TestSeedDirectoryWalksClientSubdirs(t *testing.T) {
|
||||||
t.Fatalf("weba pack = %+v", pack)
|
t.Fatalf("weba pack = %+v", pack)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBundledAndroidPersianLangPackParses(t *testing.T) {
|
||||||
|
path := filepath.Join("..", "..", "..", "data", "langpack", "android", "android_fa_v59634849.strings")
|
||||||
|
pack, err := ParseTDesktopFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse bundled android fa pack: %v", err)
|
||||||
|
}
|
||||||
|
if pack.LangPack != "android" || pack.LangCode != "fa" || pack.Version != 59634849 {
|
||||||
|
t.Fatalf("pack meta = %+v, want android/fa v59634849", pack)
|
||||||
|
}
|
||||||
|
if len(pack.Strings) < 10000 {
|
||||||
|
t.Fatalf("strings count = %d, want full android fa pack", len(pack.Strings))
|
||||||
|
}
|
||||||
|
wantPersian := "\u0641\u0627\u0631\u0633\u06cc"
|
||||||
|
for _, item := range pack.Strings {
|
||||||
|
if item.Key == "TranslateLanguageFA" {
|
||||||
|
if item.Value != wantPersian {
|
||||||
|
t.Fatalf("TranslateLanguageFA = %q, want %q", item.Value, wantPersian)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("TranslateLanguageFA not found in bundled android fa pack")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,15 @@ func CreatorChannelAdminRights() ChannelAdminRights {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreatorProjectionAdminRights returns the full rights clients expect for an
|
||||||
|
// owner while preserving creator-scoped mutable flags.
|
||||||
|
func CreatorProjectionAdminRights(rights ChannelAdminRights) ChannelAdminRights {
|
||||||
|
creatorRights := CreatorChannelAdminRights()
|
||||||
|
creatorRights.Anonymous = rights.Anonymous
|
||||||
|
creatorRights.ManageDirectMessages = rights.ManageDirectMessages
|
||||||
|
return creatorRights
|
||||||
|
}
|
||||||
|
|
||||||
// NormalizeFullMegagroupAdminRights fills implicit full-admin bits for megagroups.
|
// NormalizeFullMegagroupAdminRights fills implicit full-admin bits for megagroups.
|
||||||
func NormalizeFullMegagroupAdminRights(ch Channel, rights ChannelAdminRights) ChannelAdminRights {
|
func NormalizeFullMegagroupAdminRights(ch Channel, rights ChannelAdminRights) ChannelAdminRights {
|
||||||
if ch.Megagroup && !ch.Broadcast &&
|
if ch.Megagroup && !ch.Broadcast &&
|
||||||
|
|
@ -1235,10 +1244,16 @@ type EditChannelAdminRequest struct {
|
||||||
ChannelID int64
|
ChannelID int64
|
||||||
MemberID int64
|
MemberID int64
|
||||||
AdminRights ChannelAdminRights
|
AdminRights ChannelAdminRights
|
||||||
|
RankSet bool
|
||||||
Rank string
|
Rank string
|
||||||
Date int
|
Date int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasRank reports whether channels.editAdmin explicitly supplied a rank.
|
||||||
|
func (r EditChannelAdminRequest) HasRank() bool {
|
||||||
|
return r.RankSet || r.Rank != ""
|
||||||
|
}
|
||||||
|
|
||||||
// EditChannelAdminResult describes the participant transition.
|
// EditChannelAdminResult describes the participant transition.
|
||||||
type EditChannelAdminResult struct {
|
type EditChannelAdminResult struct {
|
||||||
Channel Channel
|
Channel Channel
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,105 @@ func TestChannelsGetParticipantsHidesAnonymousAdminFromRegularMember(t *testing.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestChannelsEditAdminCreatorSelfCanToggleAnonymous(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
userStore := memory.NewUserStore()
|
||||||
|
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 41, Phone: "15550002171", FirstName: "Owner"})
|
||||||
|
admin, _ := userStore.Create(ctx, domain.User{AccessHash: 42, Phone: "15550002172", FirstName: "Admin"})
|
||||||
|
channelStore := memory.NewChannelStore()
|
||||||
|
r := New(Config{}, Deps{
|
||||||
|
Users: appusers.NewService(userStore),
|
||||||
|
Channels: appchannels.NewService(channelStore),
|
||||||
|
}, zaptest.NewLogger(t), clock.System)
|
||||||
|
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
|
||||||
|
Users: []tg.InputUserClass{&tg.InputUser{UserID: admin.ID, AccessHash: admin.AccessHash}},
|
||||||
|
Title: "Creator Anonymous Group",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create chat: %v", err)
|
||||||
|
}
|
||||||
|
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
|
||||||
|
inputChannel := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
|
||||||
|
|
||||||
|
updates, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{
|
||||||
|
Channel: inputChannel,
|
||||||
|
UserID: &tg.InputUser{UserID: owner.ID, AccessHash: owner.AccessHash},
|
||||||
|
AdminRights: tg.ChatAdminRights{
|
||||||
|
Anonymous: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("creator toggles own anonymous admin rights: %v", err)
|
||||||
|
}
|
||||||
|
var participantUpdate *tg.ChannelParticipantCreator
|
||||||
|
for _, update := range updates.(*tg.Updates).Updates {
|
||||||
|
upd, ok := update.(*tg.UpdateChannelParticipant)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if creator, ok := upd.NewParticipant.(*tg.ChannelParticipantCreator); ok && creator.UserID == owner.ID {
|
||||||
|
participantUpdate = creator
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if participantUpdate == nil {
|
||||||
|
t.Fatalf("editAdmin updates = %+v, want creator participant update", updates.(*tg.Updates).Updates)
|
||||||
|
}
|
||||||
|
if !participantUpdate.AdminRights.Anonymous || !participantUpdate.AdminRights.ChangeInfo || !participantUpdate.AdminRights.AddAdmins {
|
||||||
|
t.Fatalf("creator update rights = %+v, want anonymous plus full creator projection", participantUpdate.AdminRights)
|
||||||
|
}
|
||||||
|
|
||||||
|
participant, err := r.onChannelsGetParticipant(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantRequest{
|
||||||
|
Channel: inputChannel,
|
||||||
|
Participant: &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get creator participant: %v", err)
|
||||||
|
}
|
||||||
|
projectedCreator, ok := participant.Participant.(*tg.ChannelParticipantCreator)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("participant = %T, want ChannelParticipantCreator", participant.Participant)
|
||||||
|
}
|
||||||
|
if !projectedCreator.AdminRights.Anonymous || !projectedCreator.AdminRights.ChangeInfo || !projectedCreator.AdminRights.AddAdmins {
|
||||||
|
t.Fatalf("projected creator rights = %+v, want anonymous plus full creator projection", projectedCreator.AdminRights)
|
||||||
|
}
|
||||||
|
|
||||||
|
chats, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{inputChannel})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get channels: %v", err)
|
||||||
|
}
|
||||||
|
projectedChannel := chats.(*tg.MessagesChats).Chats[0].(*tg.Channel)
|
||||||
|
rights, ok := projectedChannel.GetAdminRights()
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("projected channel has no admin rights: %+v", projectedChannel)
|
||||||
|
}
|
||||||
|
if !rights.Anonymous || !rights.ChangeInfo || !rights.AddAdmins {
|
||||||
|
t.Fatalf("projected channel admin rights = %+v, want anonymous plus full creator projection", rights)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{
|
||||||
|
Channel: inputChannel,
|
||||||
|
UserID: &tg.InputUser{UserID: admin.ID, AccessHash: admin.AccessHash},
|
||||||
|
AdminRights: tg.ChatAdminRights{
|
||||||
|
Anonymous: true,
|
||||||
|
ChangeInfo: true,
|
||||||
|
AddAdmins: true,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("promote admin: %v", err)
|
||||||
|
}
|
||||||
|
_, err = r.onChannelsEditAdmin(WithUserID(ctx, admin.ID), &tg.ChannelsEditAdminRequest{
|
||||||
|
Channel: inputChannel,
|
||||||
|
UserID: &tg.InputUser{UserID: owner.ID, AccessHash: owner.AccessHash},
|
||||||
|
AdminRights: tg.ChatAdminRights{
|
||||||
|
ChangeInfo: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "USER_CREATOR") {
|
||||||
|
t.Fatalf("admin edits creator err = %v, want USER_CREATOR", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func tgParticipantListHasUser(participants []tg.ChannelParticipantClass, userID int64) bool {
|
func tgParticipantListHasUser(participants []tg.ChannelParticipantClass, userID int64) bool {
|
||||||
for _, participant := range participants {
|
for _, participant := range participants {
|
||||||
for _, id := range channelParticipantUserRefs(participant) {
|
for _, id := range channelParticipantUserRefs(participant) {
|
||||||
|
|
|
||||||
|
|
@ -402,6 +402,7 @@ func (r *Router) onChannelsEditAdmin(ctx context.Context, req *tg.ChannelsEditAd
|
||||||
ChannelID: channelID,
|
ChannelID: channelID,
|
||||||
MemberID: target.ID,
|
MemberID: target.ID,
|
||||||
AdminRights: domainChannelAdminRights(req.AdminRights),
|
AdminRights: domainChannelAdminRights(req.AdminRights),
|
||||||
|
RankSet: req.Flags.Has(0),
|
||||||
Rank: req.Rank,
|
Rank: req.Rank,
|
||||||
Date: int(r.clock.Now().Unix()),
|
Date: int(r.clock.Now().Unix()),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -780,10 +780,7 @@ func tgChatAdminRights(rights domain.ChannelAdminRights) tg.ChatAdminRights {
|
||||||
}
|
}
|
||||||
|
|
||||||
func creatorProjectionAdminRights(rights domain.ChannelAdminRights) domain.ChannelAdminRights {
|
func creatorProjectionAdminRights(rights domain.ChannelAdminRights) domain.ChannelAdminRights {
|
||||||
creatorRights := domain.CreatorChannelAdminRights()
|
return domain.CreatorProjectionAdminRights(rights)
|
||||||
creatorRights.Anonymous = rights.Anonymous
|
|
||||||
creatorRights.ManageDirectMessages = rights.ManageDirectMessages
|
|
||||||
return creatorRights
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func domainChannelAdminRights(rights tg.ChatAdminRights) domain.ChannelAdminRights {
|
func domainChannelAdminRights(rights tg.ChatAdminRights) domain.ChannelAdminRights {
|
||||||
|
|
|
||||||
|
|
@ -91,8 +91,8 @@ func (r *Router) langpackLanguages(ctx context.Context, langPack string) []tg.La
|
||||||
if langPack == "" {
|
if langPack == "" {
|
||||||
langPack = langPackFromClient(ctx)
|
langPack = langPackFromClient(ctx)
|
||||||
}
|
}
|
||||||
_ = langPack
|
langPack = strings.ToLower(langPack)
|
||||||
return []tg.LangPackLanguage{
|
languages := []tg.LangPackLanguage{
|
||||||
{
|
{
|
||||||
Official: true,
|
Official: true,
|
||||||
Name: "English",
|
Name: "English",
|
||||||
|
|
@ -114,6 +114,20 @@ func (r *Router) langpackLanguages(ctx context.Context, langPack string) []tg.La
|
||||||
TranslationsURL: "",
|
TranslationsURL: "",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if langPack == "android" {
|
||||||
|
languages = append(languages, tg.LangPackLanguage{
|
||||||
|
Official: true,
|
||||||
|
Rtl: true,
|
||||||
|
Name: "Persian",
|
||||||
|
NativeName: "فارسی",
|
||||||
|
LangCode: "fa",
|
||||||
|
PluralCode: "fa",
|
||||||
|
StringsCount: 11002,
|
||||||
|
TranslatedCount: 11002,
|
||||||
|
TranslationsURL: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return languages
|
||||||
}
|
}
|
||||||
|
|
||||||
func langPackFromClient(ctx context.Context) string {
|
func langPackFromClient(ctx context.Context) string {
|
||||||
|
|
@ -124,6 +138,12 @@ func langPackFromClient(ctx context.Context) string {
|
||||||
if info.LangPack != "" {
|
if info.LangPack != "" {
|
||||||
return info.LangPack
|
return info.LangPack
|
||||||
}
|
}
|
||||||
|
switch info.ClientType() {
|
||||||
|
case ClientTypeAndroid:
|
||||||
|
return string(ClientTypeAndroid)
|
||||||
|
case ClientTypeTDesktop:
|
||||||
|
return string(ClientTypeTDesktop)
|
||||||
|
}
|
||||||
client := strings.ToLower(info.DeviceModel + " " + info.SystemVersion + " " + info.AppVersion)
|
client := strings.ToLower(info.DeviceModel + " " + info.SystemVersion + " " + info.AppVersion)
|
||||||
if strings.Contains(client, "android") {
|
if strings.Contains(client, "android") {
|
||||||
return "android"
|
return "android"
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,9 @@ func TestLangpackGetLanguagesCurrentAndLegacy(t *testing.T) {
|
||||||
if err := (&tg.LangpackGetLanguagesRequest{LangPack: "tdesktop"}).Encode(&in); err != nil {
|
if err := (&tg.LangpackGetLanguagesRequest{LangPack: "tdesktop"}).Encode(&in); err != nil {
|
||||||
t.Fatalf("encode request: %v", err)
|
t.Fatalf("encode request: %v", err)
|
||||||
}
|
}
|
||||||
assertLangpackLanguages(t, r, context.Background(), &in)
|
langs := dispatchLangpackLanguages(t, r, context.Background(), &in)
|
||||||
|
assertHasLangpackLanguage(t, langs, "en")
|
||||||
|
assertNoLangpackLanguage(t, langs, "fa")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("legacy android no args", func(t *testing.T) {
|
t.Run("legacy android no args", func(t *testing.T) {
|
||||||
|
|
@ -30,7 +32,17 @@ func TestLangpackGetLanguagesCurrentAndLegacy(t *testing.T) {
|
||||||
AppVersion: "12.7.3",
|
AppVersion: "12.7.3",
|
||||||
LangCode: "en",
|
LangCode: "en",
|
||||||
})
|
})
|
||||||
assertLangpackLanguages(t, r, ctx, &in)
|
langs := dispatchLangpackLanguages(t, r, ctx, &in)
|
||||||
|
assertHasLangpackLanguage(t, langs, "en")
|
||||||
|
assertHasLangpackLanguage(t, langs, "fa")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("legacy android from cached client type", func(t *testing.T) {
|
||||||
|
var in bin.Buffer
|
||||||
|
in.PutID(0x800fd57d)
|
||||||
|
ctx := WithClientInfo(context.Background(), ClientInfo{Type: ClientTypeAndroid})
|
||||||
|
langs := dispatchLangpackLanguages(t, r, ctx, &in)
|
||||||
|
assertHasLangpackLanguage(t, langs, "fa")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,6 +70,23 @@ func TestLangpackGetLanguage(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLangpackAndroidPersianLanguage(t *testing.T) {
|
||||||
|
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System)
|
||||||
|
|
||||||
|
lang := r.langpackLanguage(context.Background(), "android", "fa")
|
||||||
|
if lang.LangCode != "fa" || lang.PluralCode != "fa" || lang.Name != "Persian" || lang.NativeName != "فارسی" || !lang.Rtl {
|
||||||
|
t.Fatalf("android fa language = %+v", lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
languages := r.langpackLanguages(androidClientContext(), "")
|
||||||
|
for _, item := range languages {
|
||||||
|
if item.LangCode == "fa" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("android languages = %+v, want fa entry", languages)
|
||||||
|
}
|
||||||
|
|
||||||
func TestLegacyLangpackGetLangPack(t *testing.T) {
|
func TestLegacyLangpackGetLangPack(t *testing.T) {
|
||||||
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System)
|
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System)
|
||||||
|
|
||||||
|
|
@ -157,7 +186,7 @@ func TestLegacyAccountRegisterDevice(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func assertLangpackLanguages(t *testing.T, r *Router, ctx context.Context, in *bin.Buffer) {
|
func dispatchLangpackLanguages(t *testing.T, r *Router, ctx context.Context, in *bin.Buffer) []tg.LangPackLanguage {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
enc, err := r.Dispatch(ctx, [8]byte{}, 0, in)
|
enc, err := r.Dispatch(ctx, [8]byte{}, 0, in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -171,8 +200,25 @@ func assertLangpackLanguages(t *testing.T, r *Router, ctx context.Context, in *b
|
||||||
if err := langs.Decode(&out); err != nil {
|
if err := langs.Decode(&out); err != nil {
|
||||||
t.Fatalf("decode response: %v", err)
|
t.Fatalf("decode response: %v", err)
|
||||||
}
|
}
|
||||||
if len(langs.Elems) == 0 || langs.Elems[0].LangCode != "en" {
|
return langs.Elems
|
||||||
t.Fatalf("languages = %+v, want English entry", langs.Elems)
|
}
|
||||||
|
|
||||||
|
func assertHasLangpackLanguage(t *testing.T, langs []tg.LangPackLanguage, code string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, lang := range langs {
|
||||||
|
if lang.LangCode == code {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("languages = %+v, want %s entry", langs, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoLangpackLanguage(t *testing.T, langs []tg.LangPackLanguage, code string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, lang := range langs {
|
||||||
|
if lang.LangCode == code {
|
||||||
|
t.Fatalf("languages = %+v, want no %s entry", langs, code)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -552,7 +552,8 @@ func adminRightsSubset(want, have domain.ChannelAdminRights) bool {
|
||||||
(!want.AddAdmins || have.AddAdmins) &&
|
(!want.AddAdmins || have.AddAdmins) &&
|
||||||
(!want.ManageCall || have.ManageCall) &&
|
(!want.ManageCall || have.ManageCall) &&
|
||||||
(!want.Anonymous || have.Anonymous) &&
|
(!want.Anonymous || have.Anonymous) &&
|
||||||
(!want.ManageRanks || have.ManageRanks)
|
(!want.ManageRanks || have.ManageRanks) &&
|
||||||
|
(!want.ManageDirectMessages || have.ManageDirectMessages)
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkEditMemberRank validates a rank-only (member tag) edit: creator edits
|
// checkEditMemberRank validates a rank-only (member tag) edit: creator edits
|
||||||
|
|
|
||||||
|
|
@ -282,9 +282,6 @@ func (s *ChannelStore) EditChannelAdmin(_ context.Context, req domain.EditChanne
|
||||||
if !canAddChannelAdmins(actor) {
|
if !canAddChannelAdmins(actor) {
|
||||||
return domain.EditChannelAdminResult{}, domain.ErrChannelAdminRequired
|
return domain.EditChannelAdminResult{}, domain.ErrChannelAdminRequired
|
||||||
}
|
}
|
||||||
if actor.Role != domain.ChannelRoleCreator && !adminRightsSubset(req.AdminRights, actor.AdminRights) {
|
|
||||||
return domain.EditChannelAdminResult{}, domain.ErrChannelRightForbidden
|
|
||||||
}
|
|
||||||
previous, ok := s.members[req.ChannelID][req.MemberID]
|
previous, ok := s.members[req.ChannelID][req.MemberID]
|
||||||
if !ok {
|
if !ok {
|
||||||
previous = domain.ChannelMember{
|
previous = domain.ChannelMember{
|
||||||
|
|
@ -300,9 +297,43 @@ func (s *ChannelStore) EditChannelAdmin(_ context.Context, req domain.EditChanne
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if previous.Role == domain.ChannelRoleCreator {
|
if previous.Role == domain.ChannelRoleCreator {
|
||||||
|
if req.MemberID != req.UserID || channel.CreatorUserID != req.UserID || actor.Role != domain.ChannelRoleCreator {
|
||||||
return domain.EditChannelAdminResult{}, domain.ErrChannelUserCreator
|
return domain.EditChannelAdminResult{}, domain.ErrChannelUserCreator
|
||||||
}
|
}
|
||||||
member := previous
|
member := previous
|
||||||
|
member.Status = domain.ChannelMemberActive
|
||||||
|
member.LeftAt = 0
|
||||||
|
member.AdminRights = domain.CreatorProjectionAdminRights(req.AdminRights)
|
||||||
|
if req.HasRank() {
|
||||||
|
member.Rank = req.Rank
|
||||||
|
}
|
||||||
|
s.members[req.ChannelID][req.MemberID] = member
|
||||||
|
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||||
|
ChannelID: req.ChannelID,
|
||||||
|
UserID: req.UserID,
|
||||||
|
Date: req.Date,
|
||||||
|
Type: domain.ChannelAdminLogParticipantPromote,
|
||||||
|
PrevParticipant: ptrChannelMember(previous),
|
||||||
|
NewParticipant: ptrChannelMember(member),
|
||||||
|
})
|
||||||
|
s.refreshChannelCountsLocked(req.ChannelID)
|
||||||
|
channel = s.channels[req.ChannelID]
|
||||||
|
event := transientChannelParticipantEvent(channel.ID, req.UserID, previous, member, req.Date)
|
||||||
|
recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0)
|
||||||
|
recipients = append(recipients, req.MemberID)
|
||||||
|
return domain.EditChannelAdminResult{
|
||||||
|
Channel: channel,
|
||||||
|
Previous: previous,
|
||||||
|
Participant: member,
|
||||||
|
Event: event,
|
||||||
|
Recipients: recipients,
|
||||||
|
Date: req.Date,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if actor.Role != domain.ChannelRoleCreator && !adminRightsSubset(req.AdminRights, actor.AdminRights) {
|
||||||
|
return domain.EditChannelAdminResult{}, domain.ErrChannelRightForbidden
|
||||||
|
}
|
||||||
|
member := previous
|
||||||
member.InviterUserID = req.UserID
|
member.InviterUserID = req.UserID
|
||||||
member.Status = domain.ChannelMemberActive
|
member.Status = domain.ChannelMemberActive
|
||||||
member.LeftAt = 0
|
member.LeftAt = 0
|
||||||
|
|
@ -312,7 +343,9 @@ func (s *ChannelStore) EditChannelAdmin(_ context.Context, req domain.EditChanne
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
member.AdminRights = domain.NormalizeFullMegagroupAdminRights(channel, req.AdminRights)
|
member.AdminRights = domain.NormalizeFullMegagroupAdminRights(channel, req.AdminRights)
|
||||||
|
if req.HasRank() {
|
||||||
member.Rank = req.Rank
|
member.Rank = req.Rank
|
||||||
|
}
|
||||||
if zeroChannelAdminRights(req.AdminRights) {
|
if zeroChannelAdminRights(req.AdminRights) {
|
||||||
member.Role = domain.ChannelRoleMember
|
member.Role = domain.ChannelRoleMember
|
||||||
member.Rank = ""
|
member.Rank = ""
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,68 @@ func TestChannelCreatorLeaveTransfersOwner(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestChannelCreatorCanEditOwnAdminRights(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := NewChannelStore()
|
||||||
|
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||||
|
CreatorUserID: 1,
|
||||||
|
Title: "creator self admin",
|
||||||
|
Megagroup: true,
|
||||||
|
MemberUserIDs: []int64{2},
|
||||||
|
Date: 1_700_000_114,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create channel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
edited, err := store.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{
|
||||||
|
UserID: 1,
|
||||||
|
ChannelID: created.Channel.ID,
|
||||||
|
MemberID: 1,
|
||||||
|
AdminRights: domain.ChannelAdminRights{
|
||||||
|
Anonymous: true,
|
||||||
|
},
|
||||||
|
Date: 1_700_000_115,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("creator edits own admin rights: %v", err)
|
||||||
|
}
|
||||||
|
if edited.Participant.Role != domain.ChannelRoleCreator || !edited.Participant.AdminRights.Anonymous || !edited.Participant.AdminRights.ChangeInfo || !edited.Participant.AdminRights.AddAdmins {
|
||||||
|
t.Fatalf("edited creator = %+v, want creator with anonymous plus full creator projection", edited.Participant)
|
||||||
|
}
|
||||||
|
persisted, err := store.GetParticipant(ctx, 1, created.Channel.ID, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get creator participant: %v", err)
|
||||||
|
}
|
||||||
|
if persisted.Role != domain.ChannelRoleCreator || !persisted.AdminRights.Anonymous || !persisted.AdminRights.ChangeInfo || !persisted.AdminRights.AddAdmins {
|
||||||
|
t.Fatalf("persisted creator = %+v, want creator with anonymous plus full creator projection", persisted)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := store.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{
|
||||||
|
UserID: 1,
|
||||||
|
ChannelID: created.Channel.ID,
|
||||||
|
MemberID: 2,
|
||||||
|
AdminRights: domain.ChannelAdminRights{
|
||||||
|
ChangeInfo: true,
|
||||||
|
AddAdmins: true,
|
||||||
|
},
|
||||||
|
Date: 1_700_000_116,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("promote admin: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{
|
||||||
|
UserID: 2,
|
||||||
|
ChannelID: created.Channel.ID,
|
||||||
|
MemberID: 1,
|
||||||
|
AdminRights: domain.ChannelAdminRights{
|
||||||
|
ChangeInfo: true,
|
||||||
|
},
|
||||||
|
Date: 1_700_000_117,
|
||||||
|
}); !errors.Is(err, domain.ErrChannelUserCreator) {
|
||||||
|
t.Fatalf("admin edits creator err = %v, want ErrChannelUserCreator", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChannelAdminAndBanDoNotAdvanceChannelPts(t *testing.T) {
|
func TestChannelAdminAndBanDoNotAdvanceChannelPts(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
store := NewChannelStore()
|
store := NewChannelStore()
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,96 @@ func TestChannelStoreEditAboutPersistsAndChecksPermission(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestChannelStoreCreatorCanEditOwnAdminRights(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
suffix := randomSuffix(t)
|
||||||
|
users := NewUserStore(pool)
|
||||||
|
owner, err := users.Create(ctx, domain.User{
|
||||||
|
AccessHash: 141,
|
||||||
|
Phone: "+1888" + suffix + "11",
|
||||||
|
FirstName: "CreatorSelfOwner",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create owner: %v", err)
|
||||||
|
}
|
||||||
|
admin, err := users.Create(ctx, domain.User{
|
||||||
|
AccessHash: 142,
|
||||||
|
Phone: "+1888" + suffix + "12",
|
||||||
|
FirstName: "CreatorSelfAdmin",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create admin: %v", err)
|
||||||
|
}
|
||||||
|
var channelID int64
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if channelID != 0 {
|
||||||
|
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
|
||||||
|
}
|
||||||
|
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, admin.ID})
|
||||||
|
})
|
||||||
|
|
||||||
|
channels := NewChannelStore(pool)
|
||||||
|
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||||
|
CreatorUserID: owner.ID,
|
||||||
|
Title: "Creator Self " + suffix,
|
||||||
|
Megagroup: true,
|
||||||
|
MemberUserIDs: []int64{admin.ID},
|
||||||
|
Date: 1700000610,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create channel: %v", err)
|
||||||
|
}
|
||||||
|
channelID = created.Channel.ID
|
||||||
|
|
||||||
|
edited, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{
|
||||||
|
UserID: owner.ID,
|
||||||
|
ChannelID: channelID,
|
||||||
|
MemberID: owner.ID,
|
||||||
|
AdminRights: domain.ChannelAdminRights{
|
||||||
|
Anonymous: true,
|
||||||
|
},
|
||||||
|
Date: 1700000611,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("creator edits own admin rights: %v", err)
|
||||||
|
}
|
||||||
|
if edited.Participant.Role != domain.ChannelRoleCreator || !edited.Participant.AdminRights.Anonymous || !edited.Participant.AdminRights.ChangeInfo || !edited.Participant.AdminRights.AddAdmins {
|
||||||
|
t.Fatalf("edited creator = %+v, want creator with anonymous plus full creator projection", edited.Participant)
|
||||||
|
}
|
||||||
|
persisted, err := channels.GetParticipant(ctx, owner.ID, channelID, owner.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get creator participant: %v", err)
|
||||||
|
}
|
||||||
|
if persisted.Role != domain.ChannelRoleCreator || !persisted.AdminRights.Anonymous || !persisted.AdminRights.ChangeInfo || !persisted.AdminRights.AddAdmins {
|
||||||
|
t.Fatalf("persisted creator = %+v, want creator with anonymous plus full creator projection", persisted)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{
|
||||||
|
UserID: owner.ID,
|
||||||
|
ChannelID: channelID,
|
||||||
|
MemberID: admin.ID,
|
||||||
|
AdminRights: domain.ChannelAdminRights{
|
||||||
|
ChangeInfo: true,
|
||||||
|
AddAdmins: true,
|
||||||
|
},
|
||||||
|
Date: 1700000612,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("promote admin: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{
|
||||||
|
UserID: admin.ID,
|
||||||
|
ChannelID: channelID,
|
||||||
|
MemberID: owner.ID,
|
||||||
|
AdminRights: domain.ChannelAdminRights{
|
||||||
|
ChangeInfo: true,
|
||||||
|
},
|
||||||
|
Date: 1700000613,
|
||||||
|
}); !errors.Is(err, domain.ErrChannelUserCreator) {
|
||||||
|
t.Fatalf("admin edits creator err = %v, want ErrChannelUserCreator", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChannelStoreAdminLogFiltersAndSearch(t *testing.T) {
|
func TestChannelStoreAdminLogFiltersAndSearch(t *testing.T) {
|
||||||
pool := testPool(t)
|
pool := testPool(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
|
||||||
|
|
@ -783,7 +783,8 @@ func adminRightsSubset(want, have domain.ChannelAdminRights) bool {
|
||||||
(!want.AddAdmins || have.AddAdmins) &&
|
(!want.AddAdmins || have.AddAdmins) &&
|
||||||
(!want.ManageCall || have.ManageCall) &&
|
(!want.ManageCall || have.ManageCall) &&
|
||||||
(!want.Anonymous || have.Anonymous) &&
|
(!want.Anonymous || have.Anonymous) &&
|
||||||
(!want.ManageRanks || have.ManageRanks)
|
(!want.ManageRanks || have.ManageRanks) &&
|
||||||
|
(!want.ManageDirectMessages || have.ManageDirectMessages)
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkEditMemberRank validates a rank-only (member tag) edit: creator edits
|
// checkEditMemberRank validates a rank-only (member tag) edit: creator edits
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,6 @@ func (s *ChannelStore) EditChannelAdmin(ctx context.Context, req domain.EditChan
|
||||||
if !canAddChannelAdmins(actor) {
|
if !canAddChannelAdmins(actor) {
|
||||||
return domain.EditChannelAdminResult{}, domain.ErrChannelAdminRequired
|
return domain.EditChannelAdminResult{}, domain.ErrChannelAdminRequired
|
||||||
}
|
}
|
||||||
if actor.Role != domain.ChannelRoleCreator && !adminRightsSubset(req.AdminRights, actor.AdminRights) {
|
|
||||||
return domain.EditChannelAdminResult{}, domain.ErrChannelRightForbidden
|
|
||||||
}
|
|
||||||
previous, err := s.getChannelMember(ctx, tx, req.ChannelID, req.MemberID)
|
previous, err := s.getChannelMember(ctx, tx, req.ChannelID, req.MemberID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, domain.ErrChannelPrivate) {
|
if !errors.Is(err, domain.ErrChannelPrivate) {
|
||||||
|
|
@ -56,13 +53,52 @@ func (s *ChannelStore) EditChannelAdmin(ctx context.Context, req domain.EditChan
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if previous.Role == domain.ChannelRoleCreator {
|
if previous.Role == domain.ChannelRoleCreator {
|
||||||
|
if req.MemberID != req.UserID || channel.CreatorUserID != req.UserID || actor.Role != domain.ChannelRoleCreator {
|
||||||
return domain.EditChannelAdminResult{}, domain.ErrChannelUserCreator
|
return domain.EditChannelAdminResult{}, domain.ErrChannelUserCreator
|
||||||
}
|
}
|
||||||
member := previous
|
member := previous
|
||||||
|
member.Status = domain.ChannelMemberActive
|
||||||
|
member.LeftAt = 0
|
||||||
|
member.AdminRights = domain.CreatorProjectionAdminRights(req.AdminRights)
|
||||||
|
if req.HasRank() {
|
||||||
|
member.Rank = req.Rank
|
||||||
|
}
|
||||||
|
if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil {
|
||||||
|
return domain.EditChannelAdminResult{}, err
|
||||||
|
}
|
||||||
|
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
|
||||||
|
ChannelID: req.ChannelID,
|
||||||
|
UserID: req.UserID,
|
||||||
|
Date: req.Date,
|
||||||
|
Type: domain.ChannelAdminLogParticipantPromote,
|
||||||
|
PrevParticipant: &previous,
|
||||||
|
NewParticipant: &member,
|
||||||
|
}); err != nil {
|
||||||
|
return domain.EditChannelAdminResult{}, err
|
||||||
|
}
|
||||||
|
channel, err = refreshChannelCountsTx(ctx, tx, channel)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EditChannelAdminResult{}, err
|
||||||
|
}
|
||||||
|
event := transientChannelParticipantEvent(channel.ID, req.UserID, previous, member, req.Date)
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return domain.EditChannelAdminResult{}, fmt.Errorf("commit edit channel creator admin: %w", err)
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0)
|
||||||
|
recipients = append(recipients, req.MemberID)
|
||||||
|
return domain.EditChannelAdminResult{Channel: channel, Previous: previous, Participant: member, Event: event, Recipients: recipients, Date: req.Date}, nil
|
||||||
|
}
|
||||||
|
if actor.Role != domain.ChannelRoleCreator && !adminRightsSubset(req.AdminRights, actor.AdminRights) {
|
||||||
|
return domain.EditChannelAdminResult{}, domain.ErrChannelRightForbidden
|
||||||
|
}
|
||||||
|
member := previous
|
||||||
member.InviterUserID = req.UserID
|
member.InviterUserID = req.UserID
|
||||||
member.Status = domain.ChannelMemberActive
|
member.Status = domain.ChannelMemberActive
|
||||||
member.LeftAt = 0
|
member.LeftAt = 0
|
||||||
|
if req.HasRank() {
|
||||||
member.Rank = req.Rank
|
member.Rank = req.Rank
|
||||||
|
}
|
||||||
if previous.Status != domain.ChannelMemberActive {
|
if previous.Status != domain.ChannelMemberActive {
|
||||||
if minPts := channelInitialAvailableMinPts(channel); minPts > member.AvailableMinPts {
|
if minPts := channelInitialAvailableMinPts(channel); minPts > member.AvailableMinPts {
|
||||||
member.AvailableMinPts = minPts
|
member.AvailableMinPts = minPts
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue