fix: sync latest Android and langpack updates

This commit is contained in:
A 2026-07-02 14:09:24 +08:00
parent 20b388eeaa
commit 4570df5939
20 changed files with 12384 additions and 27 deletions

View file

@ -139,6 +139,10 @@ Recommended checks:
- Check server logs for no new `NOT_IMPLEMENTED`, `Unhandled RPC`, `bad_msg`,
panic, or internal errors.
## Contributors
- [ajarshia](https://github.com/ajarshia) - Android Persian (`fa`) language pack.
## Repository Layout
```text

View file

@ -121,6 +121,10 @@ Start-Process $tdesktop -ArgumentList @("-workdir", "$PWD\.tdata-bob")
- 同一账号多 session 登录,确认当前 session 不重复 echo其它在线 session 能收到 updates。
- 检查 server 日志没有新增 `NOT_IMPLEMENTED``Unhandled RPC``bad_msg`、panic 或 internal error。
## 贡献者
- [ajarshia](https://github.com/ajarshia) - Android Persian (`fa`) 语言包。
## 仓库结构
```text

File diff suppressed because it is too large Load diff

View file

@ -128,7 +128,7 @@ func (s *Service) sendServiceBotReply(ctx context.Context, botUserID, userID int
RecipientUserID: userID,
RandomID: s.botReplyRandomID(),
Message: reply.Text,
Entities: reply.Entities,
Entities: serviceBotReplyEntities(reply.Text, reply.Entities),
Date: int(s.now().Unix()),
RecipientBlocked: blocked,
}); err != nil {

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

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

View file

@ -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.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: domain.StickersBotUserID}
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,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.StickersBotUserID},
Limit: 100,
@ -44,9 +49,9 @@ func sendMessageToStickers(t *testing.T, svc *Service, messages *memory.MessageS
}
}
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) {
@ -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") {
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")
if reply := sendTextToStickers(t, svc, messages, owner, "/cancel"); !strings.Contains(reply, "Cancelled") {
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") {
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 {
t.Fatalf("created requests = %d, want 1", len(creator.created))
}
@ -454,6 +465,22 @@ func botCommandExists(commands []domain.BotCommand, want string) bool {
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 {
created []domain.CreateStickerSetRequest
sets []domain.StickerSet

View file

@ -96,3 +96,27 @@ func TestSeedDirectoryWalksClientSubdirs(t *testing.T) {
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")
}

View file

@ -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.
func NormalizeFullMegagroupAdminRights(ch Channel, rights ChannelAdminRights) ChannelAdminRights {
if ch.Megagroup && !ch.Broadcast &&
@ -1235,10 +1244,16 @@ type EditChannelAdminRequest struct {
ChannelID int64
MemberID int64
AdminRights ChannelAdminRights
RankSet bool
Rank string
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.
type EditChannelAdminResult struct {
Channel Channel

View file

@ -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 {
for _, participant := range participants {
for _, id := range channelParticipantUserRefs(participant) {

View file

@ -402,6 +402,7 @@ func (r *Router) onChannelsEditAdmin(ctx context.Context, req *tg.ChannelsEditAd
ChannelID: channelID,
MemberID: target.ID,
AdminRights: domainChannelAdminRights(req.AdminRights),
RankSet: req.Flags.Has(0),
Rank: req.Rank,
Date: int(r.clock.Now().Unix()),
})

View file

@ -780,10 +780,7 @@ func tgChatAdminRights(rights domain.ChannelAdminRights) tg.ChatAdminRights {
}
func creatorProjectionAdminRights(rights domain.ChannelAdminRights) domain.ChannelAdminRights {
creatorRights := domain.CreatorChannelAdminRights()
creatorRights.Anonymous = rights.Anonymous
creatorRights.ManageDirectMessages = rights.ManageDirectMessages
return creatorRights
return domain.CreatorProjectionAdminRights(rights)
}
func domainChannelAdminRights(rights tg.ChatAdminRights) domain.ChannelAdminRights {

View file

@ -91,8 +91,8 @@ func (r *Router) langpackLanguages(ctx context.Context, langPack string) []tg.La
if langPack == "" {
langPack = langPackFromClient(ctx)
}
_ = langPack
return []tg.LangPackLanguage{
langPack = strings.ToLower(langPack)
languages := []tg.LangPackLanguage{
{
Official: true,
Name: "English",
@ -114,6 +114,20 @@ func (r *Router) langpackLanguages(ctx context.Context, langPack string) []tg.La
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 {
@ -124,6 +138,12 @@ func langPackFromClient(ctx context.Context) string {
if 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)
if strings.Contains(client, "android") {
return "android"

View file

@ -19,7 +19,9 @@ func TestLangpackGetLanguagesCurrentAndLegacy(t *testing.T) {
if err := (&tg.LangpackGetLanguagesRequest{LangPack: "tdesktop"}).Encode(&in); err != nil {
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) {
@ -30,7 +32,17 @@ func TestLangpackGetLanguagesCurrentAndLegacy(t *testing.T) {
AppVersion: "12.7.3",
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) {
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()
enc, err := r.Dispatch(ctx, [8]byte{}, 0, in)
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 {
t.Fatalf("decode response: %v", err)
}
if len(langs.Elems) == 0 || langs.Elems[0].LangCode != "en" {
t.Fatalf("languages = %+v, want English entry", langs.Elems)
return 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)
}
}
}

View file

@ -552,7 +552,8 @@ func adminRightsSubset(want, have domain.ChannelAdminRights) bool {
(!want.AddAdmins || have.AddAdmins) &&
(!want.ManageCall || have.ManageCall) &&
(!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

View file

@ -282,9 +282,6 @@ func (s *ChannelStore) EditChannelAdmin(_ context.Context, req domain.EditChanne
if !canAddChannelAdmins(actor) {
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]
if !ok {
previous = domain.ChannelMember{
@ -300,7 +297,41 @@ func (s *ChannelStore) EditChannelAdmin(_ context.Context, req domain.EditChanne
}
}
if previous.Role == domain.ChannelRoleCreator {
return domain.EditChannelAdminResult{}, domain.ErrChannelUserCreator
if req.MemberID != req.UserID || channel.CreatorUserID != req.UserID || actor.Role != domain.ChannelRoleCreator {
return domain.EditChannelAdminResult{}, domain.ErrChannelUserCreator
}
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
@ -312,7 +343,9 @@ func (s *ChannelStore) EditChannelAdmin(_ context.Context, req domain.EditChanne
}
}
member.AdminRights = domain.NormalizeFullMegagroupAdminRights(channel, req.AdminRights)
member.Rank = req.Rank
if req.HasRank() {
member.Rank = req.Rank
}
if zeroChannelAdminRights(req.AdminRights) {
member.Role = domain.ChannelRoleMember
member.Rank = ""

View file

@ -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) {
ctx := context.Background()
store := NewChannelStore()

View file

@ -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) {
pool := testPool(t)
ctx := context.Background()

View file

@ -783,7 +783,8 @@ func adminRightsSubset(want, have domain.ChannelAdminRights) bool {
(!want.AddAdmins || have.AddAdmins) &&
(!want.ManageCall || have.ManageCall) &&
(!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

View file

@ -35,9 +35,6 @@ func (s *ChannelStore) EditChannelAdmin(ctx context.Context, req domain.EditChan
if !canAddChannelAdmins(actor) {
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)
if err != nil {
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 {
return domain.EditChannelAdminResult{}, domain.ErrChannelUserCreator
if req.MemberID != req.UserID || channel.CreatorUserID != req.UserID || actor.Role != domain.ChannelRoleCreator {
return domain.EditChannelAdminResult{}, domain.ErrChannelUserCreator
}
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.Status = domain.ChannelMemberActive
member.LeftAt = 0
member.Rank = req.Rank
if req.HasRank() {
member.Rank = req.Rank
}
if previous.Status != domain.ChannelMemberActive {
if minPts := channelInitialAvailableMinPts(channel); minPts > member.AvailableMinPts {
member.AvailableMinPts = minPts