feat: sync community aggregates

Sync telesrv 36eda30 (feat(communities): implement Layer 228 community aggregates).

Skipped telesrv docs changes per public sync rules; normalized the public appearance seed label.
This commit is contained in:
A 2026-07-20 16:46:02 +08:00
parent 7de1766941
commit da6a57e1a3
52 changed files with 5687 additions and 289 deletions

View file

@ -35,6 +35,12 @@ func (r *Router) notifyScopeFromInput(userID int64, in tg.InputNotifyPeerClass)
return domain.NotifyScope{Kind: domain.NotifyScopeChats}, true
case *tg.InputNotifyBroadcasts:
return domain.NotifyScope{Kind: domain.NotifyScopeBroadcasts}, true
case *tg.InputNotifyCommunity:
ref, ok := inputChannelRef(p.Community)
if !ok {
return domain.NotifyScope{}, false
}
return domain.NotifyScope{Kind: domain.NotifyScopePeer, Peer: domain.Peer{Type: domain.PeerTypeCommunity, ID: ref.ID}}, true
case *tg.InputNotifyPeer:
peer, ok := r.domainPeerFromInputPeer(userID, p.Peer)
if !ok {
@ -145,6 +151,7 @@ func (r *Router) onAccountGetNotifyExceptions(ctx context.Context, req *tg.Accou
updates := make([]tg.UpdateClass, 0, len(exceptions))
userIDs := make([]int64, 0)
channelIDs := make([]int64, 0)
communityIDs := make([]int64, 0)
for _, ex := range exceptions {
if filterPeer != nil && ex.Peer != *filterPeer {
continue
@ -163,15 +170,23 @@ func (r *Router) onAccountGetNotifyExceptions(ctx context.Context, req *tg.Accou
userIDs = append(userIDs, ex.Peer.ID)
case domain.PeerTypeChannel:
channelIDs = append(channelIDs, ex.Peer.ID)
case domain.PeerTypeCommunity:
communityIDs = append(communityIDs, ex.Peer.ID)
}
}
if len(updates) == 0 {
return empty, nil
}
chats := r.tgChatsForChannelIDs(ctx, userID, channelIDs)
if r.deps.Communities != nil && len(communityIDs) > 0 {
if views, err := r.deps.Communities.GetMany(ctx, userID, communityIDs); err == nil {
chats = appendUniqueTGChats(chats, tgCommunityChats(views)...)
}
}
return &tg.Updates{
Updates: updates,
Users: r.tgUsersForIDs(ctx, userID, userIDs),
Chats: r.tgChatsForChannelIDs(ctx, userID, channelIDs),
Chats: chats,
Date: int(r.clock.Now().Unix()),
}, nil
}
@ -260,6 +275,9 @@ func tgNotifyPeer(scope domain.NotifyScope) tg.NotifyPeerClass {
case domain.NotifyScopeBroadcasts:
return &tg.NotifyBroadcasts{}
case domain.NotifyScopePeer:
if scope.Peer.Type == domain.PeerTypeCommunity {
return &tg.NotifyCommunity{CommunityID: scope.Peer.ID}
}
peer := tgPeer(scope.Peer)
if scope.TopicID != 0 {
return &tg.NotifyForumTopic{Peer: peer, TopMsgID: scope.TopicID}
@ -274,7 +292,7 @@ func tgNotifyPeer(scope domain.NotifyScope) tg.NotifyPeerClass {
// 显示且跨重启恢复。perf:从 per-user notify 缓存读取(命中即 0 PG),而非每次 getDialogs
// 都查 notify_settings——绝大多数用户没有任何自定义静音,缓存命中后零数据库开销。
func (r *Router) withDialogNotifySettings(ctx context.Context, viewerUserID int64, list domain.DialogList) domain.DialogList {
if len(list.Dialogs) == 0 {
if len(list.Dialogs) == 0 && len(list.Communities) == 0 {
return list
}
settings := r.userNotifySettings(ctx, viewerUserID)
@ -287,6 +305,13 @@ func (r *Router) withDialogNotifySettings(ctx context.Context, viewerUserID int6
list.Dialogs[i].NotifySettings = &sc
}
}
for i := range list.Communities {
peer := domain.Peer{Type: domain.PeerTypeCommunity, ID: list.Communities[i].Community.ID}
if s, ok := settings[peer]; ok {
sc := s.Clone()
list.Communities[i].State.NotifySettings = &sc
}
}
return list
}

View file

@ -2,9 +2,12 @@ package rpc
import (
"context"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
"errors"
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
func (r *Router) onChannelsCreateChannel(ctx context.Context, req *tg.ChannelsCreateChannelRequest) (tg.UpdatesClass, error) {
@ -71,37 +74,54 @@ func (r *Router) onChannelsGetChannels(ctx context.Context, ids []tg.InputChanne
channelIDs := make([]int64, 0, len(ids))
for _, input := range ids {
ref, ok := inputChannelRef(input)
if !ok || ref.ID == 0 || r.deps.Channels == nil {
if !ok || ref.ID == 0 {
continue
}
refs = append(refs, ref)
channelIDs = append(channelIDs, ref.ID)
}
if len(channelIDs) == 0 || r.deps.Channels == nil {
if len(channelIDs) == 0 || (r.deps.Channels == nil && r.deps.Communities == nil) {
return &tg.MessagesChats{}, nil
}
views, err := r.deps.Channels.GetChannels(ctx, userID, channelIDs)
if err != nil {
return nil, internalErr()
var views []domain.ChannelView
if r.deps.Channels != nil {
views, err = r.deps.Channels.GetChannels(ctx, userID, channelIDs)
if err != nil {
return nil, internalErr()
}
}
byID := make(map[int64]domain.ChannelView, len(views))
for _, view := range views {
byID[view.Channel.ID] = view
}
communityByID := make(map[int64]domain.CommunityView)
if r.deps.Communities != nil {
communityViews, err := r.deps.Communities.GetMany(ctx, userID, channelIDs)
if err != nil {
return nil, internalErr()
}
for _, view := range communityViews {
communityByID[view.Community.ID] = view
}
}
chats := make([]tg.ChatClass, 0, len(refs))
for _, ref := range refs {
view, ok := byID[ref.ID]
if !ok || !inputChannelAccessHashMatches(ref, view.Channel) {
if view, ok := communityByID[ref.ID]; ok {
if !ref.CheckAccessHash || ref.AccessHash == view.Community.AccessHash {
chats = append(chats, tgCommunityChat(view))
}
continue
}
chats = append(chats, tgChannelChatForView(userID, view))
if view, ok := byID[ref.ID]; ok && inputChannelAccessHashMatches(ref, view.Channel) {
chats = append(chats, tgChannelChatForView(userID, view))
}
}
r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats)
return &tg.MessagesChats{Chats: chats}, nil
}
func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputChannelClass) (*tg.MessagesChatFull, error) {
if r.deps.Channels == nil {
if r.deps.Channels == nil && r.deps.Communities == nil {
return &tg.MessagesChatFull{}, nil
}
userID, _, err := r.currentUserID(ctx)
@ -112,6 +132,34 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
if !ok {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
if r.deps.Communities != nil {
view, communityErrValue := r.deps.Communities.Get(ctx, userID, ref.ID)
if communityErrValue == nil {
if ref.CheckAccessHash && ref.AccessHash != view.Community.AccessHash {
return nil, channelInvalidErr(domain.ErrCommunityPrivate)
}
if settings := r.userNotifySettings(ctx, userID); len(settings) > 0 {
if setting, ok := settings[domain.Peer{Type: domain.PeerTypeCommunity, ID: view.Community.ID}]; ok {
copy := setting.Clone()
view.State.NotifySettings = &copy
}
}
return &tg.MessagesChatFull{
FullChat: tgCommunityFull(view),
Chats: tgCommunityHydratedChats(userID, view),
Users: tgUsers(view.Users),
}, nil
}
if errors.Is(communityErrValue, domain.ErrCommunityPrivate) {
return nil, communityErr(communityErrValue)
}
if !errors.Is(communityErrValue, domain.ErrCommunityInvalid) {
return nil, communityErr(communityErrValue)
}
}
if r.deps.Channels == nil {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
loadEpoch := r.channelFullProjectionCache.LoadEpoch()
if cached, ok := r.channelFullProjectionCache.Lookup(userID, ref.ID); ok {
if !inputChannelAccessHashMatches(ref, domain.Channel{ID: ref.ID, AccessHash: cached.accessHash}) {

View file

@ -227,7 +227,7 @@ func (r *Router) onMessagesEditChatAdmin(ctx context.Context, req *tg.MessagesEd
}
func (r *Router) onMessagesEditChatAbout(ctx context.Context, req *tg.MessagesEditChatAboutRequest) (bool, error) {
if r.deps.Channels == nil {
if r.deps.Channels == nil && r.deps.Communities == nil {
return false, notImplementedErr()
}
if utf8.RuneCountInString(req.About) > maxChannelAboutLength {
@ -237,6 +237,22 @@ func (r *Router) onMessagesEditChatAbout(ctx context.Context, req *tg.MessagesEd
if err != nil {
return false, internalErr()
}
if community, ok, err := r.maybeCommunityFromInputPeer(ctx, userID, req.Peer); ok {
if err != nil {
return false, err
}
view, changed, err := r.deps.Communities.EditAbout(ctx, userID, community.Community.ID, req.About)
if err != nil {
return false, communityErr(err)
}
if changed {
r.pushCommunityState(ctx, userID, view)
}
return true, nil
}
if r.deps.Channels == nil {
return false, channelInvalidErr(domain.ErrChannelInvalid)
}
channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer)
if err != nil {
return false, err
@ -255,13 +271,26 @@ func (r *Router) onMessagesEditChatAbout(ctx context.Context, req *tg.MessagesEd
}
func (r *Router) onMessagesEditChatDefaultBannedRights(ctx context.Context, req *tg.MessagesEditChatDefaultBannedRightsRequest) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
if r.deps.Channels == nil && r.deps.Communities == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if community, ok, err := r.maybeCommunityFromInputPeer(ctx, userID, req.Peer); ok {
if err != nil {
return nil, err
}
view, changed, err := r.deps.Communities.EditDefaultBannedRights(ctx, userID, community.Community.ID, domainChannelBannedRights(req.BannedRights))
if err != nil {
return nil, communityErr(err)
}
return r.communityMutationUpdates(ctx, userID, view, changed), nil
}
if r.deps.Channels == nil {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err

View file

@ -23,6 +23,13 @@ func (r *Router) onChannelsGetAdminedPublicChannels(ctx context.Context, req *tg
if req.ByLocation {
return &tg.MessagesChats{}, nil
}
if req.ForCommunityPeer {
channels, err := r.deps.Channels.ListCommunityLinkableChannels(ctx, userID)
if err != nil {
return nil, internalErr()
}
return &tg.MessagesChats{Chats: tgChannels(userID, channels)}, nil
}
channels, err := r.deps.Channels.ListAdminedPublicChannels(ctx, userID)
if err != nil {
return nil, internalErr()
@ -107,7 +114,7 @@ func (r *Router) onChannelsGetMessageAuthor(ctx context.Context, req *tg.Channel
}
func (r *Router) onChannelsGetParticipants(ctx context.Context, req *tg.ChannelsGetParticipantsRequest) (tg.ChannelsChannelParticipantsClass, error) {
if r.deps.Channels == nil {
if r.deps.Channels == nil && r.deps.Communities == nil {
return &tg.ChannelsChannelParticipants{}, nil
}
userID, _, err := r.currentUserID(ctx)
@ -122,6 +129,26 @@ func (r *Router) onChannelsGetParticipants(ctx context.Context, req *tg.Channels
if utf8.RuneCountInString(filter.Query) > domain.MaxChannelParticipantsQueryLength {
return nil, limitInvalidErr()
}
if community, isCommunity, err := r.maybeCommunityFromInput(ctx, userID, req.Channel); isCommunity {
if err != nil {
return nil, err
}
list, err := r.deps.Communities.Participants(ctx, userID, community.Community.ID, filter, req.Offset, req.Limit)
if err != nil {
return nil, communityErr(err)
}
if req.Hash != 0 && list.Hash == req.Hash {
return &tg.ChannelsChannelParticipantsNotModified{}, nil
}
participants := make([]tg.ChannelParticipantClass, 0, len(list.Participants))
for _, member := range list.Participants {
participants = append(participants, tgCommunityMember(userID, member))
}
return &tg.ChannelsChannelParticipants{Count: list.Count, Participants: participants, Chats: []tg.ChatClass{tgCommunityChat(community)}, Users: tgUsers(list.Users)}, nil
}
if r.deps.Channels == nil {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
list, err := r.deps.Channels.GetParticipants(ctx, userID, ref.ID, filter, req.Offset, req.Limit)
if err != nil {
return nil, channelInvalidErr(err)
@ -390,17 +417,13 @@ func (r *Router) recordChannelStateForUser(ctx context.Context, userID, channelI
}
func (r *Router) onChannelsEditAdmin(ctx context.Context, req *tg.ChannelsEditAdminRequest) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
if r.deps.Channels == nil && r.deps.Communities == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return nil, err
}
target, found, err := r.userFromInput(ctx, userID, req.UserID)
if err != nil {
return nil, internalErr()
@ -408,6 +431,33 @@ func (r *Router) onChannelsEditAdmin(ctx context.Context, req *tg.ChannelsEditAd
if !found || target.ID == 0 {
return nil, peerIDInvalidErr()
}
if community, ok, err := r.maybeCommunityFromInput(ctx, userID, req.Channel); ok {
if err != nil {
return nil, err
}
view, changed, err := r.deps.Communities.EditAdmin(ctx, userID, domain.CommunityEditAdminRequest{
CommunityID: community.Community.ID,
UserID: target.ID,
Rights: domainChannelAdminRights(req.AdminRights),
Rank: req.Rank,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, communityErr(err)
}
updates := r.communityMutationUpdates(ctx, userID, view, changed)
if changed && target.ID != userID {
r.refreshAndPushCommunityState(ctx, target.ID, community.Community.ID, community.Community)
}
return updates, nil
}
if r.deps.Channels == nil {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return nil, err
}
res, err := r.deps.Channels.EditAdmin(ctx, userID, domain.EditChannelAdminRequest{
UserID: userID,
ChannelID: channelID,

View file

@ -309,7 +309,7 @@ func (r *Router) onChannelsToggleAutotranslation(ctx context.Context, req *tg.Ch
}
func (r *Router) onChannelsEditTitle(ctx context.Context, req *tg.ChannelsEditTitleRequest) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
if r.deps.Channels == nil && r.deps.Communities == nil {
return nil, notImplementedErr()
}
if !validChannelTitle(req.Title) {
@ -319,6 +319,19 @@ func (r *Router) onChannelsEditTitle(ctx context.Context, req *tg.ChannelsEditTi
if err != nil {
return nil, internalErr()
}
if community, ok, err := r.maybeCommunityFromInput(ctx, userID, req.Channel); ok {
if err != nil {
return nil, err
}
view, changed, err := r.deps.Communities.EditTitle(ctx, userID, community.Community.ID, req.Title)
if err != nil {
return nil, communityErr(err)
}
return r.communityMutationUpdates(ctx, userID, view, changed), nil
}
if r.deps.Channels == nil {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return nil, err
@ -354,7 +367,7 @@ func (r *Router) onChannelsEditTitle(ctx context.Context, req *tg.ChannelsEditTi
}
func (r *Router) onChannelsEditPhoto(ctx context.Context, req *tg.ChannelsEditPhotoRequest) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
if r.deps.Channels == nil && r.deps.Communities == nil {
return nil, notImplementedErr()
}
if req.Photo == nil {
@ -364,11 +377,24 @@ func (r *Router) onChannelsEditPhoto(ctx context.Context, req *tg.ChannelsEditPh
if err != nil {
return nil, internalErr()
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
photo, err := r.resolveInputChatPhoto(ctx, userID, req.Photo)
if err != nil {
return nil, err
}
photo, err := r.resolveInputChatPhoto(ctx, userID, req.Photo)
if community, ok, err := r.maybeCommunityFromInput(ctx, userID, req.Channel); ok {
if err != nil {
return nil, err
}
view, changed, err := r.deps.Communities.SetPhoto(ctx, userID, community.Community.ID, photo, int(r.clock.Now().Unix()))
if err != nil {
return nil, communityErr(err)
}
return r.communityMutationUpdates(ctx, userID, view, changed), nil
}
if r.deps.Channels == nil {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
channelID, err := r.channelIDFromInput(ctx, userID, req.Channel)
if err != nil {
return nil, err
}

View file

@ -61,13 +61,29 @@ func (r *Router) onChannelsSetMainProfileTab(ctx context.Context, req *tg.Channe
}
func (r *Router) onChannelsDeleteChannel(ctx context.Context, input tg.InputChannelClass) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
if r.deps.Channels == nil && r.deps.Communities == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if community, ok, err := r.maybeCommunityFromInput(ctx, userID, input); ok {
if err != nil {
return nil, err
}
view, _, err := r.deps.Communities.Delete(ctx, userID, community.Community.ID, int(r.clock.Now().Unix()))
if err != nil {
return nil, communityErr(err)
}
for _, serviceMessage := range view.ServiceMessages {
r.enqueueChannelMessageFanout(ctx, userID, serviceMessage, nil)
}
return r.communityMutationUpdates(ctx, userID, view, true), nil
}
if r.deps.Channels == nil {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
channelID, err := r.channelIDFromInput(ctx, userID, input)
if err != nil {
return nil, err

467
internal/rpc/communities.go Normal file
View file

@ -0,0 +1,467 @@
package rpc
import (
"context"
"errors"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
func communityErr(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, domain.ErrCommunityPrivate):
return tgerr400("CHANNEL_PRIVATE")
case errors.Is(err, domain.ErrCommunityAdminRequired):
return tgerr400("CHAT_ADMIN_REQUIRED")
case errors.Is(err, domain.ErrCommunityCreatorRequired):
return tgerr400("CHAT_ADMIN_REQUIRED")
case errors.Is(err, domain.ErrCommunityPeersTooMuch):
return tgerr400("COMMUNITY_PEERS_TOO_MUCH")
case errors.Is(err, domain.ErrCommunityRequestCreated):
return tgerr400("COMMUNITY_REQUEST_CREATED")
case errors.Is(err, domain.ErrCommunityRequestMissing):
return tgerr400("COMMUNITY_REQUEST_MISSING")
case errors.Is(err, domain.ErrCommunityPeerLinked):
return tgerr400("COMMUNITY_PEER_ALREADY_LINKED")
case errors.Is(err, domain.ErrCommunityPeerInvalid), errors.Is(err, domain.ErrCommunityParticipantInvalid):
return peerIDInvalidErr()
case errors.Is(err, domain.ErrChannelTitleInvalid):
return tgerr400("CHAT_TITLE_EMPTY")
case errors.Is(err, domain.ErrAboutTooLong):
return aboutTooLongErr()
case errors.Is(err, domain.ErrCommunityInvalid):
return channelInvalidErr(err)
default:
return internalErr()
}
}
func (r *Router) communityFromInput(ctx context.Context, userID int64, input tg.InputChannelClass) (domain.CommunityView, error) {
if r.deps.Communities == nil {
return domain.CommunityView{}, notImplementedErr()
}
ref, ok := inputChannelRef(input)
if !ok || ref.ID == 0 {
return domain.CommunityView{}, channelInvalidErr(domain.ErrCommunityInvalid)
}
view, err := r.deps.Communities.Get(ctx, userID, ref.ID)
if err != nil {
return domain.CommunityView{}, communityErr(err)
}
if ref.CheckAccessHash && ref.AccessHash != view.Community.AccessHash {
return domain.CommunityView{}, communityErr(domain.ErrCommunityPrivate)
}
return view, nil
}
// maybeCommunityFromInput distinguishes a Community from an ordinary channel.
// IDs share one allocator, so ErrCommunityInvalid is the only fallthrough case.
func (r *Router) maybeCommunityFromInput(ctx context.Context, userID int64, input tg.InputChannelClass) (domain.CommunityView, bool, error) {
if r.deps.Communities == nil {
return domain.CommunityView{}, false, nil
}
ref, ok := inputChannelRef(input)
if !ok || ref.ID == 0 {
return domain.CommunityView{}, false, nil
}
view, err := r.deps.Communities.Get(ctx, userID, ref.ID)
if errors.Is(err, domain.ErrCommunityInvalid) {
return domain.CommunityView{}, false, nil
}
if err != nil {
return domain.CommunityView{}, true, communityErr(err)
}
if ref.CheckAccessHash && ref.AccessHash != view.Community.AccessHash {
return domain.CommunityView{}, true, communityErr(domain.ErrCommunityPrivate)
}
return view, true, nil
}
func (r *Router) maybeCommunityFromInputPeer(ctx context.Context, userID int64, peer tg.InputPeerClass) (domain.CommunityView, bool, error) {
ref, ok := inputPeerChannelRef(peer)
if !ok {
return domain.CommunityView{}, false, nil
}
input := &tg.InputChannel{ChannelID: ref.ID, AccessHash: ref.AccessHash}
return r.maybeCommunityFromInput(ctx, userID, input)
}
func (r *Router) communityPeerFromInput(ctx context.Context, userID int64, input tg.InputPeerClass) (domain.Peer, error) {
peer, ok := r.domainPeerFromInputPeer(userID, input)
if peer.ID == 0 || (peer.Type != domain.PeerTypeChannel && peer.Type != domain.PeerTypeUser) {
return domain.Peer{}, peerIDInvalidErr()
}
if !ok {
return domain.Peer{}, peerIDInvalidErr()
}
if peer.Type == domain.PeerTypeChannel {
ref, ok := inputPeerChannelRef(input)
if !ok || r.deps.Channels == nil {
return domain.Peer{}, peerIDInvalidErr()
}
// A Community admin may approve or unlink a channel without being a
// member of that channel. Resolve the immutable base row for constructor
// and access_hash validation; aggregate authorization remains in the
// Community transaction (direct links still require channel admin rights,
// approvals require an existing validated request).
if resolver, ok := r.deps.Channels.(interface {
GetChannelByID(context.Context, int64) (domain.Channel, error)
}); ok {
channel, err := resolver.GetChannelByID(ctx, peer.ID)
if err != nil || channel.ID == 0 || channel.Deleted {
return domain.Peer{}, peerIDInvalidErr()
}
if ref.CheckAccessHash && !inputChannelAccessHashMatches(ref, channel) {
return domain.Peer{}, channelInvalidErr(domain.ErrChannelPrivate)
}
} else if err := r.validateInputPeerChannelAccess(ctx, userID, input, peer.ID); err != nil {
return domain.Peer{}, err
}
}
return peer, nil
}
func (r *Router) communityUpdates(view domain.CommunityView) *tg.Updates {
return &tg.Updates{Updates: []tg.UpdateClass{}, Users: tgUsers(view.Users), Chats: tgCommunityHydratedChats(view.Self.UserID, view), Date: int(r.clock.Now().Unix())}
}
func (r *Router) pushCommunityState(ctx context.Context, userID int64, view domain.CommunityView) {
r.pushUserUpdates(ctx, userID, r.communityUpdates(view))
}
func (r *Router) refreshAndPushCommunityState(ctx context.Context, viewerUserID, communityID int64, fallback domain.Community) {
if viewerUserID == 0 || r.deps.Communities == nil {
return
}
view, err := r.deps.Communities.Get(ctx, viewerUserID, communityID)
if err == nil {
r.pushCommunityState(ctx, viewerUserID, view)
return
}
if errors.Is(err, domain.ErrCommunityPrivate) {
r.pushCommunityState(ctx, viewerUserID, domain.CommunityView{
Community: fallback,
Self: domain.CommunityMember{CommunityID: communityID, UserID: viewerUserID},
Forbidden: true,
})
}
}
func (r *Router) communityMutationUpdates(ctx context.Context, userID int64, view domain.CommunityView, changed bool) *tg.Updates {
out := r.communityUpdates(view)
if changed {
r.pushCommunityState(ctx, userID, view)
}
return out
}
func (r *Router) withCommunityDialogList(ctx context.Context, userID int64, filter domain.DialogFilter, list domain.DialogList) (domain.DialogList, error) {
if r.deps.Communities == nil || (filter.HasFolderID && filter.FolderID != domain.DialogMainFolderID) {
return list, nil
}
views, err := r.deps.Communities.ListJoined(ctx, userID)
if err != nil {
return domain.DialogList{}, err
}
for _, view := range views {
if !view.State.Collapsed || (filter.PinnedOnly && !view.State.Pinned) || (filter.ExcludePinned && view.State.Pinned) {
continue
}
list.Communities = append(list.Communities, view)
list.Count++
}
return list, nil
}
func (r *Router) communityDialogPeerFromInput(ctx context.Context, userID int64, input tg.InputDialogPeerClass) (domain.CommunityView, bool, error) {
peer, ok := input.(*tg.InputDialogPeerCommunity)
if !ok || peer == nil || peer.Community == nil {
return domain.CommunityView{}, false, nil
}
view, err := r.communityFromInput(ctx, userID, peer.Community)
return view, true, err
}
func (r *Router) onCommunitiesCreate(ctx context.Context, req *tg.CommunitiesCreateRequest) (tg.UpdatesClass, error) {
if req == nil || r.deps.Communities == nil {
return nil, notImplementedErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
peer, err := r.communityPeerFromInput(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
visibility := domain.CommunityPeerVisible
if req.Hidden {
visibility = domain.CommunityPeerHidden
}
view, err := r.deps.Communities.Create(ctx, userID, domain.CreateCommunityRequest{Title: req.Title, About: req.About, InitialPeer: peer, Visibility: visibility, Date: int(r.clock.Now().Unix())})
if err != nil {
return nil, communityErr(err)
}
for _, serviceMessage := range view.ServiceMessages {
r.enqueueChannelMessageFanout(ctx, userID, serviceMessage, nil)
}
return r.communityUpdates(view), nil
}
func (r *Router) emitCommunityLinkService(ctx context.Context, actorUserID int64, result domain.CommunityTogglePeerLinkResult) {
if result.ServiceMessage == nil {
return
}
r.enqueueChannelMessageFanout(ctx, actorUserID, *result.ServiceMessage, nil)
}
func (r *Router) onCommunitiesTogglePeerLink(ctx context.Context, req *tg.CommunitiesTogglePeerLinkRequest) (bool, error) {
if req == nil || r.deps.Communities == nil {
return false, notImplementedErr()
}
actions := 0
if req.Visible {
actions++
}
if req.Hidden {
actions++
}
if req.Deleted {
actions++
}
if actions != 1 {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
view, err := r.communityFromInput(ctx, userID, req.Community)
if err != nil {
return false, err
}
peer, err := r.communityPeerFromInput(ctx, userID, req.Peer)
if err != nil {
return false, err
}
visibility := domain.CommunityPeerVisible
if req.Hidden {
visibility = domain.CommunityPeerHidden
}
result, err := r.deps.Communities.TogglePeerLink(ctx, userID, domain.CommunityTogglePeerLinkRequest{CommunityID: view.Community.ID, Peer: peer, Visibility: visibility, Deleted: req.Deleted, Date: int(r.clock.Now().Unix())})
if err != nil {
return false, communityErr(err)
}
if result.RequestCreated {
return false, tgerr400("COMMUNITY_REQUEST_CREATED")
}
r.emitCommunityLinkService(ctx, userID, result)
r.refreshAndPushCommunityState(ctx, userID, view.Community.ID, result.Community)
return true, nil
}
func (r *Router) onCommunitiesGetJoined(ctx context.Context) (tg.MessagesChatsClass, error) {
if r.deps.Communities == nil {
return &tg.MessagesChats{}, nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
views, err := r.deps.Communities.ListJoined(ctx, userID)
if err != nil {
return nil, communityErr(err)
}
return &tg.MessagesChats{Chats: tgCommunityChats(views)}, nil
}
func (r *Router) onCommunitiesToggleCollapsed(ctx context.Context, req *tg.CommunitiesToggleCommunityCollapsedInDialogsRequest) (tg.UpdatesClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
view, err := r.communityFromInput(ctx, userID, req.Community)
if err != nil {
return nil, err
}
wasPinned := view.State.Pinned
view, changed, err := r.deps.Communities.SetCollapsed(ctx, userID, view.Community.ID, req.Collapsed)
if err != nil {
return nil, communityErr(err)
}
out := r.communityUpdates(view)
if changed && !req.Collapsed && wasPinned {
out.Updates = append(out.Updates, &tg.UpdateDialogPinned{Peer: &tg.DialogPeerCommunity{CommunityID: view.Community.ID}})
}
if changed {
r.pushCommunityState(ctx, userID, view)
}
return out, nil
}
func (r *Router) onCommunitiesGetPeerLinkRequests(ctx context.Context, req *tg.CommunitiesGetPeerLinkRequestsRequest) (*tg.CommunitiesPeerLinkRequests, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
view, err := r.communityFromInput(ctx, userID, req.Community)
if err != nil {
return nil, err
}
page, err := r.deps.Communities.ListPeerLinkRequests(ctx, userID, view.Community.ID, req.Offset, req.Limit)
if err != nil {
return nil, communityErr(err)
}
requests := make([]tg.CommunityPeerRequest, 0, len(page.Requests))
for _, item := range page.Requests {
requests = append(requests, tg.CommunityPeerRequest{Visible: item.Visibility == domain.CommunityPeerVisible, Peer: tgPeer(item.Peer), RequestedBy: item.RequestedBy, Date: item.Date})
}
out := &tg.CommunitiesPeerLinkRequests{TotalCount: page.TotalCount, Requests: requests, Chats: tgChannels(userID, page.Channels), Users: tgUsers(page.Users)}
if page.NextOffset != "" {
out.SetNextOffset(page.NextOffset)
}
return out, nil
}
func (r *Router) onCommunitiesTogglePeerLinkRequestApproval(ctx context.Context, req *tg.CommunitiesTogglePeerLinkRequestApprovalRequest) (bool, error) {
if req == nil {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
view, err := r.communityFromInput(ctx, userID, req.Community)
if err != nil {
return false, err
}
peer, err := r.communityPeerFromInput(ctx, userID, req.Peer)
if err != nil {
return false, err
}
result, err := r.deps.Communities.DecidePeerLinkRequest(ctx, userID, view.Community.ID, peer, req.Reject, int(r.clock.Now().Unix()))
if err != nil {
return false, communityErr(err)
}
if !req.Reject {
r.emitCommunityLinkService(ctx, userID, result)
r.refreshAndPushCommunityState(ctx, userID, view.Community.ID, result.Community)
if result.RequestedBy != userID {
r.refreshAndPushCommunityState(ctx, result.RequestedBy, view.Community.ID, result.Community)
}
}
return true, nil
}
func (r *Router) onCommunitiesToggleAllPeerLinkRequestApproval(ctx context.Context, req *tg.CommunitiesToggleAllPeerLinkRequestApprovalRequest) (bool, error) {
if req == nil {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
view, err := r.communityFromInput(ctx, userID, req.Community)
if err != nil {
return false, err
}
results, err := r.deps.Communities.DecideAllPeerLinkRequests(ctx, userID, view.Community.ID, req.Reject, int(r.clock.Now().Unix()))
if err != nil {
return false, communityErr(err)
}
if !req.Reject {
requesters := map[int64]struct{}{}
for _, result := range results {
r.emitCommunityLinkService(ctx, userID, result)
if result.RequestedBy != 0 && result.RequestedBy != userID {
requesters[result.RequestedBy] = struct{}{}
}
}
if len(results) > 0 {
r.refreshAndPushCommunityState(ctx, userID, view.Community.ID, results[0].Community)
for requester := range requesters {
r.refreshAndPushCommunityState(ctx, requester, view.Community.ID, results[0].Community)
}
}
}
return true, nil
}
func (r *Router) onCommunitiesToggleParticipantBanned(ctx context.Context, req *tg.CommunitiesToggleParticipantBannedRequest) (bool, error) {
if req == nil {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
view, err := r.communityFromInput(ctx, userID, req.Community)
if err != nil {
return false, err
}
peer, err := r.communityPeerFromInput(ctx, userID, req.Participant)
if err != nil || peer.Type != domain.PeerTypeUser {
return false, peerIDInvalidErr()
}
result, err := r.deps.Communities.ToggleParticipantBanned(ctx, userID, view.Community.ID, peer.ID, req.Unban, int(r.clock.Now().Unix()))
if err != nil {
return false, communityErr(err)
}
for _, removed := range result.RemovedLinks {
r.emitCommunityLinkService(ctx, userID, removed)
}
for _, ban := range result.ChannelBans {
r.invalidateChannelFullBotInfoCacheForChannel(ban.Channel.ID)
r.removeOnlineChannelMemberships(ban.Channel.ID, peer.ID)
r.recordChannelStateForUser(ctx, peer.ID, ban.Channel.ID, false)
cache := newViewerPeerCache(r)
build := func(viewerUserID int64) *tg.Updates {
updates := r.channelParticipantUpdatesWithPeerCache(ctx, viewerUserID, userID, ban.Channel, ban.Previous, ban.Participant, ban.Date, cache)
if updates != nil && ban.ServiceEvent.Pts != 0 {
if update := tgChannelUpdate(viewerUserID, ban.ServiceEvent); update != nil {
updates.Updates = append([]tg.UpdateClass{update}, updates.Updates...)
}
}
return updates
}
r.pushChannelUpdates(ctx, userID, ban.Channel.ID, ban.Recipients, build)
}
if result.Changed && !req.Unban {
forbidden := domain.CommunityView{Community: view.Community, Forbidden: true, Self: domain.CommunityMember{UserID: peer.ID}}
r.pushUserUpdates(ctx, peer.ID, r.communityUpdates(forbidden))
}
return true, nil
}
func (r *Router) onCommunitiesGetParticipantJoinedChats(ctx context.Context, req *tg.CommunitiesGetParticipantJoinedChatsRequest) (*tg.CommunitiesParticipantJoinedChats, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
view, err := r.communityFromInput(ctx, userID, req.Community)
if err != nil {
return nil, err
}
peer, err := r.communityPeerFromInput(ctx, userID, req.Participant)
if err != nil || peer.Type != domain.PeerTypeUser {
return nil, peerIDInvalidErr()
}
joined, err := r.deps.Communities.ParticipantJoinedChats(ctx, userID, view.Community.ID, peer.ID)
if err != nil {
return nil, communityErr(err)
}
return &tg.CommunitiesParticipantJoinedChats{CreatorChatIDs: joined.CreatorChatIDs, JoinedChatIDs: joined.JoinedChatIDs, Chats: tgChannels(userID, joined.Channels), Users: tgUsers(joined.Users)}, nil
}

View file

@ -0,0 +1,38 @@
package rpc
import (
"context"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
)
func (r *Router) registerCommunities(d *tlprofile.Dispatcher) {
registerRPC[*tg.CommunitiesCreateRequest](d, tlprofile.SemanticMethodCommunitiesCreate, func(ctx context.Context, req *tg.CommunitiesCreateRequest) (any, error) {
return r.onCommunitiesCreate(ctx, req)
})
registerRPC[*tg.CommunitiesTogglePeerLinkRequest](d, tlprofile.SemanticMethodCommunitiesTogglePeerLink, func(ctx context.Context, req *tg.CommunitiesTogglePeerLinkRequest) (any, error) {
return r.onCommunitiesTogglePeerLink(ctx, req)
})
registerRPC[*tg.CommunitiesGetJoinedCommunitiesRequest](d, tlprofile.SemanticMethodCommunitiesGetJoinedCommunities, func(ctx context.Context, req *tg.CommunitiesGetJoinedCommunitiesRequest) (any, error) {
return r.onCommunitiesGetJoined(ctx)
})
registerRPC[*tg.CommunitiesToggleCommunityCollapsedInDialogsRequest](d, tlprofile.SemanticMethodCommunitiesToggleCommunityCollapsedInDialogs, func(ctx context.Context, req *tg.CommunitiesToggleCommunityCollapsedInDialogsRequest) (any, error) {
return r.onCommunitiesToggleCollapsed(ctx, req)
})
registerRPC[*tg.CommunitiesGetPeerLinkRequestsRequest](d, tlprofile.SemanticMethodCommunitiesGetPeerLinkRequests, func(ctx context.Context, req *tg.CommunitiesGetPeerLinkRequestsRequest) (any, error) {
return r.onCommunitiesGetPeerLinkRequests(ctx, req)
})
registerRPC[*tg.CommunitiesTogglePeerLinkRequestApprovalRequest](d, tlprofile.SemanticMethodCommunitiesTogglePeerLinkRequestApproval, func(ctx context.Context, req *tg.CommunitiesTogglePeerLinkRequestApprovalRequest) (any, error) {
return r.onCommunitiesTogglePeerLinkRequestApproval(ctx, req)
})
registerRPC[*tg.CommunitiesToggleAllPeerLinkRequestApprovalRequest](d, tlprofile.SemanticMethodCommunitiesToggleAllPeerLinkRequestApproval, func(ctx context.Context, req *tg.CommunitiesToggleAllPeerLinkRequestApprovalRequest) (any, error) {
return r.onCommunitiesToggleAllPeerLinkRequestApproval(ctx, req)
})
registerRPC[*tg.CommunitiesToggleParticipantBannedRequest](d, tlprofile.SemanticMethodCommunitiesToggleParticipantBanned, func(ctx context.Context, req *tg.CommunitiesToggleParticipantBannedRequest) (any, error) {
return r.onCommunitiesToggleParticipantBanned(ctx, req)
})
registerRPC[*tg.CommunitiesGetParticipantJoinedChatsRequest](d, tlprofile.SemanticMethodCommunitiesGetParticipantJoinedChats, func(ctx context.Context, req *tg.CommunitiesGetParticipantJoinedChatsRequest) (any, error) {
return r.onCommunitiesGetParticipantJoinedChats(ctx, req)
})
}

View file

@ -0,0 +1,315 @@
package rpc
import (
"context"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appcommunities "telesrv/internal/app/communities"
appdialogs "telesrv/internal/app/dialogs"
appstories "telesrv/internal/app/stories"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func communityRPCChannel(t *testing.T, service *appchannels.Service, creator domain.User, title string, members ...domain.User) domain.Channel {
t.Helper()
memberIDs := make([]int64, 0, len(members))
for _, member := range members {
memberIDs = append(memberIDs, member.ID)
}
created, err := service.CreateChannel(context.Background(), creator.ID, domain.CreateChannelRequest{
CreatorUserID: creator.ID,
Title: title,
Megagroup: true,
MemberUserIDs: memberIDs,
Date: 1_800_100_000,
})
if err != nil {
t.Fatalf("create channel %q: %v", title, err)
}
return created.Channel
}
func TestCommunityDialogsSharePinnedLimit(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
owner, err := users.Create(ctx, domain.User{AccessHash: 711, Phone: "15552000011", FirstName: "Pin Owner"})
if err != nil {
t.Fatal(err)
}
channels := memory.NewChannelStore()
channelService := appchannels.NewService(channels)
communityService := appcommunities.NewService(memory.NewCommunityStore(users, channels, nil, nil))
r := New(Config{}, Deps{
Users: appusers.NewService(users), Channels: channelService,
Communities: communityService, Dialogs: appdialogs.NewService(memory.NewDialogStore(), channels),
}, zaptest.NewLogger(t), clock.System)
inputs := make([]*tg.InputChannel, 0, domain.MaxPinnedDialogsMainFolder)
for i := 0; i < domain.MaxPinnedDialogsMainFolder; i++ {
channel := communityRPCChannel(t, channelService, owner, "Pinned Community Channel")
view, err := communityService.Create(ctx, owner.ID, domain.CreateCommunityRequest{
Title: "Pinned Community", InitialPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID},
Visibility: domain.CommunityPeerVisible, Date: 1_800_110_000 + i,
})
if err != nil {
t.Fatalf("create community %d: %v", i, err)
}
if _, _, err := communityService.SetCollapsed(ctx, owner.ID, view.Community.ID, true); err != nil {
t.Fatalf("collapse community %d: %v", i, err)
}
inputs = append(inputs, &tg.InputChannel{ChannelID: view.Community.ID, AccessHash: view.Community.AccessHash})
}
for i := 0; i < domain.MaxPinnedDialogsMainFolder-1; i++ {
input := inputs[i]
toggle := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeerCommunity{Community: input}}
toggle.SetPinned(true)
ok, err := r.onMessagesToggleDialogPin(WithUserID(ctx, owner.ID), toggle)
if err != nil || !ok {
t.Fatalf("pin community %d = %v, %v", i, ok, err)
}
}
joined, err := communityService.ListJoined(ctx, owner.ID)
if err != nil || len(joined) != domain.MaxPinnedDialogsMainFolder {
t.Fatalf("joined Communities before ordinary pin = %+v, %v", joined, err)
}
for i := 0; i < domain.MaxPinnedDialogsMainFolder-1; i++ {
if !joined[i].State.Pinned || !joined[i].State.Collapsed {
t.Fatalf("joined Community %d state before ordinary pin = %+v", i, joined[i].State)
}
}
ordinary := communityRPCChannel(t, channelService, owner, "Ordinary Pinned Channel")
ordinaryToggle := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerChannel{
ChannelID: ordinary.ID, AccessHash: ordinary.AccessHash,
}}}
ordinaryToggle.SetPinned(true)
if ok, err := r.onMessagesToggleDialogPin(WithUserID(ctx, owner.ID), ordinaryToggle); err != nil || !ok {
t.Fatalf("pin ordinary dialog at shared limit = %v, %v", ok, err)
}
pinned, err := r.pinnedDialogsList(ctx, owner.ID, domain.DialogMainFolderID)
if err != nil {
t.Fatal(err)
}
order := combinedPinnedDialogPeers(pinned)
if len(order) != domain.MaxPinnedDialogsMainFolder || order[0] != (domain.Peer{Type: domain.PeerTypeChannel, ID: ordinary.ID}) {
t.Fatalf("combined pinned order = %+v (dialogs=%+v communities=%+v count=%d), want ordinary dialog promoted above Communities", order, pinned.Dialogs, pinned.Communities, pinned.Count)
}
overLimit := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeerCommunity{Community: inputs[len(inputs)-1]}}
overLimit.SetPinned(true)
if ok, err := r.onMessagesToggleDialogPin(WithUserID(ctx, owner.ID), overLimit); err == nil || ok || !tgerr.Is(err, "PINNED_DIALOGS_TOO_MUCH") {
t.Fatalf("pin over shared limit = %v, %v", ok, err)
}
if _, err := r.onMessagesReorderPinnedDialogs(WithUserID(ctx, owner.ID), &tg.MessagesReorderPinnedDialogsRequest{
FolderID: domain.DialogArchiveFolderID,
Order: []tg.InputDialogPeerClass{&tg.InputDialogPeerCommunity{Community: inputs[0]}},
}); err == nil || !tgerr.Is(err, "FOLDER_ID_INVALID") {
t.Fatalf("archive Community reorder error = %v", err)
}
}
func TestCommunitiesRPCLayer228Lifecycle(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 701, Phone: "15552000001", FirstName: "Owner"})
member, _ := userStore.Create(ctx, domain.User{AccessHash: 702, Phone: "15552000002", FirstName: "Member"})
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
initial := communityRPCChannel(t, channelService, owner, "Initial", member)
communityService := appcommunities.NewService(memory.NewCommunityStore(userStore, channelStore, nil, nil))
storyStore := memory.NewStoryStore()
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, ID: 1,
Date: 1_800_100_001, ExpireDate: 1_900_100_001, Public: true,
}}); err != nil {
t.Fatalf("seed owner story: %v", err)
}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
Communities: communityService,
Stories: appstories.NewService(storyStore),
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1_800_100_100, 0)})
createdResult, err := r.onCommunitiesCreate(WithUserID(ctx, owner.ID), &tg.CommunitiesCreateRequest{
Hidden: true,
Title: "Official Community",
About: "Layer 228",
Peer: &tg.InputPeerChannel{ChannelID: initial.ID, AccessHash: initial.AccessHash},
})
if err != nil {
t.Fatalf("communities.create: %v", err)
}
created, ok := createdResult.(*tg.Updates)
if !ok || len(created.Chats) != 1 {
t.Fatalf("create result = %#v, want Updates with Community", createdResult)
}
community, ok := created.Chats[0].(*tg.Community)
if !ok || community.Title != "Official Community" || !community.Creator {
t.Fatalf("create chat = %#v", created.Chats[0])
}
inputCommunity := &tg.InputChannel{ChannelID: community.ID, AccessHash: community.AccessHash}
joinedResult, err := r.onCommunitiesGetJoined(WithUserID(ctx, member.ID))
if err != nil {
t.Fatalf("communities.getJoinedCommunities: %v", err)
}
joined := joinedResult.(*tg.MessagesChats)
if len(joined.Chats) != 1 || joined.Chats[0].(*tg.Community).ID != community.ID {
t.Fatalf("joined communities = %+v", joined.Chats)
}
full, err := r.onChannelsGetFullChannel(WithUserID(ctx, member.ID), inputCommunity)
if err != nil {
t.Fatalf("channels.getFullChannel community: %v", err)
}
communityFull, ok := full.FullChat.(*tg.CommunityFull)
if !ok || communityFull.About != "Layer 228" || len(communityFull.LinkedPeers) != 1 || len(full.Chats) != 2 {
t.Fatalf("community full = %#v chats=%+v", full.FullChat, full.Chats)
}
collapsedResult, err := r.onCommunitiesToggleCollapsed(WithUserID(ctx, owner.ID), &tg.CommunitiesToggleCommunityCollapsedInDialogsRequest{
Collapsed: true,
Community: inputCommunity,
})
if err != nil {
t.Fatalf("toggle collapsed: %v", err)
}
collapsed := collapsedResult.(*tg.Updates)
if len(collapsed.Chats) == 0 || !collapsed.Chats[0].(*tg.Community).CollapsedInDialogs {
t.Fatalf("collapsed updates = %+v", collapsed.Chats)
}
list, err := r.withCommunityDialogList(ctx, owner.ID, domain.DialogFilter{}, domain.DialogList{})
if err != nil || len(list.Communities) != 1 {
t.Fatalf("community dialog list = %+v err=%v", list, err)
}
dialogs := tgMessagesDialogs(owner.ID, list).(*tg.MessagesDialogs)
if len(dialogs.Dialogs) != 1 {
t.Fatalf("dialogs = %+v, want dialogCommunity", dialogs.Dialogs)
}
if dialog, ok := dialogs.Dialogs[0].(*tg.DialogCommunity); !ok || dialog.CommunityID != community.ID {
t.Fatalf("dialog = %#v, want community %d", dialogs.Dialogs[0], community.ID)
}
ownedOne := communityRPCChannel(t, channelService, member, "Owned One")
ownedTwo := communityRPCChannel(t, channelService, member, "Owned Two")
requestLink := func(channel domain.Channel) {
t.Helper()
ok, err := r.onCommunitiesTogglePeerLink(WithUserID(ctx, member.ID), &tg.CommunitiesTogglePeerLinkRequest{
Visible: true,
Community: inputCommunity,
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
})
if err == nil || ok || !tgerr.Is(err, "COMMUNITY_REQUEST_CREATED") {
t.Fatalf("request link %d = %v, %v", channel.ID, ok, err)
}
}
requestLink(ownedOne)
requestLink(ownedTwo)
requests, err := r.onCommunitiesGetPeerLinkRequests(WithUserID(ctx, owner.ID), &tg.CommunitiesGetPeerLinkRequestsRequest{
Community: inputCommunity,
Limit: 20,
})
if err != nil || requests.TotalCount != 2 || len(requests.Requests) != 2 {
t.Fatalf("peer link requests = %+v err=%v", requests, err)
}
// The Community owner is deliberately not a member of Owned One. Approval
// must use the request's validated ownership rather than ordinary channel
// membership access.
approved, err := r.onCommunitiesTogglePeerLinkRequestApproval(WithUserID(ctx, owner.ID), &tg.CommunitiesTogglePeerLinkRequestApprovalRequest{
Community: inputCommunity,
Peer: &tg.InputPeerChannel{ChannelID: ownedOne.ID, AccessHash: ownedOne.AccessHash},
})
if err != nil || !approved {
t.Fatalf("approve peer link = %v, %v", approved, err)
}
approved, err = r.onCommunitiesToggleAllPeerLinkRequestApproval(WithUserID(ctx, owner.ID), &tg.CommunitiesToggleAllPeerLinkRequestApprovalRequest{Community: inputCommunity})
if err != nil || !approved {
t.Fatalf("approve all peer links = %v, %v", approved, err)
}
joinedChats, err := r.onCommunitiesGetParticipantJoinedChats(WithUserID(ctx, owner.ID), &tg.CommunitiesGetParticipantJoinedChatsRequest{
Community: inputCommunity,
Participant: &tg.InputPeerUser{UserID: member.ID, AccessHash: member.AccessHash},
})
if err != nil || len(joinedChats.JoinedChatIDs) != 3 || len(joinedChats.CreatorChatIDs) != 2 {
t.Fatalf("participant joined chats = %+v err=%v", joinedChats, err)
}
participantsResult, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{
Channel: inputCommunity,
Filter: &tg.ChannelParticipantsSearch{Q: "memBER"},
Limit: 20,
})
if err != nil {
t.Fatalf("community participants search: %v", err)
}
participants := participantsResult.(*tg.ChannelsChannelParticipants)
if participants.Count != 1 || len(participants.Participants) != 1 {
t.Fatalf("community participants = %+v", participants)
}
adminsResult, err := r.onChannelsGetParticipants(WithUserID(ctx, member.ID), &tg.ChannelsGetParticipantsRequest{
Channel: inputCommunity,
Filter: &tg.ChannelParticipantsAdmins{},
Limit: 100,
})
if err != nil {
t.Fatalf("ordinary member Community admins: %v", err)
}
admins := adminsResult.(*tg.ChannelsChannelParticipants)
if admins.Count != 1 || len(admins.Participants) != 1 {
t.Fatalf("ordinary member Community admins = %+v, want creator", admins)
}
if _, err := r.onChannelsGetParticipants(WithUserID(ctx, member.ID), &tg.ChannelsGetParticipantsRequest{
Channel: inputCommunity,
Filter: &tg.ChannelParticipantsBanned{Q: ""},
Limit: 100,
}); err == nil || !tgerr.Is(err, "CHAT_ADMIN_REQUIRED") {
t.Fatalf("ordinary member Community banned list err = %v, want CHAT_ADMIN_REQUIRED", err)
}
recent, err := r.onStoriesGetPeerMaxIDs(WithUserID(ctx, owner.ID), []tg.InputPeerClass{
&tg.InputPeerSelf{},
&tg.InputPeerChannel{ChannelID: community.ID, AccessHash: community.AccessHash},
&tg.InputPeerSelf{},
})
if err != nil {
t.Fatalf("stories.getPeerMaxIDs with Community slot: %v", err)
}
if len(recent) != 3 {
t.Fatalf("stories.getPeerMaxIDs slots = %d, want 3", len(recent))
}
for _, index := range []int{0, 2} {
if maxID, ok := recent[index].GetMaxID(); !ok || maxID != 1 {
t.Fatalf("stories.getPeerMaxIDs[%d] max_id = %d ok=%v, want 1 true", index, maxID, ok)
}
}
if maxID, ok := recent[1].GetMaxID(); ok || maxID != 0 || recent[1].Live {
t.Fatalf("stories.getPeerMaxIDs Community slot = %+v, want empty recentStory", recent[1])
}
if _, err := r.onStoriesGetPeerMaxIDs(WithUserID(ctx, owner.ID), []tg.InputPeerClass{
&tg.InputPeerChannel{ChannelID: community.ID, AccessHash: community.AccessHash + 1},
}); err == nil || !tgerr.Is(err, "CHANNEL_PRIVATE") {
t.Fatalf("stories.getPeerMaxIDs Community wrong hash err = %v, want CHANNEL_PRIVATE", err)
}
banned, err := r.onCommunitiesToggleParticipantBanned(WithUserID(ctx, owner.ID), &tg.CommunitiesToggleParticipantBannedRequest{
Community: inputCommunity,
Participant: &tg.InputPeerUser{UserID: member.ID, AccessHash: member.AccessHash},
})
if err != nil || !banned {
t.Fatalf("toggle participant banned = %v, %v", banned, err)
}
joinedResult, err = r.onCommunitiesGetJoined(WithUserID(ctx, member.ID))
if err != nil || len(joinedResult.(*tg.MessagesChats).Chats) != 0 {
t.Fatalf("banned member joined communities = %#v err=%v", joinedResult, err)
}
}

View file

@ -297,6 +297,12 @@ func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageAction
return &tg.MessageActionSetChatWallPaper{Wallpaper: wallpaper}
}
return nil
case domain.ChannelActionChangeCommunity:
out := &tg.MessageActionChangeCommunity{}
if action.CommunityID != 0 {
out.SetCommunityID(action.CommunityID)
}
return out
default:
return nil
}
@ -431,6 +437,9 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
if ch.LinkedMonoforumID != 0 && ch.BroadcastMessagesAllowed {
out.SetLinkedMonoforumID(ch.LinkedMonoforumID)
}
if ch.LinkedCommunityID != 0 {
out.SetLinkedCommunityID(ch.LinkedCommunityID)
}
if ch.Username != "" {
out.SetUsername(ch.Username)
out.SetUsernames(tgUsernames(ch.Username))
@ -852,29 +861,30 @@ func domainChannelAdminRights(rights tg.ChatAdminRights) domain.ChannelAdminRigh
func tgChatBannedRights(rights domain.ChannelBannedRights) tg.ChatBannedRights {
return tg.ChatBannedRights{
ViewMessages: rights.ViewMessages,
SendMessages: rights.SendMessages,
SendMedia: rights.SendMedia,
SendStickers: rights.SendStickers,
SendGifs: rights.SendGifs,
SendGames: rights.SendGames,
SendInline: rights.SendInline,
EmbedLinks: rights.EmbedLinks,
SendPolls: rights.SendPolls,
ChangeInfo: rights.ChangeInfo,
InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages,
ManageTopics: rights.ManageTopics,
SendPhotos: rights.SendPhotos,
SendVideos: rights.SendVideos,
SendRoundvideos: rights.SendRoundvideos,
SendAudios: rights.SendAudios,
SendVoices: rights.SendVoices,
SendDocs: rights.SendDocs,
SendPlain: rights.SendPlain,
EditRank: rights.EditRank,
SendReactions: rights.SendReactions,
UntilDate: rights.UntilDate,
ViewMessages: rights.ViewMessages,
SendMessages: rights.SendMessages,
SendMedia: rights.SendMedia,
SendStickers: rights.SendStickers,
SendGifs: rights.SendGifs,
SendGames: rights.SendGames,
SendInline: rights.SendInline,
EmbedLinks: rights.EmbedLinks,
SendPolls: rights.SendPolls,
ChangeInfo: rights.ChangeInfo,
InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages,
ManageTopics: rights.ManageTopics,
SendPhotos: rights.SendPhotos,
SendVideos: rights.SendVideos,
SendRoundvideos: rights.SendRoundvideos,
SendAudios: rights.SendAudios,
SendVoices: rights.SendVoices,
SendDocs: rights.SendDocs,
SendPlain: rights.SendPlain,
EditRank: rights.EditRank,
SendReactions: rights.SendReactions,
ManageLinkedPeers: rights.ManageLinkedPeers,
UntilDate: rights.UntilDate,
}
}
@ -888,29 +898,30 @@ func tgDefaultChatBannedRights(rights domain.ChannelBannedRights) tg.ChatBannedR
func domainChannelBannedRights(rights tg.ChatBannedRights) domain.ChannelBannedRights {
return domain.ChannelBannedRights{
ViewMessages: rights.ViewMessages,
SendMessages: rights.SendMessages,
SendMedia: rights.SendMedia,
SendStickers: rights.SendStickers,
SendGifs: rights.SendGifs,
SendGames: rights.SendGames,
SendInline: rights.SendInline,
EmbedLinks: rights.EmbedLinks,
SendPolls: rights.SendPolls,
ChangeInfo: rights.ChangeInfo,
InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages,
ManageTopics: rights.ManageTopics,
SendPhotos: rights.SendPhotos,
SendVideos: rights.SendVideos,
SendRoundvideos: rights.SendRoundvideos,
SendAudios: rights.SendAudios,
SendVoices: rights.SendVoices,
SendDocs: rights.SendDocs,
SendPlain: rights.SendPlain,
EditRank: rights.EditRank,
SendReactions: rights.SendReactions,
UntilDate: rights.UntilDate,
ViewMessages: rights.ViewMessages,
SendMessages: rights.SendMessages,
SendMedia: rights.SendMedia,
SendStickers: rights.SendStickers,
SendGifs: rights.SendGifs,
SendGames: rights.SendGames,
SendInline: rights.SendInline,
EmbedLinks: rights.EmbedLinks,
SendPolls: rights.SendPolls,
ChangeInfo: rights.ChangeInfo,
InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages,
ManageTopics: rights.ManageTopics,
SendPhotos: rights.SendPhotos,
SendVideos: rights.SendVideos,
SendRoundvideos: rights.SendRoundvideos,
SendAudios: rights.SendAudios,
SendVoices: rights.SendVoices,
SendDocs: rights.SendDocs,
SendPlain: rights.SendPlain,
EditRank: rights.EditRank,
SendReactions: rights.SendReactions,
ManageLinkedPeers: rights.ManageLinkedPeers,
UntilDate: rights.UntilDate,
}
}

View file

@ -0,0 +1,104 @@
package rpc
import (
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
func tgCommunityPhoto(c domain.Community) tg.ChatPhotoClass {
if c.PhotoID == 0 {
return &tg.ChatPhotoEmpty{}
}
out := &tg.ChatPhoto{PhotoID: c.PhotoID, DCID: c.PhotoDCID}
if len(c.PhotoStripped) > 0 {
out.SetStrippedThumb(c.PhotoStripped)
}
return out
}
func tgCommunityFullPhoto(c domain.Community) tg.PhotoClass {
if c.PhotoID == 0 {
return &tg.PhotoEmpty{}
}
sizes := syntheticAvatarSizes()
if len(c.PhotoStripped) > 0 {
sizes = append([]tg.PhotoSizeClass{&tg.PhotoStrippedSize{Type: "i", Bytes: c.PhotoStripped}}, sizes...)
}
return &tg.Photo{ID: c.PhotoID, DCID: c.PhotoDCID, Sizes: sizes}
}
func tgCommunityChat(view domain.CommunityView) tg.ChatClass {
c := view.Community
if c.Deleted || view.Forbidden {
return &tg.CommunityForbidden{ID: c.ID, AccessHash: c.AccessHash, Title: c.Title}
}
out := &tg.Community{Creator: view.Creator(), CollapsedInDialogs: view.State.Collapsed, ID: c.ID, Title: c.Title, Photo: tgCommunityPhoto(c), Date: c.Date}
out.SetAccessHash(c.AccessHash)
if view.Self.Role == domain.CommunityRoleCreator {
out.SetAdminRights(tgChatAdminRights(domain.CreatorChannelAdminRights()))
} else if view.Self.Role == domain.CommunityRoleAdmin {
out.SetAdminRights(tgChatAdminRights(view.Self.AdminRights))
}
out.SetDefaultBannedRights(tgDefaultChatBannedRights(c.DefaultBannedRights))
return out
}
func tgCommunityChats(views []domain.CommunityView) []tg.ChatClass {
out := make([]tg.ChatClass, 0, len(views))
for _, v := range views {
out = append(out, tgCommunityChat(v))
}
return out
}
func tgCommunityPeer(link domain.CommunityPeerLink) tg.CommunityPeer {
out := tg.CommunityPeer{CanViewHistory: link.CanViewHistory, Peer: tgPeer(link.Peer)}
out.SetVisible(link.Visible())
return out
}
func tgCommunityFull(view domain.CommunityView) *tg.CommunityFull {
links := make([]tg.CommunityPeer, 0, len(view.Links))
for _, l := range view.Links {
links = append(links, tgCommunityPeer(l))
}
out := &tg.CommunityFull{ID: view.Community.ID, About: view.Community.About, ChatPhoto: tgCommunityFullPhoto(view.Community), LinkedPeers: links}
if view.AdminsCount > 0 {
out.SetAdminsCount(view.AdminsCount)
}
if view.KickedCount > 0 {
out.SetKickedCount(view.KickedCount)
}
if view.PendingRequests > 0 {
out.SetPeerLinkRequestsPending(view.PendingRequests)
}
return out
}
func tgCommunityHydratedChats(viewerUserID int64, view domain.CommunityView) []tg.ChatClass {
out := []tg.ChatClass{tgCommunityChat(view)}
for _, ch := range view.Channels {
out = appendUniqueTGChats(out, tgChannelChatMin(viewerUserID, ch))
}
return out
}
func tgCommunityMember(viewerUserID int64, m domain.CommunityMember) tg.ChannelParticipantClass {
cm := domain.ChannelMember{ChannelID: m.CommunityID, UserID: m.UserID, Status: domain.ChannelMemberActive, Role: domain.ChannelRoleMember, AdminRights: m.AdminRights, Rank: m.Rank, JoinedAt: m.Date}
if m.Status == domain.CommunityMemberKicked {
cm.Status = domain.ChannelMemberKicked
cm.BannedRights = domain.ChannelBannedRights{ViewMessages: true}
}
switch m.Role {
case domain.CommunityRoleCreator:
cm.Role = domain.ChannelRoleCreator
case domain.CommunityRoleAdmin:
cm.Role = domain.ChannelRoleAdmin
}
return tgChannelParticipant(viewerUserID, cm)
}
func tgCommunityDialog(view domain.CommunityView, notify *domain.PeerNotifySettings) *tg.DialogCommunity {
return &tg.DialogCommunity{Pinned: view.State.Pinned, CommunityID: view.Community.ID, NotifySettings: *tgPeerNotifySettings(notify)}
}

View file

@ -1,10 +1,56 @@
package rpc
import (
"sort"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
type dialogProjection struct {
dialog tg.DialogClass
pinned bool
pinnedOrder int
sequence int
}
func projectedDialogs(list domain.DialogList) []tg.DialogClass {
items := make([]dialogProjection, 0, len(list.Dialogs)+len(list.Communities))
for _, d := range list.Dialogs {
if dialog := tgDialog(d); dialog != nil {
items = append(items, dialogProjection{dialog: dialog, pinned: d.Pinned, pinnedOrder: d.PinnedOrder, sequence: len(items)})
}
}
for _, community := range list.Communities {
items = append(items, dialogProjection{
dialog: tgCommunityDialog(community, community.State.NotifySettings), pinned: community.State.Pinned,
pinnedOrder: community.State.PinnedOrder, sequence: len(items),
})
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].pinned != items[j].pinned {
return items[i].pinned
}
if items[i].pinned && items[i].pinnedOrder != items[j].pinnedOrder {
return items[i].pinnedOrder > items[j].pinnedOrder
}
return items[i].sequence < items[j].sequence
})
out := make([]tg.DialogClass, 0, len(items))
for _, item := range items {
out = append(out, item.dialog)
}
return out
}
func appendCommunityDialogObjects(viewerUserID int64, list domain.DialogList, chats []tg.ChatClass, users []tg.UserClass) ([]tg.ChatClass, []tg.UserClass) {
for _, community := range list.Communities {
chats = appendUniqueTGChats(chats, tgCommunityHydratedChats(viewerUserID, community)...)
users = appendUniqueTGUsers(users, tgUsersForViewer(viewerUserID, community.Users)...)
}
return chats, users
}
func tgMessagesDialogs(viewerUserID int64, list domain.DialogList) tg.MessagesDialogsClass {
dialogs := make([]tg.DialogClass, 0, len(list.Dialogs)+1)
// dialogFolder 条目排在最前:TDesktop 据它发现 archive folder 并渲染
@ -12,11 +58,7 @@ func tgMessagesDialogs(viewerUserID int64, list domain.DialogList) tg.MessagesDi
if folder := tgDialogFolder(list.ArchiveSummary); folder != nil {
dialogs = append(dialogs, folder)
}
for _, d := range list.Dialogs {
if dialog := tgDialog(d); dialog != nil {
dialogs = append(dialogs, dialog)
}
}
dialogs = append(dialogs, projectedDialogs(list)...)
messages := make([]tg.MessageClass, 0, len(list.Messages))
for _, msg := range list.Messages {
if item := tgMessage(msg); item != nil {
@ -30,6 +72,7 @@ func tgMessagesDialogs(viewerUserID int64, list domain.DialogList) tg.MessagesDi
}
users := tgUsersForViewer(viewerUserID, list.Users)
chats := tgChannelsForDialogs(viewerUserID, list.Channels, list.Dialogs)
chats, users = appendCommunityDialogObjects(viewerUserID, list, chats, users)
if list.Count > len(dialogs) {
return &tg.MessagesDialogsSlice{
Count: list.Count,
@ -61,11 +104,7 @@ func tgPeerDialogs(viewerUserID int64, list domain.DialogList, st domain.UpdateS
if folder := tgDialogFolder(list.ArchiveSummary); folder != nil {
out.Dialogs = append(out.Dialogs, folder)
}
for _, d := range list.Dialogs {
if dialog := tgDialog(d); dialog != nil {
out.Dialogs = append(out.Dialogs, dialog)
}
}
out.Dialogs = append(out.Dialogs, projectedDialogs(list)...)
for _, msg := range list.Messages {
if item := tgMessage(msg); item != nil {
out.Messages = append(out.Messages, item)
@ -84,6 +123,7 @@ func tgPeerDialogs(viewerUserID int64, list domain.DialogList, st domain.UpdateS
}
}
out.Chats = append(out.Chats, tgChannelsForDialogs(viewerUserID, list.Channels, list.Dialogs)...)
out.Chats, out.Users = appendCommunityDialogObjects(viewerUserID, list, out.Chats, out.Users)
return out
}
@ -204,6 +244,9 @@ func tgDraftWebPage(webpage *domain.DialogDraftWebPage) tg.InputMediaClass {
}
func tgDialogPeer(p domain.Peer) tg.DialogPeerClass {
if p.Type == domain.PeerTypeCommunity && p.ID > 0 {
return &tg.DialogPeerCommunity{CommunityID: p.ID}
}
peer := tgPeer(p)
if peer == nil {
return nil

View file

@ -30,6 +30,9 @@ func tgSelfUser(u domain.User) *tg.User {
applyTgUserBotFields(out, u)
applyTgUserPremiumFields(out, u)
applyTgUserColorFields(out, u)
if u.LinkedCommunityID != 0 {
out.SetLinkedCommunityID(u.LinkedCommunityID)
}
if photo := tgUserProfilePhoto(u); photo != nil {
out.Photo = photo
}
@ -57,6 +60,9 @@ func tgUser(u domain.User) *tg.User {
applyTgUserBotFields(out, u)
applyTgUserPremiumFields(out, u)
applyTgUserColorFields(out, u)
if u.LinkedCommunityID != 0 {
out.SetLinkedCommunityID(u.LinkedCommunityID)
}
if photo := tgUserProfilePhoto(u); photo != nil {
out.Photo = photo
}

View file

@ -620,6 +620,7 @@ type ChannelsService interface {
CheckUsername(ctx context.Context, userID, channelID int64, username string) (bool, error)
UpdateUsername(ctx context.Context, userID int64, req domain.UpdateChannelUsernameRequest) (domain.Channel, error)
ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
ListCommunityLinkableChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
ListStoryPostableChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
ListSendAsChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
ResolvePublicUsername(ctx context.Context, userID int64, username string) (domain.Channel, bool, error)
@ -733,6 +734,32 @@ type ChannelsService interface {
FilterActiveMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
}
// CommunitiesService abstracts the Layer 228 Community aggregation domain.
// Community containers never expose tg types and never own message/read/pts state.
type CommunitiesService interface {
Create(ctx context.Context, userID int64, req domain.CreateCommunityRequest) (domain.CommunityView, error)
Get(ctx context.Context, userID, communityID int64) (domain.CommunityView, error)
GetMany(ctx context.Context, userID int64, ids []int64) ([]domain.CommunityView, error)
ListJoined(ctx context.Context, userID int64) ([]domain.CommunityView, error)
TogglePeerLink(ctx context.Context, userID int64, req domain.CommunityTogglePeerLinkRequest) (domain.CommunityTogglePeerLinkResult, error)
SetCollapsed(ctx context.Context, userID, communityID int64, collapsed bool) (domain.CommunityView, bool, error)
ListPeerLinkRequests(ctx context.Context, userID, communityID int64, offset string, limit int) (domain.CommunityPeerLinkRequestPage, error)
DecidePeerLinkRequest(ctx context.Context, userID, communityID int64, peer domain.Peer, reject bool, date int) (domain.CommunityTogglePeerLinkResult, error)
DecideAllPeerLinkRequests(ctx context.Context, userID, communityID int64, reject bool, date int) ([]domain.CommunityTogglePeerLinkResult, error)
ToggleParticipantBanned(ctx context.Context, userID, communityID, participantUserID int64, unban bool, date int) (domain.CommunityParticipantBanResult, error)
ParticipantJoinedChats(ctx context.Context, userID, communityID, participantUserID int64) (domain.CommunityParticipantJoinedChats, error)
Participants(ctx context.Context, userID, communityID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.CommunityParticipantList, error)
EditTitle(ctx context.Context, userID, communityID int64, title string) (domain.CommunityView, bool, error)
EditAbout(ctx context.Context, userID, communityID int64, about string) (domain.CommunityView, bool, error)
EditAdmin(ctx context.Context, userID int64, req domain.CommunityEditAdminRequest) (domain.CommunityView, bool, error)
EditDefaultBannedRights(ctx context.Context, userID, communityID int64, rights domain.ChannelBannedRights) (domain.CommunityView, bool, error)
SetPhoto(ctx context.Context, userID, communityID int64, photo *domain.Photo, date int) (domain.CommunityView, bool, error)
Delete(ctx context.Context, userID, communityID int64, date int) (domain.CommunityView, []domain.Peer, error)
SetPinned(ctx context.Context, userID, communityID int64, pinned bool) (bool, error)
ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) (bool, error)
SearchScope(ctx context.Context, userID, communityID int64) (domain.CommunitySearchScope, error)
}
// FilesService 抽象文件上传分片、下载与媒体(document/photo)组装。
// 方法只用 domain 类型;rpc 层负责 tg.InputFileLocation / InputMedia ↔ domain 转换。
type FilesService interface {
@ -857,6 +884,7 @@ type Deps struct {
Translation TranslationService
Stories StoriesService
Channels ChannelsService
Communities CommunitiesService
Files FilesService
Bots BotsService
Polls PollsService

View file

@ -3,16 +3,20 @@ package rpc
import (
"context"
"fmt"
"sort"
"telesrv/internal/domain"
)
func (r *Router) pinnedDialogsList(ctx context.Context, userID int64, folderID int) (domain.DialogList, error) {
if r == nil || r.deps.Dialogs == nil {
if r == nil {
return domain.DialogList{}, nil
}
key := fmt.Sprintf("%d:%d", userID, folderID)
value, err, _ := r.dialogsPinnedSF.Do(key, func() (any, error) {
if r.deps.Dialogs == nil {
return domain.DialogList{}, nil
}
return r.deps.Dialogs.GetDialogs(ctx, userID, domain.DialogFilter{
PinnedOnly: true,
HasFolderID: true,
@ -24,7 +28,93 @@ func (r *Router) pinnedDialogsList(ctx context.Context, userID int64, folderID i
return domain.DialogList{}, err
}
if list, ok := value.(domain.DialogList); ok {
return list, nil
return r.withCommunityDialogList(ctx, userID, domain.DialogFilter{PinnedOnly: true, HasFolderID: true, FolderID: folderID}, list)
}
return domain.DialogList{}, nil
}
// combinedPinnedDialogPeers merges ordinary dialogs and collapsed Communities
// by their shared server order. The two persistence implementations deliberately
// store their own rows, but Layer 228 exposes one messages.getPinnedDialogs list.
func combinedPinnedDialogPeers(list domain.DialogList) []domain.Peer {
type item struct {
peer domain.Peer
order int
sequence int
}
items := make([]item, 0, len(list.Dialogs)+len(list.Communities))
seen := make(map[domain.Peer]struct{}, cap(items))
appendItem := func(peer domain.Peer, pinned bool, order int) {
if !pinned || peer.ID == 0 {
return
}
if _, ok := seen[peer]; ok {
return
}
seen[peer] = struct{}{}
items = append(items, item{peer: peer, order: order, sequence: len(items)})
}
for _, dialog := range list.Dialogs {
appendItem(dialog.Peer, dialog.Pinned, dialog.PinnedOrder)
}
for _, community := range list.Communities {
appendItem(domain.Peer{Type: domain.PeerTypeCommunity, ID: community.Community.ID}, community.State.Pinned, community.State.PinnedOrder)
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].order != items[j].order {
return items[i].order > items[j].order
}
return items[i].sequence < items[j].sequence
})
out := make([]domain.Peer, 0, len(items))
for _, item := range items {
out = append(out, item.peer)
}
return out
}
func (r *Router) ensureCombinedPinCapacity(ctx context.Context, userID int64, folderID int, peer domain.Peer) error {
list, err := r.pinnedDialogsList(ctx, userID, folderID)
if err != nil {
return err
}
peers := combinedPinnedDialogPeers(list)
for _, pinned := range peers {
if pinned == peer {
return nil
}
}
if len(peers) >= domain.PinnedDialogsLimit(folderID, r.userIsPremium(ctx, userID)) {
return domain.ErrPinnedDialogsTooMuch
}
return nil
}
// promoteCombinedPinnedDialog assigns one collision-free order across ordinary
// dialogs and Communities. It is called after the underlying row is pinned so
// both stores can project the same mixed order without owning each other's data.
func (r *Router) promoteCombinedPinnedDialog(ctx context.Context, userID int64, folderID int, peer domain.Peer) error {
list, err := r.pinnedDialogsList(ctx, userID, folderID)
if err != nil {
return err
}
current := combinedPinnedDialogPeers(list)
order := make([]domain.Peer, 0, len(current)+1)
order = append(order, peer)
for _, candidate := range current {
if candidate != peer {
order = append(order, candidate)
}
}
if r.deps.Dialogs != nil {
if _, err := r.deps.Dialogs.ReorderPinned(ctx, userID, folderID, order, false); err != nil {
return err
}
}
if folderID == domain.DialogMainFolderID && r.deps.Communities != nil {
if _, err := r.deps.Communities.ReorderPinned(ctx, userID, order, false); err != nil {
return err
}
}
return nil
}

View file

@ -573,6 +573,50 @@ func (r *Router) onMessagesToggleDialogPin(ctx context.Context, req *tg.Messages
if folderPeer, ok := req.Peer.(*tg.InputDialogPeerFolder); ok {
return r.toggleArchiveFolderPin(ctx, userID, folderPeer.FolderID, req.GetPinned())
}
if community, ok, err := r.communityDialogPeerFromInput(ctx, userID, req.Peer); ok {
if err != nil {
return false, err
}
pinned := req.GetPinned()
peer := domain.Peer{Type: domain.PeerTypeCommunity, ID: community.Community.ID}
if pinned && !community.State.Pinned {
if err := r.ensureCombinedPinCapacity(ctx, userID, domain.DialogMainFolderID, peer); err != nil {
if errors.Is(err, domain.ErrPinnedDialogsTooMuch) {
return false, pinnedTooMuchErr()
}
return false, internalErr()
}
}
changed, err := r.deps.Communities.SetPinned(ctx, userID, community.Community.ID, pinned)
if err != nil {
return false, communityErr(err)
}
if !changed {
return true, nil
}
if pinned {
if err := r.promoteCombinedPinnedDialog(ctx, userID, domain.DialogMainFolderID, peer); err != nil {
return false, internalErr()
}
}
date := int(r.clock.Now().Unix())
var recorded domain.UpdateEvent
if r.deps.Updates != nil {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
event, state, err := r.deps.Updates.RecordDialogPinned(ctx, authKeyID, userID, peer, pinned, domain.DialogMainFolderID, rawAuthKeyIDForOrigin(ctx), sessionID)
if err != nil {
return false, internalErr()
}
date, recorded = state.Date, event
}
r.bookkeepAuxPtsForCurrentSession(ctx, recorded)
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{
Updates: appendAuxPtsBookkeeping([]tg.UpdateClass{&tg.UpdateDialogPinned{Pinned: pinned, Peer: tgDialogPeer(peer)}}, recorded),
Chats: []tg.ChatClass{tgCommunityChat(community)}, Date: date,
})
return true, nil
}
peers, err := r.dialogPeersFromInput(ctx, userID, []tg.InputDialogPeerClass{req.Peer})
if err != nil {
return false, err
@ -584,6 +628,25 @@ func (r *Router) onMessagesToggleDialogPin(ctx context.Context, req *tg.Messages
if r.deps.Dialogs == nil {
return true, nil
}
if pinned {
folderID := domain.DialogMainFolderID
current, err := r.deps.Dialogs.GetPeerDialogs(ctx, userID, peers)
if err != nil {
return false, internalErr()
}
for _, dialog := range current.Dialogs {
if dialog.Peer == peers[0] {
folderID = dialog.FolderID
break
}
}
if err := r.ensureCombinedPinCapacity(ctx, userID, folderID, peers[0]); err != nil {
if errors.Is(err, domain.ErrPinnedDialogsTooMuch) {
return false, pinnedTooMuchErr()
}
return false, internalErr()
}
}
changed, folderID, err := r.deps.Dialogs.TogglePinned(ctx, userID, peers[0], pinned)
if err != nil {
if errors.Is(err, domain.ErrPinnedDialogsTooMuch) {
@ -592,6 +655,11 @@ func (r *Router) onMessagesToggleDialogPin(ctx context.Context, req *tg.Messages
return false, internalErr()
}
if changed {
if pinned {
if err := r.promoteCombinedPinnedDialog(ctx, userID, folderID, peers[0]); err != nil {
return false, internalErr()
}
}
date := int(r.clock.Now().Unix())
var recorded domain.UpdateEvent
if r.deps.Updates != nil {
@ -680,12 +748,36 @@ func (r *Router) onMessagesReorderPinnedDialogs(ctx context.Context, req *tg.Mes
if err != nil {
return false, err
}
if r.deps.Dialogs == nil {
seen := make(map[domain.Peer]struct{}, len(peers))
for _, peer := range peers {
if _, duplicate := seen[peer]; duplicate {
return false, peerIDInvalidErr()
}
seen[peer] = struct{}{}
if req.FolderID != domain.DialogMainFolderID && peer.Type == domain.PeerTypeCommunity {
return false, folderIDInvalidErr()
}
}
if len(peers) > domain.PinnedDialogsLimit(req.FolderID, r.userIsPremium(ctx, userID)) {
return false, pinnedTooMuchErr()
}
if r.deps.Dialogs == nil && r.deps.Communities == nil {
return true, nil
}
changed, err := r.deps.Dialogs.ReorderPinned(ctx, userID, req.FolderID, peers, req.GetForce())
if err != nil {
return false, internalErr()
changed := false
if r.deps.Dialogs != nil {
dialogsChanged, err := r.deps.Dialogs.ReorderPinned(ctx, userID, req.FolderID, peers, req.GetForce())
if err != nil {
return false, internalErr()
}
changed = dialogsChanged
}
if r.deps.Communities != nil && req.FolderID == domain.DialogMainFolderID {
communitiesChanged, err := r.deps.Communities.ReorderPinned(ctx, userID, peers, req.GetForce())
if err != nil {
return false, communityErr(err)
}
changed = changed || communitiesChanged
}
if !changed {
return true, nil
@ -884,6 +976,15 @@ func (r *Router) dialogPeersFromInput(ctx context.Context, userID int64, items [
hasFolder := false
for _, item := range items {
switch p := item.(type) {
case *tg.InputDialogPeerCommunity:
view, ok, err := r.communityDialogPeerFromInput(ctx, userID, p)
if err != nil {
return nil, err
}
if !ok {
return nil, inputConstructorInvalidErr()
}
peers = append(peers, domain.Peer{Type: domain.PeerTypeCommunity, ID: view.Community.ID})
case *tg.InputDialogPeer:
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, p.Peer)
if err != nil {

View file

@ -528,19 +528,17 @@ func (r *Router) onMessagesGetRichMessage(ctx context.Context, req *tg.MessagesG
}
func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSearchGlobalRequest) (tg.MessagesMessagesClass, error) {
// Layer 228 adds an optional community scope. telesrv has no Communities
// membership/link read model yet, so treating this as an ordinary global
// search would leak results outside the requested scope. Reject it before
// any search/store work until that model exists.
if _, ok := req.GetCommunity(); ok || req.Community != nil {
return nil, channelInvalidErr(domain.ErrChannelInvalid)
}
if req.BroadcastsOnly && req.GroupsOnly {
return &tg.MessagesMessages{}, nil
}
query := normalizeSearchQuery(req.Q)
musicOnly := messagesSearchFilterMusic(req.Filter)
if query == "" && !musicOnly {
communityInput, hasCommunity := req.GetCommunity()
if !hasCommunity && req.Community != nil {
communityInput, hasCommunity = req.Community, true
}
emptyCommunitySearch := query == "" && !musicOnly && hasCommunity && messagesSearchFilterEmpty(req.Filter)
if query == "" && !musicOnly && !emptyCommunitySearch {
return nil, searchQueryEmptyErr()
}
if utf8.RuneCountInString(query) > maxMessageSearchQLength {
@ -553,6 +551,19 @@ func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSea
if err != nil {
return nil, internalErr()
}
var communityView *domain.CommunityView
var communityScope domain.CommunitySearchScope
if hasCommunity {
view, err := r.communityFromInput(ctx, userID, communityInput)
if err != nil {
return nil, err
}
scope, err := r.deps.Communities.SearchScope(ctx, userID, view.Community.ID)
if err != nil {
return nil, communityErr(err)
}
communityView, communityScope = &view, scope
}
limit := req.Limit
if limit <= 0 || limit > domain.MaxChannelGlobalSearchLimit {
limit = domain.MaxChannelGlobalSearchLimit
@ -565,6 +576,9 @@ func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSea
if err != nil {
return nil, err
}
if emptyCommunitySearch {
return appendCommunitySearchChat(&tg.MessagesMessages{}, communityView), nil
}
var private domain.MessageList
if !req.BroadcastsOnly && !req.GroupsOnly && r.deps.Messages != nil {
filter := domain.MessageFilter{
@ -574,6 +588,10 @@ func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSea
Limit: limit + 1,
MusicOnly: musicOnly,
}
if communityView != nil {
filter.RestrictPeerIDs = true
filter.PeerIDs = communityScope.BotUserIDs
}
if req.MaxDate > 0 {
filter.OffsetDate = req.MaxDate
}
@ -586,30 +604,49 @@ func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSea
}
}
if req.UsersOnly || r.deps.Channels == nil {
return tgMessagesMessages(userID, r.enrichMessageList(ctx, userID, limitMessageList(private, limit))), nil
return appendCommunitySearchChat(tgMessagesMessages(userID, r.enrichMessageList(ctx, userID, limitMessageList(private, limit))), communityView), nil
}
channelHistory, err := r.deps.Channels.SearchJoinedMessages(ctx, userID, domain.ChannelGlobalSearchRequest{
Query: query,
BroadcastsOnly: req.BroadcastsOnly,
GroupsOnly: req.GroupsOnly,
MusicOnly: musicOnly,
HasFolderID: hasFolderID,
FolderID: folderID,
OffsetRate: req.OffsetRate,
OffsetChannelID: channelOffsetID,
OffsetID: req.OffsetID,
MinDate: req.MinDate,
MaxDate: req.MaxDate,
Limit: limit,
Query: query,
ChannelIDs: communityScope.ChannelIDs,
RestrictChannelIDs: communityView != nil,
AllowPublicPreview: communityView != nil,
BroadcastsOnly: req.BroadcastsOnly,
GroupsOnly: req.GroupsOnly,
MusicOnly: musicOnly,
HasFolderID: hasFolderID,
FolderID: folderID,
OffsetRate: req.OffsetRate,
OffsetChannelID: channelOffsetID,
OffsetID: req.OffsetID,
MinDate: req.MinDate,
MaxDate: req.MaxDate,
Limit: limit,
})
if err != nil {
return nil, channelInvalidErr(err)
}
channelHistory = r.enrichChannelHistory(ctx, userID, channelHistory)
if req.BroadcastsOnly || req.GroupsOnly {
return r.tgGlobalChannelMessages(ctx, userID, limitChannelHistory(channelHistory, limit)), nil
return appendCommunitySearchChat(r.tgGlobalChannelMessages(ctx, userID, limitChannelHistory(channelHistory, limit)), communityView), nil
}
return r.tgGlobalSearchMessages(ctx, userID, limit, private, channelHistory), nil
return appendCommunitySearchChat(r.tgGlobalSearchMessages(ctx, userID, limit, private, channelHistory), communityView), nil
}
func appendCommunitySearchChat(result tg.MessagesMessagesClass, view *domain.CommunityView) tg.MessagesMessagesClass {
if result == nil || view == nil {
return result
}
chat := tgCommunityChat(*view)
switch out := result.(type) {
case *tg.MessagesMessages:
out.Chats = appendUniqueTGChats(out.Chats, chat)
case *tg.MessagesMessagesSlice:
out.Chats = appendUniqueTGChats(out.Chats, chat)
case *tg.MessagesChannelMessages:
out.Chats = appendUniqueTGChats(out.Chats, chat)
}
return result
}
func limitMessageList(list domain.MessageList, limit int) domain.MessageList {
@ -791,6 +828,11 @@ func messagesSearchFilterMusic(filter tg.MessagesFilterClass) bool {
return ok
}
func messagesSearchFilterEmpty(filter tg.MessagesFilterClass) bool {
_, ok := filter.(*tg.InputMessagesFilterEmpty)
return ok
}
func messagesSearchFilterChatPhotos(filter tg.MessagesFilterClass) bool {
_, ok := filter.(*tg.InputMessagesFilterChatPhotos)
return ok

View file

@ -11,24 +11,127 @@ import (
"go.uber.org/zap/zaptest"
"strings"
appchannels "telesrv/internal/app/channels"
appcommunities "telesrv/internal/app/communities"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
"testing"
)
func TestMessagesSearchGlobalRejectsUnsupportedCommunityScope(t *testing.T) {
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
func TestMessagesSearchGlobalRestrictsCommunityScope(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
owner, err := users.Create(ctx, domain.User{AccessHash: 84, Phone: "15550000084", FirstName: "Owner"})
if err != nil {
t.Fatal(err)
}
viewer, err := users.Create(ctx, domain.User{AccessHash: 85, Phone: "15550000085", FirstName: "Viewer"})
if err != nil {
t.Fatal(err)
}
channels := memory.NewChannelStore()
channelService := appchannels.NewService(channels)
linked, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "Linked", Megagroup: true, MemberUserIDs: []int64{viewer.ID}, Date: 100})
if err != nil {
t.Fatal(err)
}
publicPreview, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "Public Preview", Megagroup: true, Date: 101})
if err != nil {
t.Fatal(err)
}
publicPreview.Channel, err = channelService.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{
UserID: owner.ID, ChannelID: publicPreview.Channel.ID, Username: "community_public_preview",
})
if err != nil {
t.Fatal(err)
}
outside, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "Outside", Megagroup: true, Date: 101})
if err != nil {
t.Fatal(err)
}
communityService := appcommunities.NewService(memory.NewCommunityStore(users, channels, nil, nil))
community, err := communityService.Create(ctx, owner.ID, domain.CreateCommunityRequest{
Title: "Scope", InitialPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: linked.Channel.ID},
Visibility: domain.CommunityPeerVisible, Date: 102,
})
if err != nil {
t.Fatal(err)
}
if _, err := communityService.TogglePeerLink(ctx, owner.ID, domain.CommunityTogglePeerLinkRequest{
CommunityID: community.Community.ID,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: publicPreview.Channel.ID},
Visibility: domain.CommunityPeerVisible,
Date: 103,
}); err != nil {
t.Fatal(err)
}
r := New(Config{}, Deps{Users: appusers.NewService(users), Channels: channelService, Communities: communityService}, zaptest.NewLogger(t), clock.System)
for i, channel := range []domain.Channel{linked.Channel, publicPreview.Channel, outside.Channel} {
_, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, Message: "scoped result", RandomID: int64(9000 + i),
})
if err != nil {
t.Fatalf("send channel %d: %v", channel.ID, err)
}
}
req := &tg.MessagesSearchGlobalRequest{
Q: "scoped",
Filter: &tg.InputMessagesFilterEmpty{},
OffsetPeer: &tg.InputPeerEmpty{},
Limit: 20,
}
req.SetCommunity(&tg.InputChannel{ChannelID: 42, AccessHash: 84})
req.SetCommunity(&tg.InputChannel{ChannelID: community.Community.ID, AccessHash: community.Community.AccessHash})
if _, err := r.onMessagesSearchGlobal(WithUserID(context.Background(), 1000000001), req); !tgerr.Is(err, "CHANNEL_INVALID") {
t.Fatalf("community-scoped messages.searchGlobal err = %v, want CHANNEL_INVALID", err)
result, err := r.onMessagesSearchGlobal(WithUserID(ctx, viewer.ID), req)
if err != nil {
t.Fatalf("community-scoped messages.searchGlobal: %v", err)
}
response, ok := result.(*tg.MessagesMessages)
if !ok || len(response.Messages) != 2 {
t.Fatalf("community search result = %#v, want joined and public-preview linked messages", result)
}
gotChannels := map[int64]bool{}
for _, item := range response.Messages {
message, ok := item.(*tg.Message)
if !ok {
t.Fatalf("community search message = %#v, want channel message", item)
}
peer, ok := message.PeerID.(*tg.PeerChannel)
if !ok {
t.Fatalf("community search message peer = %#v", message.PeerID)
}
gotChannels[peer.ChannelID] = true
}
if !gotChannels[linked.Channel.ID] || !gotChannels[publicPreview.Channel.ID] || gotChannels[outside.Channel.ID] {
t.Fatalf("community search channels = %+v", gotChannels)
}
emptyReq := &tg.MessagesSearchGlobalRequest{
Filter: &tg.InputMessagesFilterEmpty{},
OffsetPeer: &tg.InputPeerEmpty{},
Limit: 20,
}
emptyReq.SetCommunity(&tg.InputChannel{ChannelID: community.Community.ID, AccessHash: community.Community.AccessHash})
emptyResult, err := r.onMessagesSearchGlobal(WithUserID(ctx, viewer.ID), emptyReq)
if err != nil {
t.Fatalf("empty community-scoped messages.searchGlobal: %v", err)
}
emptyResponse, ok := emptyResult.(*tg.MessagesMessages)
if !ok || len(emptyResponse.Messages) != 0 || len(emptyResponse.Chats) != 1 {
t.Fatalf("empty community search result = %#v, want empty messages with validated Community chat", emptyResult)
}
if got, ok := emptyResponse.Chats[0].(*tg.Community); !ok || got.ID != community.Community.ID {
t.Fatalf("empty community search chat = %#v, want Community %d", emptyResponse.Chats[0], community.Community.ID)
}
badHashReq := &tg.MessagesSearchGlobalRequest{
Filter: &tg.InputMessagesFilterEmpty{},
OffsetPeer: &tg.InputPeerEmpty{},
Limit: 20,
}
badHashReq.SetCommunity(&tg.InputChannel{ChannelID: community.Community.ID, AccessHash: community.Community.AccessHash + 1})
if _, err := r.onMessagesSearchGlobal(WithUserID(ctx, viewer.ID), badHashReq); err == nil || !tgerr.Is(err, "CHANNEL_PRIVATE") {
t.Fatalf("empty community search wrong access hash err = %v, want CHANNEL_PRIVATE", err)
}
}

View file

@ -402,7 +402,7 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
if err != nil {
return nil, err
}
if filter.Hash != 0 {
if filter.Hash != 0 && r.deps.Communities == nil {
hashCheck, err := r.deps.Dialogs.GetDialogsHash(ctx, userID, filter)
if err != nil {
return nil, internalErr()
@ -415,6 +415,10 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
if err != nil {
return nil, internalErr()
}
list, err = r.withCommunityDialogList(ctx, userID, filter, list)
if err != nil {
return nil, communityErr(err)
}
if ClientTypeFrom(ctx) == ClientTypeTDesktop && tdesktop.ShouldMergePinnedIntoInitialDialogs(filter) {
pinned, err := r.pinnedDialogsList(ctx, userID, domain.DialogMainFolderID)
if err != nil {
@ -422,7 +426,7 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
}
list = tdesktop.MergeInitialDialogsWithPinned(list, pinned)
}
if filter.Hash != 0 && list.Hash == filter.Hash {
if filter.Hash != 0 && r.deps.Communities == nil && list.Hash == filter.Hash {
return &tg.MessagesDialogsNotModified{Count: list.Count}, nil
}
return r.tgMessagesDialogs(ctx, userID, r.withDialogListPresence(ctx, userID, list)), nil
@ -481,14 +485,31 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
if err := r.checkCatchupRateLimit(ctx, userID, peerDialogsRateLimitKeyPrefix); err != nil {
return nil, err
}
regularPeers := make([]domain.Peer, 0, len(domainPeers))
communityIDs := make([]int64, 0)
for _, peer := range domainPeers {
if peer.Type == domain.PeerTypeCommunity {
communityIDs = append(communityIDs, peer.ID)
} else {
regularPeers = append(regularPeers, peer)
}
}
var list domain.DialogList
if len(domainPeers) > 0 && r.deps.Dialogs != nil {
if len(regularPeers) > 0 && r.deps.Dialogs != nil {
var err error
list, err = r.deps.Dialogs.GetPeerDialogs(ctx, userID, domainPeers)
list, err = r.deps.Dialogs.GetPeerDialogs(ctx, userID, regularPeers)
if err != nil {
return nil, internalErr()
}
}
if len(communityIDs) > 0 && r.deps.Communities != nil {
views, err := r.deps.Communities.GetMany(ctx, userID, communityIDs)
if err != nil {
return nil, communityErr(err)
}
list.Communities = append(list.Communities, views...)
list.Count += len(views)
}
st := domain.UpdateState{Date: int(r.clock.Now().Unix())}
if r.deps.Updates != nil {
var err error

View file

@ -260,6 +260,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router {
r.registerMessages(d)
r.registerStickers(d)
r.registerChannels(d)
r.registerCommunities(d)
r.registerUpload(d)
r.registerPhotos(d)
r.registerFolders(d)

View file

@ -1067,21 +1067,36 @@ func (r *Router) onStoriesGetPeerMaxIDs(ctx context.Context, id []tg.InputPeerCl
return nil, internalErr()
}
peers := make([]domain.Peer, 0, len(id))
for _, input := range id {
positions := make([]int, 0, len(id))
result := make([]tg.RecentStory, len(id))
for i, input := range id {
if _, community, err := r.maybeCommunityFromInputPeer(ctx, userID, input); err != nil {
return nil, err
} else if community {
// Communities are projected as channel peers in dialog lists, but do
// not own stories. Keep the batch positional by returning an empty
// recentStory at this index and resolve every ordinary peer normally.
continue
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input)
if err != nil {
return nil, err
}
peers = append(peers, peer)
positions = append(positions, i)
}
if r.deps.Stories == nil || userID == 0 {
return make([]tg.RecentStory, len(id)), nil
return result, nil
}
recent, err := r.deps.Stories.GetPeerMaxIDs(ctx, userID, peers, int(r.clock.Now().Unix()))
if err != nil {
return nil, storyErr(err)
}
return tgRecentStories(alignStoryRecentByPeer(peers, recent)), nil
aligned := tgRecentStories(alignStoryRecentByPeer(peers, recent))
for i, position := range positions {
result[position] = aligned[i]
}
return result, nil
}
func alignStoryRecentByPeer(peers []domain.Peer, recent []domain.RecentStory) []domain.RecentStory {