diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index b4f75444..b3e1f771 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -30,6 +30,7 @@ import ( botsapp "telesrv/internal/app/bots" channelapp "telesrv/internal/app/channels" chatlistsapp "telesrv/internal/app/chatlists" + communitiesapp "telesrv/internal/app/communities" "telesrv/internal/app/contacts" "telesrv/internal/app/dialogs" ephemeralapp "telesrv/internal/app/ephemeral" @@ -389,6 +390,7 @@ func run(logger *zap.Logger) error { postgres.WithChannelMemberCache(channelMemberCache), postgres.WithChannelDialogCache(channelDialogCache), postgres.WithChannelBoostCache(channelBoostCache)) + communityStore := postgres.NewCommunityStore(pool, channelIDAllocator, channelMessageIDAllocator) pollStore := postgres.NewPollStore(pool) mediaStore := postgres.NewMediaStore(pool) // 头像投影缓存:所有 projector 共用一层短 TTL owner→头像缓存,消除高频「返回用户」RPC @@ -716,6 +718,7 @@ func run(logger *zap.Logger) error { channelapp.WithReadModelVersions(readModelVersionStore), channelapp.WithSendPermissionChecker(adminService), ) + communitiesService := communitiesapp.NewService(communityStore) ephemeralService := ephemeralapp.NewService(ephemeralStore, channelsService, usersService, botsService) chatlistsService := chatlistsapp.NewService( chatlistStore, @@ -807,6 +810,7 @@ func run(logger *zap.Logger) error { Messages: messagesService, Translation: translationService, Channels: channelsService, + Communities: communitiesService, Files: filesService, Bots: botsService, Polls: pollsapp.NewService(pollStore), diff --git a/deploy/migrations/0122_communities.down.sql b/deploy/migrations/0122_communities.down.sql new file mode 100644 index 00000000..38cc8fac --- /dev/null +++ b/deploy/migrations/0122_communities.down.sql @@ -0,0 +1,20 @@ +ALTER TABLE public.user_update_events + DROP CONSTRAINT IF EXISTS user_update_events_peer_type_check; +ALTER TABLE public.user_update_events + ADD CONSTRAINT user_update_events_peer_type_check + CHECK (peer_type IS NULL OR peer_type IN ('user','channel')); + +DELETE FROM public.notify_settings WHERE scope_kind='peer' AND peer_type='community'; + +DROP TRIGGER IF EXISTS users_linked_community_read_model_changed ON public.users; +DROP FUNCTION IF EXISTS public.telesrv_notify_user_linked_community_read_model(); + +DROP INDEX IF EXISTS public.channels_linked_community_idx; +DROP INDEX IF EXISTS public.users_linked_community_idx; +ALTER TABLE public.channels DROP COLUMN IF EXISTS linked_community_id; +ALTER TABLE public.users DROP COLUMN IF EXISTS linked_community_id; +DROP TABLE IF EXISTS public.community_user_states; +DROP TABLE IF EXISTS public.community_peer_link_requests; +DROP TABLE IF EXISTS public.community_peer_links; +DROP TABLE IF EXISTS public.community_members; +DROP TABLE IF EXISTS public.communities; diff --git a/deploy/migrations/0122_communities.up.sql b/deploy/migrations/0122_communities.up.sql new file mode 100644 index 00000000..a61d2d38 --- /dev/null +++ b/deploy/migrations/0122_communities.up.sql @@ -0,0 +1,130 @@ +-- Layer 228 Communities are aggregation containers. Linked peer dialogs keep +-- their own messages/read state/pts; these tables only persist the container, +-- links, moderation, pending requests and per-user dialog presentation. +ALTER TABLE public.user_update_events + DROP CONSTRAINT IF EXISTS user_update_events_peer_type_check; +ALTER TABLE public.user_update_events + ADD CONSTRAINT user_update_events_peer_type_check + CHECK (peer_type IS NULL OR peer_type IN ('user','channel','community')); + +CREATE TABLE public.communities ( + id bigint PRIMARY KEY, + access_hash bigint NOT NULL, + creator_user_id bigint NOT NULL REFERENCES public.users(id), + title text NOT NULL, + about text DEFAULT ''::text NOT NULL, + default_banned_rights jsonb DEFAULT '{}'::jsonb NOT NULL, + photo_id bigint DEFAULT 0 NOT NULL, + photo_dc_id integer DEFAULT 0 NOT NULL, + photo_stripped bytea DEFAULT '\x'::bytea NOT NULL, + date integer NOT NULL, + deleted boolean DEFAULT false NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT communities_positive_check CHECK (id > 0 AND creator_user_id > 0), + CONSTRAINT communities_title_check CHECK (length(btrim(title)) > 0) +); + +CREATE UNIQUE INDEX communities_access_hash_idx ON public.communities(access_hash); +CREATE INDEX communities_creator_idx ON public.communities(creator_user_id, id) WHERE NOT deleted; + +CREATE TABLE public.community_members ( + community_id bigint NOT NULL REFERENCES public.communities(id) ON DELETE CASCADE, + user_id bigint NOT NULL REFERENCES public.users(id), + role text DEFAULT 'member'::text NOT NULL, + status text DEFAULT 'active'::text NOT NULL, + admin_rights jsonb DEFAULT '{}'::jsonb NOT NULL, + rank text DEFAULT ''::text NOT NULL, + date integer NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY (community_id, user_id), + CONSTRAINT community_members_role_check CHECK (role IN ('creator','admin','member')), + CONSTRAINT community_members_status_check CHECK (status IN ('active','kicked')) +); + +CREATE UNIQUE INDEX community_one_creator_idx + ON public.community_members(community_id) WHERE role = 'creator' AND status = 'active'; +CREATE INDEX community_members_user_idx ON public.community_members(user_id, community_id) WHERE status = 'active'; +CREATE INDEX community_members_kicked_idx ON public.community_members(community_id, user_id) WHERE status = 'kicked'; + +CREATE TABLE public.community_peer_links ( + community_id bigint NOT NULL REFERENCES public.communities(id) ON DELETE CASCADE, + peer_type text NOT NULL, + peer_id bigint NOT NULL, + visibility text NOT NULL, + created_by bigint NOT NULL REFERENCES public.users(id), + date integer NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY (community_id, peer_type, peer_id), + CONSTRAINT community_peer_links_type_check CHECK (peer_type IN ('channel','user')), + CONSTRAINT community_peer_links_visibility_check CHECK (visibility IN ('visible','hidden')), + CONSTRAINT community_peer_links_positive_check CHECK (peer_id > 0 AND created_by > 0) +); + +-- A group/channel/bot may belong to only one Community. +CREATE UNIQUE INDEX community_peer_links_unique_peer_idx ON public.community_peer_links(peer_type, peer_id); +CREATE INDEX community_peer_links_community_idx ON public.community_peer_links(community_id, date, peer_type, peer_id); + +CREATE TABLE public.community_peer_link_requests ( + community_id bigint NOT NULL REFERENCES public.communities(id) ON DELETE CASCADE, + peer_type text NOT NULL, + peer_id bigint NOT NULL, + requested_by bigint NOT NULL REFERENCES public.users(id), + visibility text NOT NULL, + date integer NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY (community_id, peer_type, peer_id), + CONSTRAINT community_peer_requests_type_check CHECK (peer_type IN ('channel','user')), + CONSTRAINT community_peer_requests_visibility_check CHECK (visibility IN ('visible','hidden')), + CONSTRAINT community_peer_requests_positive_check CHECK (peer_id > 0 AND requested_by > 0) +); + +CREATE INDEX community_peer_requests_page_idx + ON public.community_peer_link_requests(community_id, date DESC, peer_type, peer_id); + +CREATE TABLE public.community_user_states ( + community_id bigint NOT NULL REFERENCES public.communities(id) ON DELETE CASCADE, + user_id bigint NOT NULL REFERENCES public.users(id), + collapsed boolean DEFAULT false NOT NULL, + pinned boolean DEFAULT false NOT NULL, + pinned_order integer DEFAULT 0 NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY (community_id, user_id), + CONSTRAINT community_user_states_order_check CHECK (pinned_order >= 0) +); + +CREATE INDEX community_user_states_pinned_idx + ON public.community_user_states(user_id, pinned_order, community_id) WHERE pinned; + +ALTER TABLE public.users + ADD COLUMN linked_community_id bigint DEFAULT 0 NOT NULL; +ALTER TABLE public.channels + ADD COLUMN linked_community_id bigint DEFAULT 0 NOT NULL; + +CREATE INDEX users_linked_community_idx ON public.users(linked_community_id) WHERE linked_community_id <> 0; +CREATE INDEX channels_linked_community_idx ON public.channels(linked_community_id) WHERE linked_community_id <> 0; + +-- linked_community_id is part of the Layer 228 user projection. The legacy +-- user-base trigger intentionally lists projected columns and therefore does +-- not notice this newly-added column; emit the same read-model bumps here so +-- Redis/base-user and contact projections cannot retain a stale bot link. +CREATE FUNCTION public.telesrv_notify_user_linked_community_read_model() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + PERFORM telesrv_bump_read_model_version('user_base', NEW.id, 'user', NEW.id); + PERFORM telesrv_bump_read_model_version('contact_account', c.user_id, 'user', c.user_id) + FROM contacts c + WHERE c.contact_user_id = NEW.id; + PERFORM telesrv_bump_private_dialog_light_for_user(NEW.id); + RETURN NULL; +END; +$$; + +CREATE TRIGGER users_linked_community_read_model_changed +AFTER UPDATE OF linked_community_id ON public.users +FOR EACH ROW +WHEN (OLD.linked_community_id IS DISTINCT FROM NEW.linked_community_id) +EXECUTE FUNCTION public.telesrv_notify_user_linked_community_read_model(); + +-- Community notification settings reuse the existing peer-scoped table with a +-- distinct peer_type. No new scope_kind is required. diff --git a/internal/app/channels/service.go b/internal/app/channels/service.go index 4affbb43..222e6edd 100644 --- a/internal/app/channels/service.go +++ b/internal/app/channels/service.go @@ -508,6 +508,15 @@ func (s *Service) ListAdminedPublicChannels(ctx context.Context, userID int64) ( return s.channels.ListAdminedPublicChannels(ctx, userID) } +// ListCommunityLinkableChannels returns owned/administered channels that are not +// already linked to another Community. Private megagroups are valid candidates. +func (s *Service) ListCommunityLinkableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 { + return nil, nil + } + return s.channels.ListCommunityLinkableChannels(ctx, userID) +} + // ListStoryPostableChannels returns channels where user can publish stories. func (s *Service) ListStoryPostableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) { if s == nil || s.channels == nil || userID == 0 { diff --git a/internal/app/communities/service.go b/internal/app/communities/service.go new file mode 100644 index 00000000..2c14b1f4 --- /dev/null +++ b/internal/app/communities/service.go @@ -0,0 +1,185 @@ +package communities + +import ( + "context" + "strings" + "unicode/utf8" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// Service owns Community business validation. The store is the aggregate +// transaction boundary because link changes span community/link and peer rows. +type Service struct { + communities store.CommunityStore +} + +func NewService(communities store.CommunityStore) *Service { + return &Service{communities: communities} +} + +func validPeer(peer domain.Peer) bool { + return peer.ID > 0 && (peer.Type == domain.PeerTypeChannel || peer.Type == domain.PeerTypeUser) +} + +func validVisibility(v domain.CommunityPeerVisibility) bool { + return v == domain.CommunityPeerVisible || v == domain.CommunityPeerHidden +} + +func (s *Service) Create(ctx context.Context, userID int64, req domain.CreateCommunityRequest) (domain.CommunityView, error) { + if s == nil || s.communities == nil || userID == 0 || !validPeer(req.InitialPeer) || !validVisibility(req.Visibility) { + return domain.CommunityView{}, domain.ErrCommunityInvalid + } + req.CreatorUserID = userID + req.Title = strings.TrimSpace(req.Title) + req.About = strings.TrimSpace(req.About) + if req.Title == "" || utf8.RuneCountInString(req.Title) > domain.MaxCommunityTitleRunes { + return domain.CommunityView{}, domain.ErrChannelTitleInvalid + } + if utf8.RuneCountInString(req.About) > domain.MaxCommunityAboutRunes { + return domain.CommunityView{}, domain.ErrAboutTooLong + } + return s.communities.CreateCommunity(ctx, req) +} + +func (s *Service) Get(ctx context.Context, userID, communityID int64) (domain.CommunityView, error) { + if s == nil || s.communities == nil || userID == 0 || communityID == 0 { + return domain.CommunityView{}, domain.ErrCommunityInvalid + } + return s.communities.GetCommunity(ctx, userID, communityID) +} + +func (s *Service) GetMany(ctx context.Context, userID int64, ids []int64) ([]domain.CommunityView, error) { + if s == nil || s.communities == nil || userID == 0 { + return nil, domain.ErrCommunityInvalid + } + return s.communities.GetCommunities(ctx, userID, ids) +} + +func (s *Service) ListJoined(ctx context.Context, userID int64) ([]domain.CommunityView, error) { + if s == nil || s.communities == nil || userID == 0 { + return nil, domain.ErrCommunityInvalid + } + return s.communities.ListJoinedCommunities(ctx, userID) +} + +func (s *Service) TogglePeerLink(ctx context.Context, userID int64, req domain.CommunityTogglePeerLinkRequest) (domain.CommunityTogglePeerLinkResult, error) { + if s == nil || s.communities == nil || userID == 0 || req.CommunityID == 0 || !validPeer(req.Peer) { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityInvalid + } + if !req.Deleted && !validVisibility(req.Visibility) { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityPeerInvalid + } + req.ActorUserID = userID + return s.communities.ToggleCommunityPeerLink(ctx, req) +} + +func (s *Service) SetCollapsed(ctx context.Context, userID, communityID int64, collapsed bool) (domain.CommunityView, bool, error) { + if s == nil || s.communities == nil || userID == 0 || communityID == 0 { + return domain.CommunityView{}, false, domain.ErrCommunityInvalid + } + return s.communities.SetCommunityCollapsed(ctx, userID, communityID, collapsed) +} + +func (s *Service) ListPeerLinkRequests(ctx context.Context, userID, communityID int64, offset string, limit int) (domain.CommunityPeerLinkRequestPage, error) { + if s == nil || s.communities == nil || userID == 0 || communityID == 0 { + return domain.CommunityPeerLinkRequestPage{}, domain.ErrCommunityInvalid + } + if limit <= 0 || limit > domain.MaxCommunityLinkRequests { + limit = domain.MaxCommunityLinkRequests + } + return s.communities.ListCommunityPeerLinkRequests(ctx, userID, communityID, offset, limit) +} + +func (s *Service) DecidePeerLinkRequest(ctx context.Context, userID, communityID int64, peer domain.Peer, reject bool, date int) (domain.CommunityTogglePeerLinkResult, error) { + if s == nil || s.communities == nil || userID == 0 || communityID == 0 || !validPeer(peer) { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityInvalid + } + return s.communities.DecideCommunityPeerLinkRequest(ctx, userID, communityID, peer, reject, date) +} + +func (s *Service) DecideAllPeerLinkRequests(ctx context.Context, userID, communityID int64, reject bool, date int) ([]domain.CommunityTogglePeerLinkResult, error) { + if s == nil || s.communities == nil || userID == 0 || communityID == 0 { + return nil, domain.ErrCommunityInvalid + } + return s.communities.DecideAllCommunityPeerLinkRequests(ctx, userID, communityID, reject, date) +} + +func (s *Service) ToggleParticipantBanned(ctx context.Context, userID, communityID, participantUserID int64, unban bool, date int) (domain.CommunityParticipantBanResult, error) { + if s == nil || s.communities == nil || userID == 0 || communityID == 0 || participantUserID == 0 { + return domain.CommunityParticipantBanResult{}, domain.ErrCommunityInvalid + } + return s.communities.ToggleCommunityParticipantBanned(ctx, userID, communityID, participantUserID, unban, date) +} + +func (s *Service) ParticipantJoinedChats(ctx context.Context, userID, communityID, participantUserID int64) (domain.CommunityParticipantJoinedChats, error) { + if s == nil || s.communities == nil || userID == 0 || communityID == 0 || participantUserID == 0 { + return domain.CommunityParticipantJoinedChats{}, domain.ErrCommunityInvalid + } + return s.communities.GetCommunityParticipantJoinedChats(ctx, userID, communityID, participantUserID) +} + +func (s *Service) Participants(ctx context.Context, userID, communityID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.CommunityParticipantList, error) { + if s == nil || s.communities == nil || userID == 0 || communityID == 0 { + return domain.CommunityParticipantList{}, domain.ErrCommunityInvalid + } + if offset < 0 { + offset = 0 + } + if offset > domain.MaxChannelParticipantsOffset { + offset = domain.MaxChannelParticipantsOffset + } + if limit <= 0 || limit > domain.MaxCommunityParticipants { + limit = domain.MaxCommunityParticipants + } + return s.communities.ListCommunityParticipants(ctx, userID, communityID, filter, offset, limit) +} + +func (s *Service) EditTitle(ctx context.Context, userID, communityID int64, title string) (domain.CommunityView, bool, error) { + title = strings.TrimSpace(title) + if title == "" || utf8.RuneCountInString(title) > domain.MaxCommunityTitleRunes { + return domain.CommunityView{}, false, domain.ErrChannelTitleInvalid + } + return s.communities.EditCommunityTitle(ctx, userID, communityID, title) +} + +func (s *Service) EditAbout(ctx context.Context, userID, communityID int64, about string) (domain.CommunityView, bool, error) { + about = strings.TrimSpace(about) + if utf8.RuneCountInString(about) > domain.MaxCommunityAboutRunes { + return domain.CommunityView{}, false, domain.ErrAboutTooLong + } + return s.communities.EditCommunityAbout(ctx, userID, communityID, about) +} + +func (s *Service) EditAdmin(ctx context.Context, userID int64, req domain.CommunityEditAdminRequest) (domain.CommunityView, bool, error) { + if req.CommunityID == 0 || req.UserID == 0 || userID == 0 { + return domain.CommunityView{}, false, domain.ErrCommunityInvalid + } + req.ActorUserID = userID + return s.communities.EditCommunityAdmin(ctx, req) +} + +func (s *Service) EditDefaultBannedRights(ctx context.Context, userID, communityID int64, rights domain.ChannelBannedRights) (domain.CommunityView, bool, error) { + return s.communities.EditCommunityDefaultBannedRights(ctx, userID, communityID, rights) +} + +func (s *Service) SetPhoto(ctx context.Context, userID, communityID int64, photo *domain.Photo, date int) (domain.CommunityView, bool, error) { + return s.communities.SetCommunityPhoto(ctx, userID, communityID, photo, date) +} + +func (s *Service) Delete(ctx context.Context, userID, communityID int64, date int) (domain.CommunityView, []domain.Peer, error) { + return s.communities.DeleteCommunity(ctx, userID, communityID, date) +} + +func (s *Service) SetPinned(ctx context.Context, userID, communityID int64, pinned bool) (bool, error) { + return s.communities.SetCommunityPinned(ctx, userID, communityID, pinned) +} + +func (s *Service) ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) (bool, error) { + return s.communities.ReorderCommunityPinned(ctx, userID, order, force) +} + +func (s *Service) SearchScope(ctx context.Context, userID, communityID int64) (domain.CommunitySearchScope, error) { + return s.communities.CommunitySearchScope(ctx, userID, communityID) +} diff --git a/internal/domain/channel.go b/internal/domain/channel.go index e9f34f33..5bc861da 100644 --- a/internal/domain/channel.go +++ b/internal/domain/channel.go @@ -280,7 +280,10 @@ type ChannelBannedRights struct { SendPlain bool EditRank bool SendReactions bool - UntilDate int + // ManageLinkedPeers is the Layer 228 default restriction used by Communities: + // true means only admins may add peers; false lets members submit requests. + ManageLinkedPeers bool + UntilDate int } // ChannelReactionPolicyType describes which reactions are allowed in a channel. @@ -431,6 +434,10 @@ type Channel struct { // megagroup && (public || has_geo || has_link) 判定是否拉取候选列表。 HasLink bool LinkedChatID int64 + // LinkedCommunityID is the unique Community containing this group/channel. + // A channel can belong to at most one Community and Communities themselves + // are stored in a separate aggregate, never in channels. + LinkedCommunityID int64 // Monoforum 标记本频道是「频道私信(Direct Messages)」的 monoforum 虚拟频道。 // LinkedMonoforumID:母频道指向其 monoforum;monoforum 反向指向母频道(双向)。 Monoforum bool @@ -561,11 +568,15 @@ const ( ChannelActionStarGiftUnique ChannelMessageActionType = "star_gift_unique" // ChannelActionSetChatWallpaper 映射 messageActionSetChatWallPaper:频道外观页设置 wallpaper。 ChannelActionSetChatWallpaper ChannelMessageActionType = "set_chat_wallpaper" + // ChannelActionChangeCommunity maps messageActionChangeCommunity. A non-zero + // CommunityID means linked; zero means unlinked. + ChannelActionChangeCommunity ChannelMessageActionType = "change_community" ) // ChannelMessageAction describes a service action without depending on tg.*. type ChannelMessageAction struct { Type ChannelMessageActionType + CommunityID int64 Title string IconColor int IconEmojiID int64 @@ -2039,18 +2050,24 @@ type ChannelSearchPostsRequest struct { // ChannelGlobalSearchRequest describes a bounded messages.searchGlobal page // over channel/supergroup messages visible to the current account. type ChannelGlobalSearchRequest struct { - Query string - BroadcastsOnly bool - GroupsOnly bool - MusicOnly bool - HasFolderID bool - FolderID int - OffsetRate int - OffsetChannelID int64 - OffsetID int - MinDate int - MaxDate int - Limit int + Query string + ChannelIDs []int64 + RestrictChannelIDs bool + // AllowPublicPreview includes linked public channels that the account can + // preview without joining. It is enabled only by Layer 228 Community-scoped + // search; ordinary global search remains joined-dialog-only. + AllowPublicPreview bool + BroadcastsOnly bool + GroupsOnly bool + MusicOnly bool + HasFolderID bool + FolderID int + OffsetRate int + OffsetChannelID int64 + OffsetID int + MinDate int + MaxDate int + Limit int } // ChannelRepliesFilter describes messages.getReplies query conditions. diff --git a/internal/domain/community.go b/internal/domain/community.go new file mode 100644 index 00000000..144222d0 --- /dev/null +++ b/internal/domain/community.go @@ -0,0 +1,214 @@ +package domain + +import "errors" + +const ( + MaxCommunityPeers = 100 + MaxCommunityBotPeers = 100 + MaxCommunityLinkRequests = 100 + MaxCommunityTitleRunes = 128 + MaxCommunityAboutRunes = 255 + MaxCommunityParticipants = 200 +) + +var ( + ErrCommunityInvalid = errors.New("community invalid") + ErrCommunityPrivate = errors.New("community private") + ErrCommunityAdminRequired = errors.New("community admin required") + ErrCommunityCreatorRequired = errors.New("community creator required") + ErrCommunityPeerInvalid = errors.New("community peer invalid") + ErrCommunityPeerLinked = errors.New("community peer already linked") + ErrCommunityPeersTooMuch = errors.New("community peers too much") + ErrCommunityRequestCreated = errors.New("community request created") + ErrCommunityRequestMissing = errors.New("community request missing") + ErrCommunityParticipantInvalid = errors.New("community participant invalid") +) + +// Community is the Layer 228 aggregation container. It intentionally has no +// message/read/pts fields: linked dialogs remain the only message truth. +type Community struct { + ID int64 + AccessHash int64 + CreatorUserID int64 + Title string + About string + Date int + Deleted bool + DefaultBannedRights ChannelBannedRights + PhotoID int64 + PhotoDCID int + PhotoStripped []byte +} + +type CommunityMemberRole string + +const ( + CommunityRoleCreator CommunityMemberRole = "creator" + CommunityRoleAdmin CommunityMemberRole = "admin" + CommunityRoleMember CommunityMemberRole = "member" +) + +type CommunityMemberStatus string + +const ( + CommunityMemberActive CommunityMemberStatus = "active" + CommunityMemberKicked CommunityMemberStatus = "kicked" +) + +type CommunityMember struct { + CommunityID int64 + UserID int64 + Role CommunityMemberRole + Status CommunityMemberStatus + AdminRights ChannelAdminRights + Rank string + Date int +} + +func (m CommunityMember) Active() bool { return m.Status == CommunityMemberActive } + +func (m CommunityMember) CanManageLinkedPeers() bool { + return m.Active() && (m.Role == CommunityRoleCreator || + (m.Role == CommunityRoleAdmin && m.AdminRights.ManageLinkedPeers)) +} + +func (m CommunityMember) CanChangeInfo() bool { + return m.Active() && (m.Role == CommunityRoleCreator || + (m.Role == CommunityRoleAdmin && m.AdminRights.ChangeInfo)) +} + +func (m CommunityMember) CanAddAdmins() bool { + return m.Active() && (m.Role == CommunityRoleCreator || + (m.Role == CommunityRoleAdmin && m.AdminRights.AddAdmins)) +} + +func (m CommunityMember) CanBanUsers() bool { + return m.Active() && (m.Role == CommunityRoleCreator || + (m.Role == CommunityRoleAdmin && m.AdminRights.BanUsers)) +} + +type CommunityPeerVisibility string + +const ( + CommunityPeerVisible CommunityPeerVisibility = "visible" + CommunityPeerHidden CommunityPeerVisibility = "hidden" +) + +type CommunityPeerLink struct { + CommunityID int64 + Peer Peer + Visibility CommunityPeerVisibility + CanViewHistory bool + CreatedBy int64 + Date int +} + +func (l CommunityPeerLink) Visible() bool { return l.Visibility == CommunityPeerVisible } + +type CommunityPeerLinkRequest struct { + CommunityID int64 + Peer Peer + RequestedBy int64 + Visibility CommunityPeerVisibility + Date int +} + +type CommunityUserState struct { + CommunityID int64 + UserID int64 + Collapsed bool + Pinned bool + PinnedOrder int + NotifySettings *PeerNotifySettings +} + +type CommunityView struct { + Community Community + Self CommunityMember + State CommunityUserState + Links []CommunityPeerLink + Channels []Channel + Users []User + ServiceMessages []SendChannelMessageResult + AdminsCount int + KickedCount int + PendingRequests int + Forbidden bool +} + +func (v CommunityView) Creator() bool { + return v.Self.Active() && v.Self.Role == CommunityRoleCreator +} + +type CreateCommunityRequest struct { + CreatorUserID int64 + Title string + About string + InitialPeer Peer + Visibility CommunityPeerVisibility + Date int +} + +type CommunityTogglePeerLinkRequest struct { + ActorUserID int64 + CommunityID int64 + Peer Peer + Visibility CommunityPeerVisibility + Deleted bool + RequestOnly bool + Date int +} + +type CommunityTogglePeerLinkResult struct { + Community Community + Peer Peer + RequestedBy int64 + Link *CommunityPeerLink + ServiceMessage *SendChannelMessageResult + Removed bool + RequestCreated bool +} + +type CommunityPeerLinkRequestPage struct { + TotalCount int + Requests []CommunityPeerLinkRequest + NextOffset string + Channels []Channel + Users []User +} + +type CommunityParticipantJoinedChats struct { + CreatorChatIDs []int64 + JoinedChatIDs []int64 + Channels []Channel + Users []User +} + +type CommunityParticipantList struct { + Community Community + Count int + Participants []CommunityMember + Users []User + Hash int64 +} + +type CommunityParticipantBanResult struct { + Changed bool + ChannelBans []EditChannelBannedResult + RemovedLinks []CommunityTogglePeerLinkResult +} + +type CommunityEditAdminRequest struct { + ActorUserID int64 + CommunityID int64 + UserID int64 + Rights ChannelAdminRights + Rank string + Date int +} + +type CommunitySearchScope struct { + CommunityID int64 + ChannelIDs []int64 + BotUserIDs []int64 +} diff --git a/internal/domain/dialog.go b/internal/domain/dialog.go index ab89e027..96cc5a73 100644 --- a/internal/domain/dialog.go +++ b/internal/domain/dialog.go @@ -6,6 +6,9 @@ type PeerType string const ( PeerTypeUser PeerType = "user" PeerTypeChannel PeerType = "channel" + // PeerTypeCommunity identifies a Layer 228 Community container. Communities + // have dialog pin/notify state but never own messages, read boundaries or pts. + PeerTypeCommunity PeerType = "community" // PeerTypeFolder 仅用于 dialog 置顶事件中表达 dialogPeerFolder // (archive folder 行本身被置顶/取消置顶),ID 为 folder_id。 PeerTypeFolder PeerType = "folder" @@ -175,6 +178,7 @@ type DialogList struct { ChannelMessages []ChannelMessage Users []User Channels []Channel + Communities []CommunityView State UpdateState Hash int64 Count int diff --git a/internal/domain/message.go b/internal/domain/message.go index 3df48d84..679bd7d3 100644 --- a/internal/domain/message.go +++ b/internal/domain/message.go @@ -242,6 +242,10 @@ type MessageFilter struct { // SavedPeer 非零时仅返回 self-chat 中该 saved 子会话的消息 // (messages.getSavedHistory);Peer 必须同时是 self。 SavedPeer Peer + // PeerIDs restricts a global private search to these user peers. Empty is a + // valid restricted set, so RestrictPeerIDs carries presence separately. + PeerIDs []int64 + RestrictPeerIDs bool } // SendPrivateTextRequest 是私聊文本/媒体发送命令。 diff --git a/internal/domain/user.go b/internal/domain/user.go index 61035f0a..372ff3f3 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -124,6 +124,9 @@ type User struct { // PersonalChannelID 是资料页展示的「个人频道」(account.updatePersonalChannel); // 0 表示未设置。资料投影时按它取频道对象与最新一帖。 PersonalChannelID int64 + // LinkedCommunityID is the single Community containing this bot. Ordinary + // users must keep it zero; the community aggregate enforces that invariant. + LinkedCommunityID int64 Color PeerColor ProfileColor PeerColor // Profile photo fields are filled by app-layer user projection. PhotoID==0 表示无头像。 diff --git a/internal/rpc/account_notify.go b/internal/rpc/account_notify.go index 1e207539..8003c5e8 100644 --- a/internal/rpc/account_notify.go +++ b/internal/rpc/account_notify.go @@ -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 } diff --git a/internal/rpc/channels_core.go b/internal/rpc/channels_core.go index d1c34e50..93628634 100644 --- a/internal/rpc/channels_core.go +++ b/internal/rpc/channels_core.go @@ -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 = © + } + } + 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}) { diff --git a/internal/rpc/channels_legacy_chat.go b/internal/rpc/channels_legacy_chat.go index 9b15a651..2ec6cbec 100644 --- a/internal/rpc/channels_legacy_chat.go +++ b/internal/rpc/channels_legacy_chat.go @@ -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 diff --git a/internal/rpc/channels_members.go b/internal/rpc/channels_members.go index 7ec4b87d..ea9d69b7 100644 --- a/internal/rpc/channels_members.go +++ b/internal/rpc/channels_members.go @@ -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, diff --git a/internal/rpc/channels_settings.go b/internal/rpc/channels_settings.go index fde35b8e..3e185d0c 100644 --- a/internal/rpc/channels_settings.go +++ b/internal/rpc/channels_settings.go @@ -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 } diff --git a/internal/rpc/channels_stubs.go b/internal/rpc/channels_stubs.go index 3a0f951d..118aa7cb 100644 --- a/internal/rpc/channels_stubs.go +++ b/internal/rpc/channels_stubs.go @@ -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 diff --git a/internal/rpc/communities.go b/internal/rpc/communities.go new file mode 100644 index 00000000..65221d10 --- /dev/null +++ b/internal/rpc/communities.go @@ -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 +} diff --git a/internal/rpc/communities_register.go b/internal/rpc/communities_register.go new file mode 100644 index 00000000..303e60ab --- /dev/null +++ b/internal/rpc/communities_register.go @@ -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) + }) +} diff --git a/internal/rpc/communities_rpc_test.go b/internal/rpc/communities_rpc_test.go new file mode 100644 index 00000000..ca073d9c --- /dev/null +++ b/internal/rpc/communities_rpc_test.go @@ -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) + } +} diff --git a/internal/rpc/convert_channels_core.go b/internal/rpc/convert_channels_core.go index 615dbf7a..44ae66fe 100644 --- a/internal/rpc/convert_channels_core.go +++ b/internal/rpc/convert_channels_core.go @@ -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, } } diff --git a/internal/rpc/convert_communities.go b/internal/rpc/convert_communities.go new file mode 100644 index 00000000..95853df6 --- /dev/null +++ b/internal/rpc/convert_communities.go @@ -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)} +} diff --git a/internal/rpc/convert_dialogs.go b/internal/rpc/convert_dialogs.go index d554cbe1..a77b5105 100644 --- a/internal/rpc/convert_dialogs.go +++ b/internal/rpc/convert_dialogs.go @@ -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 diff --git a/internal/rpc/convert_users.go b/internal/rpc/convert_users.go index 1d2e05d7..1093453b 100644 --- a/internal/rpc/convert_users.go +++ b/internal/rpc/convert_users.go @@ -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 } diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go index 8d13b082..2341b2bb 100644 --- a/internal/rpc/deps.go +++ b/internal/rpc/deps.go @@ -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 diff --git a/internal/rpc/dialogs_pinned.go b/internal/rpc/dialogs_pinned.go index d125bbec..62236267 100644 --- a/internal/rpc/dialogs_pinned.go +++ b/internal/rpc/dialogs_pinned.go @@ -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 +} diff --git a/internal/rpc/messages_dialogs.go b/internal/rpc/messages_dialogs.go index ee5d4e4a..4c69995d 100644 --- a/internal/rpc/messages_dialogs.go +++ b/internal/rpc/messages_dialogs.go @@ -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 { diff --git a/internal/rpc/messages_history.go b/internal/rpc/messages_history.go index 65e34811..5ba0e8e8 100644 --- a/internal/rpc/messages_history.go +++ b/internal/rpc/messages_history.go @@ -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 diff --git a/internal/rpc/messages_history_rpc_test.go b/internal/rpc/messages_history_rpc_test.go index 6e93452c..ebcf66dd 100644 --- a/internal/rpc/messages_history_rpc_test.go +++ b/internal/rpc/messages_history_rpc_test.go @@ -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) } } diff --git a/internal/rpc/messages_register.go b/internal/rpc/messages_register.go index 09c63aa8..515d19fc 100644 --- a/internal/rpc/messages_register.go +++ b/internal/rpc/messages_register.go @@ -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 diff --git a/internal/rpc/router.go b/internal/rpc/router.go index 9948a856..97a560b8 100644 --- a/internal/rpc/router.go +++ b/internal/rpc/router.go @@ -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) diff --git a/internal/rpc/stories.go b/internal/rpc/stories.go index c4ab37d8..a7e4364b 100644 --- a/internal/rpc/stories.go +++ b/internal/rpc/stories.go @@ -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 { diff --git a/internal/store/channel.go b/internal/store/channel.go index deaf06f2..29b0fe88 100644 --- a/internal/store/channel.go +++ b/internal/store/channel.go @@ -36,6 +36,7 @@ type ChannelStore interface { UpdateUsername(ctx context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) SetChannelVerified(ctx context.Context, channelID int64, verified bool) (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) // ResolvePublicChannelUsername resolves an active public channel/supergroup. diff --git a/internal/store/community.go b/internal/store/community.go new file mode 100644 index 00000000..aff5c7fb --- /dev/null +++ b/internal/store/community.go @@ -0,0 +1,34 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// CommunityStore persists the Layer 228 Community aggregate. Mutations that +// touch a link and the linked peer's denormalized linked_community_id must be +// atomic in durable implementations. +type CommunityStore interface { + CreateCommunity(ctx context.Context, req domain.CreateCommunityRequest) (domain.CommunityView, error) + GetCommunity(ctx context.Context, viewerUserID, communityID int64) (domain.CommunityView, error) + GetCommunities(ctx context.Context, viewerUserID int64, communityIDs []int64) ([]domain.CommunityView, error) + ListJoinedCommunities(ctx context.Context, viewerUserID int64) ([]domain.CommunityView, error) + ToggleCommunityPeerLink(ctx context.Context, req domain.CommunityTogglePeerLinkRequest) (domain.CommunityTogglePeerLinkResult, error) + SetCommunityCollapsed(ctx context.Context, userID, communityID int64, collapsed bool) (domain.CommunityView, bool, error) + ListCommunityPeerLinkRequests(ctx context.Context, viewerUserID, communityID int64, offset string, limit int) (domain.CommunityPeerLinkRequestPage, error) + DecideCommunityPeerLinkRequest(ctx context.Context, actorUserID, communityID int64, peer domain.Peer, reject bool, date int) (domain.CommunityTogglePeerLinkResult, error) + DecideAllCommunityPeerLinkRequests(ctx context.Context, actorUserID, communityID int64, reject bool, date int) ([]domain.CommunityTogglePeerLinkResult, error) + ToggleCommunityParticipantBanned(ctx context.Context, actorUserID, communityID, participantUserID int64, unban bool, date int) (domain.CommunityParticipantBanResult, error) + GetCommunityParticipantJoinedChats(ctx context.Context, viewerUserID, communityID, participantUserID int64) (domain.CommunityParticipantJoinedChats, error) + ListCommunityParticipants(ctx context.Context, viewerUserID, communityID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.CommunityParticipantList, error) + EditCommunityTitle(ctx context.Context, actorUserID, communityID int64, title string) (domain.CommunityView, bool, error) + EditCommunityAbout(ctx context.Context, actorUserID, communityID int64, about string) (domain.CommunityView, bool, error) + EditCommunityAdmin(ctx context.Context, req domain.CommunityEditAdminRequest) (domain.CommunityView, bool, error) + EditCommunityDefaultBannedRights(ctx context.Context, actorUserID, communityID int64, rights domain.ChannelBannedRights) (domain.CommunityView, bool, error) + SetCommunityPhoto(ctx context.Context, actorUserID, communityID int64, photo *domain.Photo, date int) (domain.CommunityView, bool, error) + DeleteCommunity(ctx context.Context, actorUserID, communityID int64, date int) (domain.CommunityView, []domain.Peer, error) + SetCommunityPinned(ctx context.Context, userID, communityID int64, pinned bool) (changed bool, err error) + ReorderCommunityPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) (changed bool, err error) + CommunitySearchScope(ctx context.Context, viewerUserID, communityID int64) (domain.CommunitySearchScope, error) +} diff --git a/internal/store/memory/channel_members.go b/internal/store/memory/channel_members.go index 94f6c491..3aec3389 100644 --- a/internal/store/memory/channel_members.go +++ b/internal/store/memory/channel_members.go @@ -655,6 +655,31 @@ func (s *ChannelStore) ListAdminedPublicChannels(_ context.Context, userID int64 return append([]domain.Channel(nil), out...), nil } +func (s *ChannelStore) ListCommunityLinkableChannels(_ context.Context, userID int64) ([]domain.Channel, error) { + if userID == 0 { + return nil, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]domain.Channel, 0) + for channelID, members := range s.members { + member := members[userID] + if member.Status != domain.ChannelMemberActive || !isChannelAdmin(member) { + continue + } + channel, ok := s.channels[channelID] + if !ok || channel.Deleted || channel.Monoforum || channel.LinkedCommunityID != 0 { + continue + } + out = append(out, channel) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID }) + if len(out) > domain.MaxCommunityPeers { + out = out[:domain.MaxCommunityPeers] + } + return append([]domain.Channel(nil), out...), nil +} + func (s *ChannelStore) ListStoryPostableChannels(_ context.Context, userID int64) ([]domain.Channel, error) { if userID == 0 { return nil, nil diff --git a/internal/store/memory/channel_message_history.go b/internal/store/memory/channel_message_history.go index b9134681..0a881f6d 100644 --- a/internal/store/memory/channel_message_history.go +++ b/internal/store/memory/channel_message_history.go @@ -192,7 +192,16 @@ func (s *ChannelStore) SearchJoinedMessages(_ context.Context, viewerUserID int6 s.mu.RLock() defer s.mu.RUnlock() hits := make([]hit, 0, req.Limit+1) + channelIDs := make(map[int64]struct{}, len(req.ChannelIDs)) + for _, id := range req.ChannelIDs { + channelIDs[id] = struct{}{} + } for channelID, channel := range s.channels { + if req.RestrictChannelIDs { + if _, ok := channelIDs[channelID]; !ok { + continue + } + } if channel.Deleted { continue } @@ -203,7 +212,10 @@ func (s *ChannelStore) SearchJoinedMessages(_ context.Context, viewerUserID int6 continue } member, ok := s.members[channelID][viewerUserID] - if !ok || member.Status != domain.ChannelMemberActive || member.BannedRights.ViewMessages { + joined := ok && member.Status == domain.ChannelMemberActive && !member.BannedRights.ViewMessages + publicPreview := req.AllowPublicPreview && publicPreviewableChannel(channel) && + (!ok || member.Status != domain.ChannelMemberKicked && !member.BannedRights.ViewMessages) + if !joined && !publicPreview { continue } if req.HasFolderID { @@ -219,7 +231,7 @@ func (s *ChannelStore) SearchJoinedMessages(_ context.Context, viewerUserID int6 if query == "" && !req.MusicOnly || query != "" && strings.TrimSpace(msg.Body) == "" { continue } - if member.AvailableMinID > 0 && msg.ID <= member.AvailableMinID { + if joined && member.AvailableMinID > 0 && msg.ID <= member.AvailableMinID { continue } if req.MinDate > 0 && msg.Date <= req.MinDate { diff --git a/internal/store/memory/community.go b/internal/store/memory/community.go new file mode 100644 index 00000000..a6635416 --- /dev/null +++ b/internal/store/memory/community.go @@ -0,0 +1,1112 @@ +package memory + +import ( + "context" + "encoding/base64" + "errors" + "hash/fnv" + "sort" + "strconv" + "strings" + "sync" + + "telesrv/internal/domain" +) + +type CommunityStore struct { + mu sync.RWMutex + users *UserStore + channels *ChannelStore + bots *BotStore + dialogs *DialogStore + nextID int64 + nextHash int64 + communities map[int64]domain.Community + members map[int64]map[int64]domain.CommunityMember + links map[int64]map[domain.Peer]domain.CommunityPeerLink + requests map[int64]map[domain.Peer]domain.CommunityPeerLinkRequest + states map[int64]map[int64]domain.CommunityUserState +} + +func NewCommunityStore(users *UserStore, channels *ChannelStore, bots *BotStore, dialogs *DialogStore) *CommunityStore { + return &CommunityStore{ + users: users, channels: channels, bots: bots, dialogs: dialogs, + nextID: 3000000000, nextHash: 990000000000, + communities: map[int64]domain.Community{}, members: map[int64]map[int64]domain.CommunityMember{}, + links: map[int64]map[domain.Peer]domain.CommunityPeerLink{}, requests: map[int64]map[domain.Peer]domain.CommunityPeerLinkRequest{}, + states: map[int64]map[int64]domain.CommunityUserState{}, + } +} + +func cloneCommunity(c domain.Community) domain.Community { + c.PhotoStripped = append([]byte(nil), c.PhotoStripped...) + return c +} +func cloneCommunityView(v domain.CommunityView) domain.CommunityView { + v.Community = cloneCommunity(v.Community) + v.Links = append([]domain.CommunityPeerLink(nil), v.Links...) + v.ServiceMessages = append([]domain.SendChannelMessageResult(nil), v.ServiceMessages...) + return v +} + +func (s *CommunityStore) communityLocked(id int64) (domain.Community, error) { + c, ok := s.communities[id] + if !ok || c.Deleted { + return domain.Community{}, domain.ErrCommunityInvalid + } + return c, nil +} + +func (s *CommunityStore) derivedMemberLocked(c domain.Community, userID int64) (domain.CommunityMember, bool) { + if m, ok := s.members[c.ID][userID]; ok { + return m, true + } + if s.channels != nil { + s.channels.mu.RLock() + for p := range s.links[c.ID] { + if p.Type != domain.PeerTypeChannel { + continue + } + if m, ok := s.channels.members[p.ID][userID]; ok && m.Status == domain.ChannelMemberActive { + s.channels.mu.RUnlock() + return domain.CommunityMember{CommunityID: c.ID, UserID: userID, Role: domain.CommunityRoleMember, Status: domain.CommunityMemberActive, Date: c.Date}, true + } + } + s.channels.mu.RUnlock() + } + if s.dialogs != nil { + s.dialogs.mu.RLock() + for p := range s.links[c.ID] { + if p.Type != domain.PeerTypeUser { + continue + } + for _, d := range s.dialogs.m[userID].Dialogs { + if d.Peer == p && d.TopMessage > 0 { + s.dialogs.mu.RUnlock() + return domain.CommunityMember{CommunityID: c.ID, UserID: userID, Role: domain.CommunityRoleMember, Status: domain.CommunityMemberActive, Date: c.Date}, true + } + } + } + s.dialogs.mu.RUnlock() + } + return domain.CommunityMember{}, false +} + +func (s *CommunityStore) viewLocked(userID, id int64) (domain.CommunityView, error) { + c, e := s.communityLocked(id) + if e != nil { + return domain.CommunityView{}, e + } + m, ok := s.derivedMemberLocked(c, userID) + if !ok || !m.Active() { + return domain.CommunityView{Community: cloneCommunity(c), Self: m, Forbidden: true}, domain.ErrCommunityPrivate + } + v := domain.CommunityView{Community: cloneCommunity(c), Self: m, State: s.states[id][userID]} + v.State.CommunityID = id + v.State.UserID = userID + for _, l := range s.links[id] { + joined := false + inherentlyViewable := l.Peer.Type == domain.PeerTypeUser + if l.Peer.Type == domain.PeerTypeChannel && s.channels != nil { + s.channels.mu.RLock() + cm, ok := s.channels.members[l.Peer.ID][userID] + joined = ok && cm.Status == domain.ChannelMemberActive + if channel, ok := s.channels.channels[l.Peer.ID]; ok { + inherentlyViewable = publicPreviewableChannel(channel) + } + s.channels.mu.RUnlock() + } else if l.Peer.Type == domain.PeerTypeUser && s.dialogs != nil { + s.dialogs.mu.RLock() + for _, d := range s.dialogs.m[userID].Dialogs { + if d.Peer == l.Peer && d.TopMessage > 0 { + joined = true + break + } + } + s.dialogs.mu.RUnlock() + } + if l.Visibility == domain.CommunityPeerHidden && !joined && !m.CanManageLinkedPeers() { + continue + } + // Community administration reveals hidden links but never grants access + // to a linked private channel's history. TDesktop uses this bit to decide + // between opening History directly and showing the join prompt. + l.CanViewHistory = joined || inherentlyViewable + v.Links = append(v.Links, l) + } + if s.channels != nil { + s.channels.mu.RLock() + for _, l := range v.Links { + if l.Peer.Type == domain.PeerTypeChannel { + if ch, ok := s.channels.channels[l.Peer.ID]; ok { + v.Channels = append(v.Channels, cloneChannel(ch)) + } + } + } + s.channels.mu.RUnlock() + } + if s.users != nil { + s.users.mu.RLock() + for _, l := range v.Links { + if l.Peer.Type == domain.PeerTypeUser { + if u, ok := s.users.byID[l.Peer.ID]; ok { + v.Users = append(v.Users, u) + } + } + } + s.users.mu.RUnlock() + } + for _, cm := range s.members[id] { + if cm.Status == domain.CommunityMemberKicked { + v.KickedCount++ + } else if cm.Role == domain.CommunityRoleCreator || cm.Role == domain.CommunityRoleAdmin { + v.AdminsCount++ + } + } + v.PendingRequests = len(s.requests[id]) + sort.Slice(v.Links, func(i, j int) bool { + if v.Links[i].Date != v.Links[j].Date { + return v.Links[i].Date < v.Links[j].Date + } + if v.Links[i].Peer.Type != v.Links[j].Peer.Type { + return v.Links[i].Peer.Type < v.Links[j].Peer.Type + } + return v.Links[i].Peer.ID < v.Links[j].Peer.ID + }) + return v, nil +} + +func (s *CommunityStore) GetCommunity(_ context.Context, userID, id int64) (domain.CommunityView, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.viewLocked(userID, id) +} +func (s *CommunityStore) GetCommunities(ctx context.Context, userID int64, ids []int64) ([]domain.CommunityView, error) { + s.mu.RLock() + defer s.mu.RUnlock() + out := []domain.CommunityView{} + seen := map[int64]struct{}{} + for _, id := range ids { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + v, e := s.viewLocked(userID, id) + if errors.Is(e, domain.ErrCommunityInvalid) || errors.Is(e, domain.ErrCommunityPrivate) { + continue + } + if e != nil { + return nil, e + } + out = append(out, v) + } + return out, nil +} +func (s *CommunityStore) ListJoinedCommunities(ctx context.Context, userID int64) ([]domain.CommunityView, error) { + s.mu.RLock() + ids := make([]int64, 0, len(s.communities)) + for id := range s.communities { + ids = append(ids, id) + } + s.mu.RUnlock() + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return s.GetCommunities(ctx, userID, ids) +} + +func (s *CommunityStore) validatePeerLocked(actor int64, p domain.Peer) error { + for _, byPeer := range s.links { + if _, ok := byPeer[p]; ok { + return domain.ErrCommunityPeerLinked + } + } + switch p.Type { + case domain.PeerTypeChannel: + if s.channels == nil { + return domain.ErrCommunityPeerInvalid + } + s.channels.mu.RLock() + defer s.channels.mu.RUnlock() + ch, ok := s.channels.channels[p.ID] + if !ok || ch.Deleted || ch.Monoforum || ch.LinkedCommunityID != 0 { + return domain.ErrCommunityPeerInvalid + } + m, ok := s.channels.members[p.ID][actor] + if !ok || m.Status != domain.ChannelMemberActive || (m.Role != domain.ChannelRoleCreator && m.Role != domain.ChannelRoleAdmin) { + return domain.ErrCommunityAdminRequired + } + case domain.PeerTypeUser: + if s.users == nil || s.bots == nil { + return domain.ErrCommunityPeerInvalid + } + s.users.mu.RLock() + u, ok := s.users.byID[p.ID] + s.users.mu.RUnlock() + if !ok || !u.Bot || u.Deleted || u.LinkedCommunityID != 0 { + return domain.ErrCommunityPeerInvalid + } + s.bots.mu.RLock() + profile, ok := s.bots.byID[p.ID] + s.bots.mu.RUnlock() + if !ok || profile.OwnerUserID != actor { + return domain.ErrCommunityAdminRequired + } + default: + return domain.ErrCommunityPeerInvalid + } + return nil +} + +func (s *CommunityStore) setPeerLinkLocked(p domain.Peer, id int64) error { + switch p.Type { + case domain.PeerTypeChannel: + s.channels.mu.Lock() + ch, ok := s.channels.channels[p.ID] + if ok { + ch.LinkedCommunityID = id + s.channels.channels[p.ID] = ch + } + s.channels.mu.Unlock() + if !ok { + return domain.ErrCommunityPeerInvalid + } + case domain.PeerTypeUser: + s.users.mu.Lock() + u, ok := s.users.byID[p.ID] + if ok { + u.LinkedCommunityID = id + s.users.byID[p.ID] = u + } + s.users.mu.Unlock() + if !ok { + return domain.ErrCommunityPeerInvalid + } + default: + return domain.ErrCommunityPeerInvalid + } + return nil +} + +func (s *CommunityStore) insertLinkLocked(id, actor int64, p domain.Peer, v domain.CommunityPeerVisibility, date int) (domain.CommunityPeerLink, error) { + if e := s.validatePeerLocked(actor, p); e != nil { + return domain.CommunityPeerLink{}, e + } + channels, bots := 0, 0 + for peer := range s.links[id] { + if peer.Type == domain.PeerTypeChannel { + channels++ + } else { + bots++ + } + } + if (p.Type == domain.PeerTypeChannel && channels >= domain.MaxCommunityPeers) || (p.Type == domain.PeerTypeUser && bots >= domain.MaxCommunityBotPeers) { + return domain.CommunityPeerLink{}, domain.ErrCommunityPeersTooMuch + } + l := domain.CommunityPeerLink{CommunityID: id, Peer: p, Visibility: v, CanViewHistory: true, CreatedBy: actor, Date: date} + if s.links[id] == nil { + s.links[id] = map[domain.Peer]domain.CommunityPeerLink{} + } + s.links[id][p] = l + if e := s.setPeerLinkLocked(p, id); e != nil { + delete(s.links[id], p) + return domain.CommunityPeerLink{}, e + } + return l, nil +} + +func (s *CommunityStore) unlinkLocked(id int64, peer domain.Peer) { + delete(s.links[id], peer) + _ = s.setPeerLinkLocked(peer, 0) +} + +func (s *CommunityStore) CreateCommunity(_ context.Context, req domain.CreateCommunityRequest) (domain.CommunityView, error) { + s.mu.Lock() + defer s.mu.Unlock() + if e := s.validatePeerLocked(req.CreatorUserID, req.InitialPeer); e != nil { + return domain.CommunityView{}, e + } + id := s.nextID + s.nextID++ + if s.channels != nil { + s.channels.mu.Lock() + if id < s.channels.nextID { + id = s.channels.nextID + } + s.channels.nextID = id + 1 + s.channels.mu.Unlock() + } + hash := s.nextHash + s.nextHash++ + c := domain.Community{ID: id, AccessHash: hash, CreatorUserID: req.CreatorUserID, Title: req.Title, About: req.About, Date: req.Date} + s.communities[id] = c + s.members[id] = map[int64]domain.CommunityMember{req.CreatorUserID: {CommunityID: id, UserID: req.CreatorUserID, Role: domain.CommunityRoleCreator, Status: domain.CommunityMemberActive, AdminRights: domain.CreatorChannelAdminRights(), Date: req.Date}} + s.links[id] = map[domain.Peer]domain.CommunityPeerLink{} + s.requests[id] = map[domain.Peer]domain.CommunityPeerLinkRequest{} + s.states[id] = map[int64]domain.CommunityUserState{} + l, e := s.insertLinkLocked(id, req.CreatorUserID, req.InitialPeer, req.Visibility, req.Date) + if e != nil { + delete(s.communities, id) + return domain.CommunityView{}, e + } + serviceMessage, e := s.appendCommunityServiceMessageLocked(req.InitialPeer, req.CreatorUserID, req.Date, c.ID) + if e != nil { + s.unlinkLocked(id, req.InitialPeer) + delete(s.communities, id) + return domain.CommunityView{}, e + } + view := domain.CommunityView{Community: c, Self: s.members[id][req.CreatorUserID], Links: []domain.CommunityPeerLink{l}, AdminsCount: 1} + if serviceMessage != nil { + view.ServiceMessages = append(view.ServiceMessages, *serviceMessage) + } + return view, nil +} + +func (s *CommunityStore) actorLocked(id, user int64) (domain.Community, domain.CommunityMember, error) { + c, e := s.communityLocked(id) + if e != nil { + return domain.Community{}, domain.CommunityMember{}, e + } + m, ok := s.derivedMemberLocked(c, user) + if !ok || !m.Active() { + return domain.Community{}, domain.CommunityMember{}, domain.ErrCommunityPrivate + } + return c, m, nil +} + +func (s *CommunityStore) ToggleCommunityPeerLink(_ context.Context, req domain.CommunityTogglePeerLinkRequest) (domain.CommunityTogglePeerLinkResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + c, m, e := s.actorLocked(req.CommunityID, req.ActorUserID) + if e != nil { + return domain.CommunityTogglePeerLinkResult{}, e + } + if req.Deleted { + if !m.CanManageLinkedPeers() { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityAdminRequired + } + if _, ok := s.links[c.ID][req.Peer]; !ok { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityPeerInvalid + } + if e := s.setPeerLinkLocked(req.Peer, 0); e != nil { + return domain.CommunityTogglePeerLinkResult{}, e + } + serviceMessage, e := s.appendCommunityServiceMessageLocked(req.Peer, req.ActorUserID, req.Date, 0) + if e != nil { + _ = s.setPeerLinkLocked(req.Peer, c.ID) + return domain.CommunityTogglePeerLinkResult{}, e + } + delete(s.links[c.ID], req.Peer) + return domain.CommunityTogglePeerLinkResult{Community: c, Peer: req.Peer, ServiceMessage: serviceMessage, Removed: true}, nil + } + if m.CanManageLinkedPeers() { + l, e := s.insertLinkLocked(c.ID, req.ActorUserID, req.Peer, req.Visibility, req.Date) + if e != nil { + return domain.CommunityTogglePeerLinkResult{}, e + } + serviceMessage, e := s.appendCommunityServiceMessageLocked(req.Peer, req.ActorUserID, req.Date, c.ID) + if e != nil { + s.unlinkLocked(c.ID, req.Peer) + return domain.CommunityTogglePeerLinkResult{}, e + } + return domain.CommunityTogglePeerLinkResult{Community: c, Peer: req.Peer, Link: &l, ServiceMessage: serviceMessage}, nil + } + if c.DefaultBannedRights.ManageLinkedPeers { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityAdminRequired + } + if e := s.validatePeerLocked(req.ActorUserID, req.Peer); e != nil { + return domain.CommunityTogglePeerLinkResult{}, e + } + s.requests[c.ID][req.Peer] = domain.CommunityPeerLinkRequest{CommunityID: c.ID, Peer: req.Peer, RequestedBy: req.ActorUserID, Visibility: req.Visibility, Date: req.Date} + return domain.CommunityTogglePeerLinkResult{Community: c, Peer: req.Peer, RequestCreated: true}, nil +} + +func (s *CommunityStore) appendCommunityServiceMessageLocked(peer domain.Peer, actorUserID int64, date int, communityID int64) (*domain.SendChannelMessageResult, error) { + if peer.Type != domain.PeerTypeChannel || s.channels == nil { + return nil, nil + } + s.channels.mu.Lock() + defer s.channels.mu.Unlock() + channel, ok := s.channels.channels[peer.ID] + if !ok || channel.Deleted { + return nil, domain.ErrChannelInvalid + } + message, event := s.channels.appendChannelServiceMessageLocked(peer.ID, actorUserID, date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChangeCommunity, + CommunityID: communityID, + }) + channel = s.channels.channels[peer.ID] + channel.TopMessageID = message.ID + channel.Pts = event.Pts + s.channels.channels[peer.ID] = channel + return &domain.SendChannelMessageResult{ + Channel: channel, Message: message, Event: event, + Recipients: s.channels.activeMemberIDsLocked(peer.ID, 0, 0), + }, nil +} + +func (s *CommunityStore) SetCommunityCollapsed(_ context.Context, user, id int64, collapsed bool) (domain.CommunityView, bool, error) { + s.mu.Lock() + c, _, e := s.actorLocked(id, user) + if e != nil { + s.mu.Unlock() + return domain.CommunityView{}, false, e + } + state := s.states[id][user] + changed := state.Collapsed != collapsed + state.CommunityID, state.UserID, state.Collapsed = id, user, collapsed + if !collapsed { + state.Pinned = false + state.PinnedOrder = 0 + } + s.states[id][user] = state + v, e := s.viewLocked(user, c.ID) + s.mu.Unlock() + return v, changed, e +} + +func encodeMemoryCommunityOffset(n int) string { + return base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(n))) +} +func decodeMemoryCommunityOffset(raw string) (int, error) { + if raw == "" { + return 0, nil + } + b, e := base64.RawURLEncoding.DecodeString(raw) + if e != nil { + return 0, domain.ErrCommunityInvalid + } + n, e := strconv.Atoi(string(b)) + if e != nil || n < 0 { + return 0, domain.ErrCommunityInvalid + } + return n, nil +} + +func (s *CommunityStore) ListCommunityPeerLinkRequests(_ context.Context, user, id int64, offset string, limit int) (domain.CommunityPeerLinkRequestPage, error) { + s.mu.RLock() + defer s.mu.RUnlock() + _, m, e := s.actorLocked(id, user) + if e != nil { + return domain.CommunityPeerLinkRequestPage{}, e + } + if !m.CanManageLinkedPeers() { + return domain.CommunityPeerLinkRequestPage{}, domain.ErrCommunityAdminRequired + } + start, e := decodeMemoryCommunityOffset(offset) + if e != nil { + return domain.CommunityPeerLinkRequestPage{}, e + } + items := make([]domain.CommunityPeerLinkRequest, 0, len(s.requests[id])) + for _, r := range s.requests[id] { + items = append(items, r) + } + sort.Slice(items, func(i, j int) bool { + if items[i].Date != items[j].Date { + return items[i].Date > items[j].Date + } + if items[i].Peer.Type != items[j].Peer.Type { + return items[i].Peer.Type > items[j].Peer.Type + } + return items[i].Peer.ID > items[j].Peer.ID + }) + page := domain.CommunityPeerLinkRequestPage{TotalCount: len(items)} + if start > len(items) { + start = len(items) + } + end := start + limit + if end > len(items) { + end = len(items) + } + page.Requests = append(page.Requests, items[start:end]...) + if end < len(items) { + page.NextOffset = encodeMemoryCommunityOffset(end) + } + if s.channels != nil { + s.channels.mu.RLock() + for _, r := range page.Requests { + if r.Peer.Type == domain.PeerTypeChannel { + if ch, ok := s.channels.channels[r.Peer.ID]; ok { + page.Channels = append(page.Channels, cloneChannel(ch)) + } + } + } + s.channels.mu.RUnlock() + } + if s.users != nil { + s.users.mu.RLock() + seen := map[int64]struct{}{} + for _, r := range page.Requests { + ids := []int64{r.RequestedBy} + if r.Peer.Type == domain.PeerTypeUser { + ids = append(ids, r.Peer.ID) + } + for _, uid := range ids { + if _, ok := seen[uid]; ok { + continue + } + seen[uid] = struct{}{} + if u, ok := s.users.byID[uid]; ok { + page.Users = append(page.Users, u) + } + } + } + s.users.mu.RUnlock() + } + return page, nil +} + +func (s *CommunityStore) decideLocked(actor, id int64, p domain.Peer, reject bool, date int) (domain.CommunityTogglePeerLinkResult, error) { + c, m, e := s.actorLocked(id, actor) + if e != nil { + return domain.CommunityTogglePeerLinkResult{}, e + } + if !m.CanManageLinkedPeers() { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityAdminRequired + } + r, ok := s.requests[id][p] + if !ok { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityRequestMissing + } + delete(s.requests[id], p) + if reject { + return domain.CommunityTogglePeerLinkResult{Community: c, Peer: p, RequestedBy: r.RequestedBy}, nil + } + l, e := s.insertLinkLocked(id, r.RequestedBy, p, r.Visibility, date) + if e != nil { + s.requests[id][p] = r + return domain.CommunityTogglePeerLinkResult{}, e + } + serviceMessage, e := s.appendCommunityServiceMessageLocked(p, actor, date, c.ID) + if e != nil { + s.unlinkLocked(id, p) + return domain.CommunityTogglePeerLinkResult{}, e + } + return domain.CommunityTogglePeerLinkResult{Community: c, Peer: p, RequestedBy: r.RequestedBy, Link: &l, ServiceMessage: serviceMessage}, nil +} +func (s *CommunityStore) DecideCommunityPeerLinkRequest(_ context.Context, actor, id int64, p domain.Peer, reject bool, date int) (domain.CommunityTogglePeerLinkResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.decideLocked(actor, id, p, reject, date) +} +func (s *CommunityStore) DecideAllCommunityPeerLinkRequests(_ context.Context, actor, id int64, reject bool, date int) ([]domain.CommunityTogglePeerLinkResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + _, m, e := s.actorLocked(id, actor) + if e != nil { + return nil, e + } + if !m.CanManageLinkedPeers() { + return nil, domain.ErrCommunityAdminRequired + } + peers := make([]domain.Peer, 0, len(s.requests[id])) + for p := range s.requests[id] { + peers = append(peers, p) + } + sort.Slice(peers, func(i, j int) bool { + if peers[i].Type != peers[j].Type { + return peers[i].Type < peers[j].Type + } + return peers[i].ID < peers[j].ID + }) + if reject { + s.requests[id] = map[domain.Peer]domain.CommunityPeerLinkRequest{} + return make([]domain.CommunityTogglePeerLinkResult, len(peers)), nil + } + channels, bots := 0, 0 + for p := range s.links[id] { + if p.Type == domain.PeerTypeChannel { + channels++ + } else { + bots++ + } + } + for _, p := range peers { + if p.Type == domain.PeerTypeChannel { + channels++ + } else { + bots++ + } + if channels > domain.MaxCommunityPeers || bots > domain.MaxCommunityBotPeers { + return nil, domain.ErrCommunityPeersTooMuch + } + r := s.requests[id][p] + if e := s.validatePeerLocked(r.RequestedBy, p); e != nil { + return nil, e + } + } + out := []domain.CommunityTogglePeerLinkResult{} + for _, p := range peers { + r := s.requests[id][p] + l, e := s.insertLinkLocked(id, r.RequestedBy, p, r.Visibility, date) + if e != nil { + return nil, e + } + delete(s.requests[id], p) + serviceMessage, e := s.appendCommunityServiceMessageLocked(p, actor, date, s.communities[id].ID) + if e != nil { + s.unlinkLocked(id, p) + return nil, e + } + out = append(out, domain.CommunityTogglePeerLinkResult{Community: s.communities[id], Peer: p, RequestedBy: r.RequestedBy, Link: &l, ServiceMessage: serviceMessage}) + } + return out, nil +} + +func (s *CommunityStore) GetCommunityParticipantJoinedChats(_ context.Context, user, id, participant int64) (domain.CommunityParticipantJoinedChats, error) { + s.mu.RLock() + defer s.mu.RUnlock() + _, m, e := s.actorLocked(id, user) + if e != nil { + return domain.CommunityParticipantJoinedChats{}, e + } + if !m.CanBanUsers() && user != participant { + return domain.CommunityParticipantJoinedChats{}, domain.ErrCommunityAdminRequired + } + out := domain.CommunityParticipantJoinedChats{} + if s.channels != nil { + s.channels.mu.RLock() + for p := range s.links[id] { + if p.Type != domain.PeerTypeChannel { + continue + } + cm, ok := s.channels.members[p.ID][participant] + if !ok || cm.Status != domain.ChannelMemberActive { + continue + } + out.JoinedChatIDs = append(out.JoinedChatIDs, p.ID) + if cm.Role == domain.ChannelRoleCreator { + out.CreatorChatIDs = append(out.CreatorChatIDs, p.ID) + } + if ch, ok := s.channels.channels[p.ID]; ok { + out.Channels = append(out.Channels, cloneChannel(ch)) + } + } + s.channels.mu.RUnlock() + } + if s.users != nil { + s.users.mu.RLock() + if participantUser, ok := s.users.byID[participant]; ok { + out.Users = append(out.Users, participantUser) + } + s.users.mu.RUnlock() + } + sort.Slice(out.JoinedChatIDs, func(i, j int) bool { return out.JoinedChatIDs[i] < out.JoinedChatIDs[j] }) + sort.Slice(out.CreatorChatIDs, func(i, j int) bool { return out.CreatorChatIDs[i] < out.CreatorChatIDs[j] }) + return out, nil +} + +func (s *CommunityStore) ToggleCommunityParticipantBanned(_ context.Context, actor, id, participant int64, unban bool, date int) (domain.CommunityParticipantBanResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + c, m, e := s.actorLocked(id, actor) + if e != nil { + return domain.CommunityParticipantBanResult{}, e + } + if !m.CanBanUsers() || participant == c.CreatorUserID { + return domain.CommunityParticipantBanResult{}, domain.ErrCommunityAdminRequired + } + if unban { + old, ok := s.members[id][participant] + if ok && old.Role == domain.CommunityRoleMember && old.Status == domain.CommunityMemberKicked { + delete(s.members[id], participant) + return domain.CommunityParticipantBanResult{Changed: true}, nil + } + return domain.CommunityParticipantBanResult{}, nil + } + if participantMember, ok := s.derivedMemberLocked(c, participant); !ok || + (participantMember.Status != domain.CommunityMemberActive && participantMember.Status != domain.CommunityMemberKicked) { + return domain.CommunityParticipantBanResult{}, domain.ErrCommunityParticipantInvalid + } + old, alreadyKicked := s.members[id][participant] + alreadyKicked = alreadyKicked && old.Role == domain.CommunityRoleMember && old.Status == domain.CommunityMemberKicked + result := domain.CommunityParticipantBanResult{} + for p := range s.links[id] { + owned := false + if p.Type == domain.PeerTypeChannel && s.channels != nil { + s.channels.mu.Lock() + ch := s.channels.channels[p.ID] + owned = ch.CreatorUserID == participant + if !owned { + if cm, ok := s.channels.members[p.ID][participant]; ok && cm.Status == domain.ChannelMemberActive { + previous := cm + cm.Status = domain.ChannelMemberKicked + cm.Role = domain.ChannelRoleMember + cm.InviterUserID = actor + cm.LeftAt = date + cm.BannedRights = domain.ChannelBannedRights{ViewMessages: true} + s.channels.members[p.ID][participant] = cm + ch.ParticipantsCount-- + ch.KickedCount++ + s.channels.channels[p.ID] = ch + event := transientChannelParticipantEvent(ch.ID, actor, previous, cm, date) + var serviceMessage domain.ChannelMessage + var serviceEvent domain.ChannelUpdateEvent + if ch.Megagroup { + serviceMessage, serviceEvent = s.channels.appendChannelServiceMessageLocked(ch.ID, actor, date, domain.ChannelMessageAction{Type: domain.ChannelActionChatDelete, UserIDs: []int64{participant}}) + ch = s.channels.channels[p.ID] + ch.TopMessageID, ch.Pts = serviceMessage.ID, serviceEvent.Pts + s.channels.channels[p.ID] = ch + } + recipients := s.channels.activeMemberIDsLocked(p.ID, 0, 0) + recipients = append(recipients, participant) + result.ChannelBans = append(result.ChannelBans, domain.EditChannelBannedResult{ + Channel: ch, Previous: previous, Participant: cm, Event: event, Recipients: recipients, + Date: date, Message: serviceMessage, ServiceEvent: serviceEvent, + }) + } + } + s.channels.mu.Unlock() + } else if p.Type == domain.PeerTypeUser && s.bots != nil { + s.bots.mu.RLock() + owned = s.bots.byID[p.ID].OwnerUserID == participant + s.bots.mu.RUnlock() + } + if owned { + if e := s.setPeerLinkLocked(p, 0); e != nil { + return domain.CommunityParticipantBanResult{}, e + } + serviceMessage, e := s.appendCommunityServiceMessageLocked(p, actor, date, 0) + if e != nil { + _ = s.setPeerLinkLocked(p, c.ID) + return domain.CommunityParticipantBanResult{}, e + } + delete(s.links[id], p) + result.RemovedLinks = append(result.RemovedLinks, domain.CommunityTogglePeerLinkResult{Community: c, Peer: p, Removed: true, ServiceMessage: serviceMessage}) + } + } + if !alreadyKicked { + s.members[id][participant] = domain.CommunityMember{CommunityID: id, UserID: participant, Role: domain.CommunityRoleMember, Status: domain.CommunityMemberKicked, Date: date} + } + result.Changed = !alreadyKicked || len(result.ChannelBans) > 0 || len(result.RemovedLinks) > 0 + return result, nil +} + +func (s *CommunityStore) allParticipantsLocked(id int64) []domain.CommunityMember { + byID := map[int64]domain.CommunityMember{} + for uid, m := range s.members[id] { + byID[uid] = m + } + if s.channels != nil { + s.channels.mu.RLock() + for p := range s.links[id] { + if p.Type != domain.PeerTypeChannel { + continue + } + for uid, cm := range s.channels.members[p.ID] { + if cm.Status == domain.ChannelMemberActive { + if _, ok := byID[uid]; !ok { + byID[uid] = domain.CommunityMember{CommunityID: id, UserID: uid, Role: domain.CommunityRoleMember, Status: domain.CommunityMemberActive} + } + } + } + } + s.channels.mu.RUnlock() + } + out := make([]domain.CommunityMember, 0, len(byID)) + for _, m := range byID { + out = append(out, m) + } + sort.Slice(out, func(i, j int) bool { + rank := func(r domain.CommunityMemberRole) int { + if r == domain.CommunityRoleCreator { + return 0 + } + if r == domain.CommunityRoleAdmin { + return 1 + } + return 2 + } + if rank(out[i].Role) != rank(out[j].Role) { + return rank(out[i].Role) < rank(out[j].Role) + } + return out[i].UserID < out[j].UserID + }) + return out +} +func (s *CommunityStore) ListCommunityParticipants(_ context.Context, user, id int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.CommunityParticipantList, error) { + s.mu.RLock() + defer s.mu.RUnlock() + c, m, e := s.actorLocked(id, user) + if e != nil { + return domain.CommunityParticipantList{}, e + } + restricted := filter.Kind == domain.ChannelParticipantsKicked || filter.Kind == domain.ChannelParticipantsBanned + if restricted && !m.CanManageLinkedPeers() { + return domain.CommunityParticipantList{}, domain.ErrCommunityAdminRequired + } + all := s.allParticipantsLocked(id) + query := strings.ToLower(strings.TrimSpace(filter.Query)) + usersByID := make(map[int64]domain.User, len(all)) + if s.users != nil { + s.users.mu.RLock() + for _, participant := range all { + if user, ok := s.users.byID[participant.UserID]; ok { + usersByID[participant.UserID] = user + } + } + s.users.mu.RUnlock() + } + items := all[:0] + for _, p := range all { + ok := p.Status == domain.CommunityMemberActive + if filter.Kind == domain.ChannelParticipantsAdmins { + ok = ok && (p.Role == domain.CommunityRoleCreator || p.Role == domain.CommunityRoleAdmin) + } else if filter.Kind == domain.ChannelParticipantsKicked || filter.Kind == domain.ChannelParticipantsBanned { + ok = p.Status == domain.CommunityMemberKicked + } + if ok && query != "" { + user := usersByID[p.UserID] + haystack := strings.ToLower(strings.Join([]string{ + strconv.FormatInt(p.UserID, 10), user.FirstName, user.LastName, user.Username, user.Phone, + }, " ")) + ok = strings.Contains(haystack, query) + } + if ok { + items = append(items, p) + } + } + out := domain.CommunityParticipantList{Community: c, Count: len(items)} + if offset > len(items) { + offset = len(items) + } + end := offset + limit + if end > len(items) { + end = len(items) + } + out.Participants = append([]domain.CommunityMember(nil), items[offset:end]...) + h := fnv.New64a() + for _, p := range out.Participants { + _, _ = h.Write([]byte(strconv.FormatInt(p.UserID, 10) + string(p.Role) + string(p.Status))) + } + out.Hash = int64(h.Sum64() & 0x7fffffffffffffff) + for _, p := range out.Participants { + if user, ok := usersByID[p.UserID]; ok { + out.Users = append(out.Users, user) + } + } + return out, nil +} + +func (s *CommunityStore) editViewLocked(user, id int64, can func(domain.CommunityMember) bool, edit func(*domain.Community) bool) (domain.CommunityView, bool, error) { + c, m, e := s.actorLocked(id, user) + if e != nil { + return domain.CommunityView{}, false, e + } + if !can(m) { + return domain.CommunityView{}, false, domain.ErrCommunityAdminRequired + } + changed := edit(&c) + s.communities[id] = c + v, e := s.viewLocked(user, id) + return v, changed, e +} +func (s *CommunityStore) EditCommunityTitle(_ context.Context, user, id int64, title string) (domain.CommunityView, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.editViewLocked(user, id, domain.CommunityMember.CanChangeInfo, func(c *domain.Community) bool { + if c.Title == title { + return false + } + c.Title = title + return true + }) +} +func (s *CommunityStore) EditCommunityAbout(_ context.Context, user, id int64, about string) (domain.CommunityView, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.editViewLocked(user, id, domain.CommunityMember.CanChangeInfo, func(c *domain.Community) bool { + if c.About == about { + return false + } + c.About = about + return true + }) +} +func (s *CommunityStore) EditCommunityDefaultBannedRights(_ context.Context, user, id int64, rights domain.ChannelBannedRights) (domain.CommunityView, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.editViewLocked(user, id, domain.CommunityMember.CanChangeInfo, func(c *domain.Community) bool { + if c.DefaultBannedRights == rights { + return false + } + c.DefaultBannedRights = rights + return true + }) +} +func (s *CommunityStore) SetCommunityPhoto(_ context.Context, user, id int64, photo *domain.Photo, date int) (domain.CommunityView, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.editViewLocked(user, id, domain.CommunityMember.CanChangeInfo, func(c *domain.Community) bool { + pid, dc := int64(0), 0 + var stripped []byte + if photo != nil { + pid, dc = photo.ID, photo.DCID + stripped = domain.StrippedFromSizes(photo.Sizes) + } + if c.PhotoID == pid && c.PhotoDCID == dc && string(c.PhotoStripped) == string(stripped) { + return false + } + c.PhotoID, c.PhotoDCID, c.PhotoStripped = pid, dc, append([]byte(nil), stripped...) + return true + }) +} +func (s *CommunityStore) EditCommunityAdmin(_ context.Context, req domain.CommunityEditAdminRequest) (domain.CommunityView, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + c, m, e := s.actorLocked(req.CommunityID, req.ActorUserID) + if e != nil { + return domain.CommunityView{}, false, e + } + zero := req.Rights == (domain.ChannelAdminRights{}) + if req.UserID == req.ActorUserID && m.Role == domain.CommunityRoleAdmin && zero { + delete(s.members[c.ID], req.UserID) + v, e := s.viewLocked(req.ActorUserID, c.ID) + if errors.Is(e, domain.ErrCommunityPrivate) { + return domain.CommunityView{Community: c, Self: m, Forbidden: true}, true, nil + } + return v, true, e + } + if !m.CanAddAdmins() { + return domain.CommunityView{}, false, domain.ErrCommunityAdminRequired + } + if req.UserID == c.CreatorUserID { + return domain.CommunityView{}, false, domain.ErrCommunityCreatorRequired + } + old, ok := s.members[c.ID][req.UserID] + if zero { + if ok && old.Role == domain.CommunityRoleAdmin { + delete(s.members[c.ID], req.UserID) + v, e := s.viewLocked(req.ActorUserID, c.ID) + return v, true, e + } + v, e := s.viewLocked(req.ActorUserID, c.ID) + return v, false, e + } + s.members[c.ID][req.UserID] = domain.CommunityMember{CommunityID: c.ID, UserID: req.UserID, Role: domain.CommunityRoleAdmin, Status: domain.CommunityMemberActive, AdminRights: req.Rights, Rank: req.Rank, Date: req.Date} + v, e := s.viewLocked(req.ActorUserID, c.ID) + return v, true, e +} + +func (s *CommunityStore) DeleteCommunity(_ context.Context, user, id int64, date int) (domain.CommunityView, []domain.Peer, error) { + s.mu.Lock() + defer s.mu.Unlock() + c, m, e := s.actorLocked(id, user) + if e != nil { + return domain.CommunityView{}, nil, e + } + if m.Role != domain.CommunityRoleCreator { + return domain.CommunityView{}, nil, domain.ErrCommunityCreatorRequired + } + peers := make([]domain.Peer, 0, len(s.links[id])) + serviceMessages := make([]domain.SendChannelMessageResult, 0, len(s.links[id])) + for p := range s.links[id] { + peers = append(peers, p) + if e := s.setPeerLinkLocked(p, 0); e != nil { + return domain.CommunityView{}, nil, e + } + serviceMessage, e := s.appendCommunityServiceMessageLocked(p, user, date, 0) + if e != nil { + _ = s.setPeerLinkLocked(p, c.ID) + return domain.CommunityView{}, nil, e + } + if serviceMessage != nil { + serviceMessages = append(serviceMessages, *serviceMessage) + } + } + c.Deleted = true + c.Title = "" + c.About = "" + s.communities[id] = c + delete(s.links, id) + delete(s.requests, id) + delete(s.states, id) + return domain.CommunityView{Community: c, Self: m, Forbidden: true, ServiceMessages: serviceMessages}, peers, nil +} +func (s *CommunityStore) SetCommunityPinned(_ context.Context, user, id int64, pinned bool) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if _, _, e := s.actorLocked(id, user); e != nil { + return false, e + } + st, ok := s.states[id][user] + if !ok || !st.Collapsed { + return false, domain.ErrCommunityInvalid + } + if st.Pinned == pinned { + return false, nil + } + st.Pinned = pinned + if pinned { + max := 1000000000 + for _, byUser := range s.states { + if x, ok := byUser[user]; ok && x.Pinned && x.PinnedOrder > max { + max = x.PinnedOrder + } + } + st.PinnedOrder = max + 1 + } else { + st.PinnedOrder = 0 + } + s.states[id][user] = st + return true, nil +} +func (s *CommunityStore) ReorderCommunityPinned(_ context.Context, user int64, order []domain.Peer, force bool) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + seen := map[int64]struct{}{} + for _, peer := range order { + if peer.Type != domain.PeerTypeCommunity { + continue + } + if _, ok := seen[peer.ID]; ok { + return false, domain.ErrCommunityInvalid + } + seen[peer.ID] = struct{}{} + st, ok := s.states[peer.ID][user] + if !ok || !st.Collapsed { + return false, domain.ErrCommunityInvalid + } + } + changed := false + for id, byUser := range s.states { + if st, ok := byUser[user]; ok && st.Pinned { + if force { + st.Pinned = false + st.PinnedOrder = 0 + s.states[id][user] = st + changed = true + } + } + } + for i, peer := range order { + if peer.Type != domain.PeerTypeCommunity { + continue + } + st := s.states[peer.ID][user] + pinnedOrder := len(order) - i + if st.Pinned && st.PinnedOrder == pinnedOrder { + continue + } + st.Pinned = true + st.PinnedOrder = pinnedOrder + s.states[peer.ID][user] = st + changed = true + } + return changed, nil +} +func (s *CommunityStore) CommunitySearchScope(ctx context.Context, user, id int64) (domain.CommunitySearchScope, error) { + v, e := s.GetCommunity(ctx, user, id) + if e != nil { + return domain.CommunitySearchScope{}, e + } + out := domain.CommunitySearchScope{CommunityID: id} + for _, l := range v.Links { + if l.Peer.Type == domain.PeerTypeChannel { + if l.CanViewHistory { + out.ChannelIDs = append(out.ChannelIDs, l.Peer.ID) + } + } else { + out.BotUserIDs = append(out.BotUserIDs, l.Peer.ID) + } + } + return out, nil +} diff --git a/internal/store/memory/community_test.go b/internal/store/memory/community_test.go new file mode 100644 index 00000000..1efbf5d5 --- /dev/null +++ b/internal/store/memory/community_test.go @@ -0,0 +1,285 @@ +package memory + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +func mustCommunityTestUser(t *testing.T, store *UserStore, firstName, phone string) domain.User { + t.Helper() + user, err := store.Create(context.Background(), domain.User{AccessHash: int64(len(phone) + len(firstName)), Phone: phone, FirstName: firstName}) + if err != nil { + t.Fatalf("create user %q: %v", firstName, err) + } + return user +} + +func mustCommunityTestChannel(t *testing.T, store *ChannelStore, 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 := store.CreateChannel(context.Background(), domain.CreateChannelRequest{ + CreatorUserID: creator.ID, + Title: title, + Megagroup: true, + MemberUserIDs: memberIDs, + Date: 1_800_000_000, + }) + if err != nil { + t.Fatalf("create channel %q: %v", title, err) + } + return created.Channel +} + +func TestCommunityLifecycleRequestsSearchAndModeration(t *testing.T) { + ctx := context.Background() + users := NewUserStore() + owner := mustCommunityTestUser(t, users, "Community Owner", "15551000001") + member := mustCommunityTestUser(t, users, "Alice Searchable", "15551000002") + channels := NewChannelStore() + initial := mustCommunityTestChannel(t, channels, owner, "Initial", member) + store := NewCommunityStore(users, channels, nil, nil) + + created, err := store.CreateCommunity(ctx, domain.CreateCommunityRequest{ + CreatorUserID: owner.ID, + Title: "Engineering", + InitialPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: initial.ID}, + Visibility: domain.CommunityPeerHidden, + Date: 1_800_000_001, + }) + if err != nil { + t.Fatalf("create community: %v", err) + } + if len(created.Links) != 1 || created.Links[0].Visibility != domain.CommunityPeerHidden { + t.Fatalf("initial links = %+v, want one hidden link", created.Links) + } + if len(created.ServiceMessages) != 1 || created.ServiceMessages[0].Message.Action == nil || + created.ServiceMessages[0].Message.Action.Type != domain.ChannelActionChangeCommunity || + created.ServiceMessages[0].Message.Action.CommunityID != created.Community.ID || created.ServiceMessages[0].Event.Pts == 0 { + t.Fatalf("create service messages = %+v, want durable change-community action", created.ServiceMessages) + } + initialView, err := channels.GetChannel(ctx, owner.ID, initial.ID) + if err != nil || initialView.Channel.LinkedCommunityID != created.Community.ID { + t.Fatalf("initial linked community = %d, err=%v, want %d", initialView.Channel.LinkedCommunityID, err, created.Community.ID) + } + + _, err = store.CreateCommunity(ctx, domain.CreateCommunityRequest{ + CreatorUserID: owner.ID, + Title: "Duplicate", + InitialPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: initial.ID}, + Visibility: domain.CommunityPeerVisible, + Date: 1_800_000_002, + }) + if !errors.Is(err, domain.ErrCommunityPeerLinked) { + t.Fatalf("reuse linked peer error = %v, want ErrCommunityPeerLinked", err) + } + + owned := mustCommunityTestChannel(t, channels, member, "Member Owned") + requested, err := store.ToggleCommunityPeerLink(ctx, domain.CommunityTogglePeerLinkRequest{ + ActorUserID: member.ID, + CommunityID: created.Community.ID, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: owned.ID}, + Visibility: domain.CommunityPeerVisible, + Date: 1_800_000_003, + }) + if err != nil || !requested.RequestCreated { + t.Fatalf("link request = %+v, err=%v, want pending request", requested, err) + } + page, err := store.ListCommunityPeerLinkRequests(ctx, owner.ID, created.Community.ID, "", 20) + if err != nil || page.TotalCount != 1 || len(page.Requests) != 1 || page.Requests[0].RequestedBy != member.ID { + t.Fatalf("request page = %+v, err=%v", page, err) + } + approved, err := store.DecideCommunityPeerLinkRequest(ctx, owner.ID, created.Community.ID, requested.Peer, false, 1_800_000_004) + if err != nil || approved.Link == nil || approved.RequestedBy != member.ID || approved.ServiceMessage == nil { + t.Fatalf("approved request = %+v, err=%v", approved, err) + } + ownerView, err := store.GetCommunity(ctx, owner.ID, created.Community.ID) + if err != nil { + t.Fatalf("owner community view after approval: %v", err) + } + for _, link := range ownerView.Links { + if link.Peer == approved.Peer && link.CanViewHistory { + t.Fatalf("community admin without private-channel membership advertised can_view_history") + } + } + privateVisible := mustCommunityTestChannel(t, channels, owner, "Private Visible") + linked, err := store.ToggleCommunityPeerLink(ctx, domain.CommunityTogglePeerLinkRequest{ + ActorUserID: owner.ID, + CommunityID: created.Community.ID, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: privateVisible.ID}, + Visibility: domain.CommunityPeerVisible, + Date: 1_800_000_004, + }) + if err != nil || linked.Link == nil { + t.Fatalf("link private visible channel = %+v, err=%v", linked, err) + } + memberView, err := store.GetCommunity(ctx, member.ID, created.Community.ID) + if err != nil { + t.Fatalf("member community view: %v", err) + } + foundPrivate := false + for _, link := range memberView.Links { + if link.Peer.ID == privateVisible.ID { + foundPrivate = true + if link.CanViewHistory { + t.Fatalf("private visible channel advertised can_view_history to non-member") + } + } + } + if !foundPrivate { + t.Fatalf("visible private channel missing from member community view") + } + + _, err = store.ToggleCommunityPeerLink(ctx, domain.CommunityTogglePeerLinkRequest{ + ActorUserID: owner.ID, + CommunityID: created.Community.ID, + Peer: approved.Peer, + Visibility: domain.CommunityPeerHidden, + Date: 1_800_000_005, + }) + if !errors.Is(err, domain.ErrCommunityPeerLinked) { + t.Fatalf("change visibility in place error = %v, want unlink/relink requirement", err) + } + + participants, err := store.ListCommunityParticipants(ctx, owner.ID, created.Community.ID, domain.ChannelParticipantsFilter{ + Kind: domain.ChannelParticipantsSearch, + Query: "searchABLE", + }, 0, 20) + if err != nil || participants.Count != 1 || len(participants.Participants) != 1 || participants.Participants[0].UserID != member.ID { + t.Fatalf("participant name search = %+v, err=%v, want member", participants, err) + } + admins, err := store.ListCommunityParticipants(ctx, member.ID, created.Community.ID, domain.ChannelParticipantsFilter{ + Kind: domain.ChannelParticipantsAdmins, + }, 0, 100) + if err != nil || admins.Count != 1 || len(admins.Participants) != 1 || admins.Participants[0].UserID != owner.ID { + t.Fatalf("member-visible Community admins = %+v, err=%v, want creator", admins, err) + } + if _, err := store.ListCommunityParticipants(ctx, member.ID, created.Community.ID, domain.ChannelParticipantsFilter{ + Kind: domain.ChannelParticipantsKicked, + }, 0, 100); !errors.Is(err, domain.ErrCommunityAdminRequired) { + t.Fatalf("member Community kicked list error = %v, want admin required", err) + } + outsider := mustCommunityTestUser(t, users, "Community Outsider", "15551000003") + if _, err := store.ListCommunityParticipants(ctx, outsider.ID, created.Community.ID, domain.ChannelParticipantsFilter{ + Kind: domain.ChannelParticipantsAdmins, + }, 0, 100); !errors.Is(err, domain.ErrCommunityPrivate) { + t.Fatalf("outsider Community admins error = %v, want private", err) + } + + ban, err := store.ToggleCommunityParticipantBanned(ctx, owner.ID, created.Community.ID, member.ID, false, 1_800_000_006) + if err != nil { + t.Fatalf("ban community participant: %v", err) + } + if !ban.Changed || len(ban.ChannelBans) != 1 || len(ban.RemovedLinks) != 1 || ban.RemovedLinks[0].Peer.ID != owned.ID { + t.Fatalf("ban result = %+v, want one channel ban and owned-link removal", ban) + } + if action := ban.RemovedLinks[0].ServiceMessage.Message.Action; action == nil || action.Type != domain.ChannelActionChangeCommunity || action.CommunityID != 0 { + t.Fatalf("unlink service action = %+v, want change-community(0)", action) + } + if got := ban.RemovedLinks[0].ServiceMessage.Channel.LinkedCommunityID; got != 0 { + t.Fatalf("unlink service channel linked community = %d, want 0", got) + } + kicked, err := channels.GetParticipant(ctx, owner.ID, initial.ID, member.ID) + if err != nil || kicked.Status != domain.ChannelMemberKicked || !kicked.BannedRights.ViewMessages { + t.Fatalf("linked channel participant = %+v, err=%v, want kicked", kicked, err) + } + ownedView, err := channels.GetChannel(ctx, member.ID, owned.ID) + if err != nil || ownedView.Channel.LinkedCommunityID != 0 { + t.Fatalf("owned channel linked community = %d, err=%v, want 0", ownedView.Channel.LinkedCommunityID, err) + } + if _, err := store.GetCommunity(ctx, member.ID, created.Community.ID); !errors.Is(err, domain.ErrCommunityPrivate) { + t.Fatalf("banned member get community error = %v, want private", err) + } + repeated, err := store.ToggleCommunityParticipantBanned(ctx, owner.ID, created.Community.ID, member.ID, false, 1_800_000_007) + if err != nil || repeated.Changed || len(repeated.ChannelBans) != 0 || len(repeated.RemovedLinks) != 0 { + t.Fatalf("repeated ban = %+v err=%v, want idempotent no-op", repeated, err) + } +} + +func TestCommunityCollapsedPinAndMixedOrder(t *testing.T) { + ctx := context.Background() + users := NewUserStore() + owner := mustCommunityTestUser(t, users, "Owner", "15551000011") + channels := NewChannelStore() + store := NewCommunityStore(users, channels, nil, nil) + + makeCommunity := func(title string) domain.CommunityView { + channel := mustCommunityTestChannel(t, channels, owner, title+" Channel") + view, err := store.CreateCommunity(ctx, domain.CreateCommunityRequest{ + CreatorUserID: owner.ID, + Title: title, + InitialPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, + Visibility: domain.CommunityPeerVisible, + Date: 1_800_000_100, + }) + if err != nil { + t.Fatalf("create %s: %v", title, err) + } + if _, changed, err := store.SetCommunityCollapsed(ctx, owner.ID, view.Community.ID, true); err != nil || !changed { + t.Fatalf("collapse %s: changed=%v err=%v", title, changed, err) + } + if changed, err := store.SetCommunityPinned(ctx, owner.ID, view.Community.ID, true); err != nil || !changed { + t.Fatalf("pin %s: changed=%v err=%v", title, changed, err) + } + return view + } + one := makeCommunity("One") + two := makeCommunity("Two") + changed, err := store.ReorderCommunityPinned(ctx, owner.ID, []domain.Peer{ + {Type: domain.PeerTypeChannel, ID: 77}, + {Type: domain.PeerTypeCommunity, ID: one.Community.ID}, + {Type: domain.PeerTypeUser, ID: 88}, + {Type: domain.PeerTypeCommunity, ID: two.Community.ID}, + }, true) + if err != nil || !changed { + t.Fatalf("mixed pinned reorder: changed=%v err=%v", changed, err) + } + oneView, _ := store.GetCommunity(ctx, owner.ID, one.Community.ID) + twoView, _ := store.GetCommunity(ctx, owner.ID, two.Community.ID) + if !oneView.State.Pinned || !twoView.State.Pinned || oneView.State.PinnedOrder <= twoView.State.PinnedOrder { + t.Fatalf("pinned orders one=%+v two=%+v, want global mixed order preserved", oneView.State, twoView.State) + } + uncollapsed, changed, err := store.SetCommunityCollapsed(ctx, owner.ID, one.Community.ID, false) + if err != nil || !changed || uncollapsed.State.Pinned { + t.Fatalf("uncollapse state = %+v changed=%v err=%v, want pin cleared", uncollapsed.State, changed, err) + } +} + +func TestCommunityCanUseOwnedBotAsInitialPeer(t *testing.T) { + ctx := context.Background() + users := NewUserStore() + owner := mustCommunityTestUser(t, users, "Bot Owner", "15551000021") + bots := NewBotStore(users) + bot, _, err := bots.CreateBotAccount(ctx, domain.User{ + AccessHash: 91, FirstName: "Community Bot", Username: "community_bot", + }, domain.BotProfile{OwnerUserID: owner.ID}) + if err != nil { + t.Fatalf("create bot: %v", err) + } + store := NewCommunityStore(users, NewChannelStore(), bots, nil) + created, err := store.CreateCommunity(ctx, domain.CreateCommunityRequest{ + CreatorUserID: owner.ID, + Title: "Bot Community", + InitialPeer: domain.Peer{Type: domain.PeerTypeUser, ID: bot.ID}, + Visibility: domain.CommunityPeerVisible, + Date: 1_800_000_200, + }) + if err != nil { + t.Fatalf("create bot community: %v", err) + } + if len(created.Links) != 1 || created.Links[0].Peer.ID != bot.ID || !created.Links[0].CanViewHistory { + t.Fatalf("bot community links = %+v", created.Links) + } + if len(created.ServiceMessages) != 0 { + t.Fatalf("bot link service messages = %+v, want none", created.ServiceMessages) + } + updatedBot, ok, err := users.ByID(ctx, bot.ID) + if err != nil || !ok || updatedBot.LinkedCommunityID != created.Community.ID { + t.Fatalf("bot linked community = %d ok=%v err=%v", updatedBot.LinkedCommunityID, ok, err) + } +} diff --git a/internal/store/memory/message_history.go b/internal/store/memory/message_history.go index e9cf58b2..53bec30a 100644 --- a/internal/store/memory/message_history.go +++ b/internal/store/memory/message_history.go @@ -262,11 +262,23 @@ func filterMessageList(messages []domain.Message, filter domain.MessageFilter) d }) query := strings.ToLower(filter.Query) + peerIDs := make(map[int64]struct{}, len(filter.PeerIDs)) + for _, id := range filter.PeerIDs { + peerIDs[id] = struct{}{} + } base := make([]domain.Message, 0, len(messages)) for _, msg := range messages { if filter.HasPeer && msg.Peer != filter.Peer { continue } + if filter.RestrictPeerIDs { + if msg.Peer.Type != domain.PeerTypeUser { + continue + } + if _, ok := peerIDs[msg.Peer.ID]; !ok { + continue + } + } if query != "" && !strings.Contains(strings.ToLower(msg.Body), query) { continue } diff --git a/internal/store/postgres/channel_core.go b/internal/store/postgres/channel_core.go index 7c8aa5b2..2104c4ee 100644 --- a/internal/store/postgres/channel_core.go +++ b/internal/store/postgres/channel_core.go @@ -497,7 +497,7 @@ func scanChannel(row rowScanner) (domain.Channel, error) { func channelScanDest(ch *domain.Channel, rights, reactionPolicy *string, wallpaper **string) []any { return []any{ &ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, &ch.Verified, - &ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.HasLink, &ch.LinkedChatID, &ch.Monoforum, &ch.LinkedMonoforumID, &ch.SlowmodeSeconds, &ch.BoostsUnrestrict, rights, + &ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.HasLink, &ch.LinkedChatID, &ch.LinkedCommunityID, &ch.Monoforum, &ch.LinkedMonoforumID, &ch.SlowmodeSeconds, &ch.BoostsUnrestrict, rights, reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until, wallpaper, &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID, &ch.PinnedMessageID, &ch.Pts, &ch.TTLPeriod, &ch.Date, &ch.Deleted, diff --git a/internal/store/postgres/channel_member_admin.go b/internal/store/postgres/channel_member_admin.go index de40446e..f3c2eaff 100644 --- a/internal/store/postgres/channel_member_admin.go +++ b/internal/store/postgres/channel_member_admin.go @@ -544,6 +544,40 @@ LIMIT $2`, userID, domain.MaxAdminedPublicChannels) return out, nil } +func (s *ChannelStore) ListCommunityLinkableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) { + if userID == 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, ` +SELECT i.channel_id +FROM user_channel_member_index i +JOIN channels c ON c.id=i.channel_id +WHERE i.user_id=$1 + AND i.status='active' + AND i.role IN ('creator','admin') + AND NOT i.deleted + AND NOT c.monoforum + AND c.linked_community_id=0 +ORDER BY i.channel_id DESC +LIMIT $2`, userID, domain.MaxCommunityPeers) + if err != nil { + return nil, fmt.Errorf("list community-linkable channels: %w", err) + } + defer rows.Close() + ids := make([]int64, 0, domain.MaxCommunityPeers) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return listChannelsByIDsInOrder(ctx, s.db, ids) +} + // ListSendAsChannels lists the broadcast channels a user may post messages AS in groups // (channels.getSendAs candidates): channels where the user is the creator, or an admin holding // PostMessages rights. Mirrors ListStoryPostableChannels but is restricted to broadcast channels diff --git a/internal/store/postgres/channel_message_history.go b/internal/store/postgres/channel_message_history.go index aaa011f7..8b5a9d94 100644 --- a/internal/store/postgres/channel_message_history.go +++ b/internal/store/postgres/channel_message_history.go @@ -221,8 +221,12 @@ func (s *ChannelStore) SearchJoinedMessages(ctx context.Context, viewerUserID in if limit <= 0 || limit > domain.MaxChannelGlobalSearchLimit { limit = domain.MaxChannelGlobalSearchLimit } - args := []any{viewerUserID} + args := []any{viewerUserID, req.AllowPublicPreview} where := `NOT deleted` + if req.RestrictChannelIDs { + args = append(args, req.ChannelIDs) + where += fmt.Sprintf("\nAND channel_id = ANY($%d::bigint[])", len(args)) + } if query != "" { args = append(args, "%"+escapeLike(query)+"%") where += fmt.Sprintf(` @@ -242,15 +246,18 @@ AND EXISTS ( where += ` AND EXISTS ( SELECT 1 - FROM channels c - JOIN channel_members cm ON cm.channel_id = c.id - AND cm.user_id = $1 - AND cm.status = 'active' - AND NOT COALESCE((cm.banned_rights->>'ViewMessages')::boolean, false) + FROM channels c + LEFT JOIN channel_members cm ON cm.channel_id = c.id AND cm.user_id = $1 LEFT JOIN channel_dialogs d ON d.channel_id = c.id AND d.user_id = $1 - WHERE c.id = channel_messages.channel_id - AND NOT c.deleted - AND (cm.available_min_id <= 0 OR channel_messages.id > cm.available_min_id)` + WHERE c.id = channel_messages.channel_id + AND NOT c.deleted + AND ( + (cm.status = 'active' AND NOT COALESCE((cm.banned_rights->>'ViewMessages')::boolean, false)) + OR ($2::boolean AND COALESCE(c.username,'') <> '' + AND COALESCE(cm.status,'') <> 'kicked' + AND NOT COALESCE((cm.banned_rights->>'ViewMessages')::boolean, false)) + ) + AND (COALESCE(cm.status,'') <> 'active' OR cm.available_min_id <= 0 OR channel_messages.id > cm.available_min_id)` if req.BroadcastsOnly { where += ` AND c.broadcast AND NOT c.megagroup` diff --git a/internal/store/postgres/channel_store.go b/internal/store/postgres/channel_store.go index 21357fd4..9be8d7fd 100644 --- a/internal/store/postgres/channel_store.go +++ b/internal/store/postgres/channel_store.go @@ -116,7 +116,7 @@ func NewChannelStore(db sqlcgen.DBTX, opts ...ChannelStoreOption) *ChannelStore const channelColumns = `c.id, c.access_hash, c.creator_user_id, c.title, c.about, COALESCE(c.username, ''), c.verified, c.broadcast, c.megagroup, c.forum, c.forum_tabs, c.autotranslation, c.restricted_sponsored, c.broadcast_messages_allowed, c.send_paid_messages_stars, c.noforwards, c.join_to_send, c.join_request, c.signatures, c.pre_history_hidden, c.participants_hidden, c.antispam, EXISTS (SELECT 1 FROM channel_invites ci WHERE ci.channel_id = c.id AND NOT ci.revoked) AS has_link, -c.linked_chat_id, c.monoforum, c.linked_monoforum_id, c.slowmode_seconds, c.boosts_unrestrict, c.default_banned_rights::text, +c.linked_chat_id, c.linked_community_id, c.monoforum, c.linked_monoforum_id, c.slowmode_seconds, c.boosts_unrestrict, c.default_banned_rights::text, c.available_reactions::text, c.color_set, c.color, c.color_background_emoji_id, c.profile_color_set, c.profile_color, c.profile_color_background_emoji_id, c.emoji_status_document_id, c.emoji_status_until, c.wallpaper::text, c.participants_count, c.admins_count, c.kicked_count, c.banned_count, c.top_message_id, c.pinned_message_id, c.pts, c.ttl_period, c.date, c.deleted, c.photo_id, c.photo_dc_id, c.photo_stripped, diff --git a/internal/store/postgres/community.go b/internal/store/postgres/community.go new file mode 100644 index 00000000..b37e5b2c --- /dev/null +++ b/internal/store/postgres/community.go @@ -0,0 +1,1374 @@ +package postgres + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "hash/fnv" + "sort" + "strings" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/postgres/sqlcgen" +) + +type CommunityStore struct { + db sqlcgen.DBTX + ids store.ChannelIDAllocator + msgIDs store.ChannelMessageIDAllocator +} + +func NewCommunityStore(db sqlcgen.DBTX, ids store.ChannelIDAllocator, msgIDs store.ChannelMessageIDAllocator) *CommunityStore { + if ids == nil { + ids = pgChannelIDAllocator{db: db} + } + if msgIDs == nil { + msgIDs = pgChannelMessageIDAllocator{db: db} + } + return &CommunityStore{db: db, ids: ids, msgIDs: msgIDs} +} + +func (s *CommunityStore) appendCommunityServiceMessageTx(ctx context.Context, tx pgx.Tx, peer domain.Peer, actorUserID int64, date int, communityID int64) (*domain.SendChannelMessageResult, error) { + if peer.Type != domain.PeerTypeChannel { + return nil, nil + } + channel, err := getChannelByID(ctx, tx, peer.ID) + if err != nil { + return nil, err + } + channelStore := NewChannelStore(tx, WithChannelAllocators(s.ids, s.msgIDs)) + message, event, err := channelStore.insertServiceMessage(ctx, tx, channel, actorUserID, date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChangeCommunity, + CommunityID: communityID, + }) + if err != nil { + return nil, err + } + channel.TopMessageID = message.ID + channel.Pts = event.Pts + // The Community transaction has already validated the actor and holds the + // linked channel row. Use the internal membership scan here: the public + // method treats viewerUserID=0 as an unauthenticated private-channel read. + recipients, err := channelStore.listActiveChannelMemberIDs(ctx, tx, channel.ID, 0) + if err != nil { + return nil, err + } + return &domain.SendChannelMessageResult{Channel: channel, Message: message, Event: event, Recipients: recipients}, nil +} + +const communityColumns = `id, access_hash, creator_user_id, title, about, +default_banned_rights::text, photo_id, photo_dc_id, photo_stripped, date, deleted` + +func scanCommunity(row rowScanner) (domain.Community, error) { + var c domain.Community + var rights string + if err := row.Scan(&c.ID, &c.AccessHash, &c.CreatorUserID, &c.Title, &c.About, + &rights, &c.PhotoID, &c.PhotoDCID, &c.PhotoStripped, &c.Date, &c.Deleted); err != nil { + return domain.Community{}, err + } + if err := json.Unmarshal([]byte(rights), &c.DefaultBannedRights); err != nil { + return domain.Community{}, fmt.Errorf("decode community banned rights: %w", err) + } + c.PhotoStripped = append([]byte(nil), c.PhotoStripped...) + return c, nil +} + +func scanCommunityMember(row rowScanner) (domain.CommunityMember, error) { + var m domain.CommunityMember + var role, status, rights string + if err := row.Scan(&m.CommunityID, &m.UserID, &role, &status, &rights, &m.Rank, &m.Date); err != nil { + return domain.CommunityMember{}, err + } + m.Role = domain.CommunityMemberRole(role) + m.Status = domain.CommunityMemberStatus(status) + if err := json.Unmarshal([]byte(rights), &m.AdminRights); err != nil { + return domain.CommunityMember{}, fmt.Errorf("decode community admin rights: %w", err) + } + return m, nil +} + +func (s *CommunityStore) begin(ctx context.Context) (pgx.Tx, error) { + b, ok := s.db.(txBeginner) + if !ok { + return nil, errors.New("community store requires transaction-capable db") + } + return b.Begin(ctx) +} + +func withCommunityTx[T any](ctx context.Context, s *CommunityStore, fn func(pgx.Tx) (T, error)) (T, error) { + var zero T + tx, err := s.begin(ctx) + if err != nil { + return zero, err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + out, err := fn(tx) + if err != nil { + return zero, err + } + if err := tx.Commit(ctx); err != nil { + return zero, fmt.Errorf("commit community transaction: %w", err) + } + committed = true + return out, nil +} + +func communityByID(ctx context.Context, db sqlcgen.DBTX, id int64, forUpdate bool) (domain.Community, error) { + lock := "" + if forUpdate { + lock = " FOR UPDATE" + } + c, err := scanCommunity(db.QueryRow(ctx, `SELECT `+communityColumns+` FROM communities WHERE id=$1 AND NOT deleted`+lock, id)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Community{}, domain.ErrCommunityInvalid + } + return domain.Community{}, fmt.Errorf("get community: %w", err) + } + return c, nil +} + +func explicitCommunityMember(ctx context.Context, db sqlcgen.DBTX, communityID, userID int64) (domain.CommunityMember, bool, error) { + m, err := scanCommunityMember(db.QueryRow(ctx, ` +SELECT community_id, user_id, role, status, admin_rights::text, rank, date +FROM community_members WHERE community_id=$1 AND user_id=$2`, communityID, userID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.CommunityMember{}, false, nil + } + return domain.CommunityMember{}, false, fmt.Errorf("get community member: %w", err) + } + return m, true, nil +} + +func derivedCommunityMember(ctx context.Context, db sqlcgen.DBTX, c domain.Community, userID int64) (domain.CommunityMember, bool, error) { + if userID == 0 { + return domain.CommunityMember{}, false, nil + } + if m, ok, err := explicitCommunityMember(ctx, db, c.ID, userID); err != nil || ok { + return m, ok, err + } + var joined bool + err := db.QueryRow(ctx, ` +SELECT EXISTS ( + SELECT 1 + FROM community_peer_links l + JOIN channel_members cm ON l.peer_type='channel' AND cm.channel_id=l.peer_id + WHERE l.community_id=$1 AND cm.user_id=$2 AND cm.status='active' + UNION ALL + SELECT 1 + FROM community_peer_links l + JOIN dialogs d ON l.peer_type='user' AND d.peer_id=l.peer_id AND d.peer_type='user' + WHERE l.community_id=$1 AND d.user_id=$2 AND d.top_message_id > 0 + LIMIT 1 +)`, c.ID, userID).Scan(&joined) + if err != nil { + return domain.CommunityMember{}, false, fmt.Errorf("derive community member: %w", err) + } + if !joined { + return domain.CommunityMember{}, false, nil + } + return domain.CommunityMember{ + CommunityID: c.ID, UserID: userID, Role: domain.CommunityRoleMember, + Status: domain.CommunityMemberActive, Date: c.Date, + }, true, nil +} + +func communityState(ctx context.Context, db sqlcgen.DBTX, communityID, userID int64) (domain.CommunityUserState, error) { + state := domain.CommunityUserState{CommunityID: communityID, UserID: userID} + err := db.QueryRow(ctx, ` +SELECT collapsed, pinned, pinned_order FROM community_user_states +WHERE community_id=$1 AND user_id=$2`, communityID, userID). + Scan(&state.Collapsed, &state.Pinned, &state.PinnedOrder) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return domain.CommunityUserState{}, fmt.Errorf("get community state: %w", err) + } + return state, nil +} + +func communityLinkRows(ctx context.Context, db sqlcgen.DBTX, c domain.Community, viewer domain.CommunityMember) ([]domain.CommunityPeerLink, error) { + rows, err := db.Query(ctx, ` +SELECT l.peer_type, l.peer_id, l.visibility, l.created_by, l.date, + CASE + WHEN l.peer_type='channel' THEN EXISTS ( + SELECT 1 FROM channel_members cm + WHERE cm.channel_id=l.peer_id AND cm.user_id=$2 AND cm.status='active') + ELSE EXISTS ( + SELECT 1 FROM dialogs d + WHERE d.user_id=$2 AND d.peer_type='user' AND d.peer_id=l.peer_id AND d.top_message_id > 0) + END AS joined, + CASE + WHEN l.peer_type='user' THEN true + ELSE EXISTS ( + SELECT 1 FROM channels c + WHERE c.id=l.peer_id AND NOT c.deleted AND COALESCE(c.username,'') <> '') + END AS inherently_viewable +FROM community_peer_links l +WHERE l.community_id=$1 +ORDER BY l.date, l.peer_type, l.peer_id`, c.ID, viewer.UserID) + if err != nil { + return nil, fmt.Errorf("list community links: %w", err) + } + defer rows.Close() + canManage := viewer.CanManageLinkedPeers() + out := make([]domain.CommunityPeerLink, 0) + for rows.Next() { + var typ, visibility string + var id, createdBy int64 + var date int + var joined, inherentlyViewable bool + if err := rows.Scan(&typ, &id, &visibility, &createdBy, &date, &joined, &inherentlyViewable); err != nil { + return nil, fmt.Errorf("scan community link: %w", err) + } + if visibility == string(domain.CommunityPeerHidden) && !joined && !canManage { + continue + } + out = append(out, domain.CommunityPeerLink{ + CommunityID: c.ID, Peer: domain.Peer{Type: domain.PeerType(typ), ID: id}, + Visibility: domain.CommunityPeerVisibility(visibility), CanViewHistory: joined || inherentlyViewable, + CreatedBy: createdBy, Date: date, + }) + } + return out, rows.Err() +} + +func communityView(ctx context.Context, db sqlcgen.DBTX, viewerUserID, communityID int64) (domain.CommunityView, error) { + c, err := communityByID(ctx, db, communityID, false) + if err != nil { + return domain.CommunityView{}, err + } + self, joined, err := derivedCommunityMember(ctx, db, c, viewerUserID) + if err != nil { + return domain.CommunityView{}, err + } + if !joined || !self.Active() { + return domain.CommunityView{Community: c, Self: self, Forbidden: true}, domain.ErrCommunityPrivate + } + state, err := communityState(ctx, db, c.ID, viewerUserID) + if err != nil { + return domain.CommunityView{}, err + } + links, err := communityLinkRows(ctx, db, c, self) + if err != nil { + return domain.CommunityView{}, err + } + view := domain.CommunityView{Community: c, Self: self, State: state, Links: links} + channelIDs, userIDs := make([]int64, 0, len(links)), make([]int64, 0, len(links)) + for _, link := range links { + if link.Peer.Type == domain.PeerTypeChannel { + channelIDs = append(channelIDs, link.Peer.ID) + } else { + userIDs = append(userIDs, link.Peer.ID) + } + } + view.Channels, err = listChannelsByIDs(ctx, db, channelIDs) + if err != nil { + return domain.CommunityView{}, err + } + view.Users, err = listUsersByIDs(ctx, db, userIDs) + if err != nil { + return domain.CommunityView{}, err + } + err = db.QueryRow(ctx, ` +SELECT + COUNT(*) FILTER (WHERE status='active' AND role IN ('creator','admin'))::int, + COUNT(*) FILTER (WHERE status='kicked')::int, + (SELECT COUNT(*)::int FROM community_peer_link_requests WHERE community_id=$1) +FROM community_members WHERE community_id=$1`, c.ID). + Scan(&view.AdminsCount, &view.KickedCount, &view.PendingRequests) + if err != nil { + return domain.CommunityView{}, fmt.Errorf("get community counts: %w", err) + } + return view, nil +} + +func (s *CommunityStore) GetCommunity(ctx context.Context, viewerUserID, communityID int64) (domain.CommunityView, error) { + return communityView(ctx, s.db, viewerUserID, communityID) +} + +func (s *CommunityStore) GetCommunities(ctx context.Context, viewerUserID int64, ids []int64) ([]domain.CommunityView, error) { + seen := make(map[int64]struct{}, len(ids)) + out := make([]domain.CommunityView, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + view, err := communityView(ctx, s.db, viewerUserID, id) + if errors.Is(err, domain.ErrCommunityInvalid) || errors.Is(err, domain.ErrCommunityPrivate) { + continue + } + if err != nil { + return nil, err + } + out = append(out, view) + } + return out, nil +} + +func (s *CommunityStore) ListJoinedCommunities(ctx context.Context, viewerUserID int64) ([]domain.CommunityView, error) { + rows, err := s.db.Query(ctx, ` +SELECT DISTINCT c.id +FROM communities c +LEFT JOIN community_members explicit ON explicit.community_id=c.id AND explicit.user_id=$1 +WHERE NOT c.deleted AND ( + (explicit.status='active') + OR (explicit.user_id IS NULL AND EXISTS ( + SELECT 1 FROM community_peer_links l + JOIN channel_members cm ON l.peer_type='channel' AND cm.channel_id=l.peer_id + WHERE l.community_id=c.id AND cm.user_id=$1 AND cm.status='active')) + OR (explicit.user_id IS NULL AND EXISTS ( + SELECT 1 FROM community_peer_links l + JOIN dialogs d ON l.peer_type='user' AND d.peer_id=l.peer_id AND d.peer_type='user' + WHERE l.community_id=c.id AND d.user_id=$1 AND d.top_message_id > 0)) +) +ORDER BY c.id`, viewerUserID) + if err != nil { + return nil, fmt.Errorf("list joined communities: %w", err) + } + defer rows.Close() + ids := make([]int64, 0) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return s.GetCommunities(ctx, viewerUserID, ids) +} + +func validateCommunityPeerForLink(ctx context.Context, db sqlcgen.DBTX, actorUserID int64, peer domain.Peer, lock bool) error { + lockSQL := "" + if lock { + lockSQL = " FOR UPDATE" + } + switch peer.Type { + case domain.PeerTypeChannel: + var linked int64 + var deleted, monoforum bool + if err := db.QueryRow(ctx, `SELECT linked_community_id, deleted, monoforum FROM channels WHERE id=$1`+lockSQL, peer.ID). + Scan(&linked, &deleted, &monoforum); err != nil { + return domain.ErrCommunityPeerInvalid + } + if deleted || monoforum { + return domain.ErrCommunityPeerInvalid + } + if linked != 0 { + return domain.ErrCommunityPeerLinked + } + var allowed bool + if err := db.QueryRow(ctx, `SELECT EXISTS ( +SELECT 1 FROM channel_members WHERE channel_id=$1 AND user_id=$2 AND status='active' AND role IN ('creator','admin'))`, peer.ID, actorUserID).Scan(&allowed); err != nil || !allowed { + return domain.ErrCommunityAdminRequired + } + case domain.PeerTypeUser: + var bot, deleted bool + var linked int64 + if err := db.QueryRow(ctx, `SELECT is_bot, deleted_at IS NOT NULL, linked_community_id FROM users WHERE id=$1`+lockSQL, peer.ID). + Scan(&bot, &deleted, &linked); err != nil || !bot || deleted { + return domain.ErrCommunityPeerInvalid + } + if linked != 0 { + return domain.ErrCommunityPeerLinked + } + var owned bool + if err := db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM bots WHERE bot_user_id=$1 AND owner_user_id=$2)`, peer.ID, actorUserID).Scan(&owned); err != nil || !owned { + return domain.ErrCommunityAdminRequired + } + default: + return domain.ErrCommunityPeerInvalid + } + return nil +} + +func setPeerLinkedCommunity(ctx context.Context, db sqlcgen.DBTX, peer domain.Peer, communityID int64) error { + var tag string + switch peer.Type { + case domain.PeerTypeChannel: + tag = "UPDATE channels SET linked_community_id=$2, updated_at=now() WHERE id=$1" + case domain.PeerTypeUser: + tag = "UPDATE users SET linked_community_id=$2, updated_at=now() WHERE id=$1" + default: + return domain.ErrCommunityPeerInvalid + } + cmd, err := db.Exec(ctx, tag, peer.ID, communityID) + if err != nil { + return fmt.Errorf("set linked community: %w", err) + } + if cmd.RowsAffected() != 1 { + return domain.ErrCommunityPeerInvalid + } + return nil +} + +func insertCommunityLink(ctx context.Context, db sqlcgen.DBTX, communityID, actorUserID int64, peer domain.Peer, visibility domain.CommunityPeerVisibility, date int) (domain.CommunityPeerLink, error) { + if err := validateCommunityPeerForLink(ctx, db, actorUserID, peer, true); err != nil { + return domain.CommunityPeerLink{}, err + } + var peers, bots int + if err := db.QueryRow(ctx, `SELECT +COUNT(*) FILTER (WHERE peer_type='channel')::int, +COUNT(*) FILTER (WHERE peer_type='user')::int +FROM community_peer_links WHERE community_id=$1`, communityID).Scan(&peers, &bots); err != nil { + return domain.CommunityPeerLink{}, err + } + if (peer.Type == domain.PeerTypeChannel && peers >= domain.MaxCommunityPeers) || + (peer.Type == domain.PeerTypeUser && bots >= domain.MaxCommunityBotPeers) { + return domain.CommunityPeerLink{}, domain.ErrCommunityPeersTooMuch + } + if _, err := db.Exec(ctx, ` +INSERT INTO community_peer_links(community_id,peer_type,peer_id,visibility,created_by,date) +VALUES($1,$2,$3,$4,$5,$6)`, communityID, string(peer.Type), peer.ID, string(visibility), actorUserID, date); err != nil { + if isUniqueViolation(err) { + return domain.CommunityPeerLink{}, domain.ErrCommunityPeerLinked + } + return domain.CommunityPeerLink{}, fmt.Errorf("insert community link: %w", err) + } + if err := setPeerLinkedCommunity(ctx, db, peer, communityID); err != nil { + return domain.CommunityPeerLink{}, err + } + return domain.CommunityPeerLink{CommunityID: communityID, Peer: peer, Visibility: visibility, CanViewHistory: true, CreatedBy: actorUserID, Date: date}, nil +} + +func (s *CommunityStore) CreateCommunity(ctx context.Context, req domain.CreateCommunityRequest) (domain.CommunityView, error) { + return withCommunityTx(ctx, s, func(tx pgx.Tx) (domain.CommunityView, error) { + if err := validateCommunityPeerForLink(ctx, tx, req.CreatorUserID, req.InitialPeer, true); err != nil { + return domain.CommunityView{}, err + } + id, err := s.ids.NextChannelID(ctx) + if err != nil { + return domain.CommunityView{}, fmt.Errorf("allocate community id: %w", err) + } + hash, err := randomChannelAccessHash() + if err != nil { + return domain.CommunityView{}, fmt.Errorf("community access hash: %w", err) + } + c := domain.Community{ID: id, AccessHash: hash, CreatorUserID: req.CreatorUserID, Title: req.Title, About: req.About, Date: req.Date} + rights, _ := json.Marshal(c.DefaultBannedRights) + if _, err := tx.Exec(ctx, ` +INSERT INTO communities(id,access_hash,creator_user_id,title,about,default_banned_rights,date) +VALUES($1,$2,$3,$4,$5,$6,$7)`, c.ID, c.AccessHash, c.CreatorUserID, c.Title, c.About, rights, c.Date); err != nil { + return domain.CommunityView{}, fmt.Errorf("insert community: %w", err) + } + adminRights, _ := json.Marshal(domain.CreatorChannelAdminRights()) + if _, err := tx.Exec(ctx, ` +INSERT INTO community_members(community_id,user_id,role,status,admin_rights,date) +VALUES($1,$2,'creator','active',$3,$4)`, c.ID, c.CreatorUserID, adminRights, c.Date); err != nil { + return domain.CommunityView{}, fmt.Errorf("insert community creator: %w", err) + } + link, err := insertCommunityLink(ctx, tx, c.ID, c.CreatorUserID, req.InitialPeer, req.Visibility, req.Date) + if err != nil { + return domain.CommunityView{}, err + } + serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, req.InitialPeer, req.CreatorUserID, req.Date, c.ID) + if err != nil { + return domain.CommunityView{}, err + } + self := domain.CommunityMember{CommunityID: c.ID, UserID: c.CreatorUserID, Role: domain.CommunityRoleCreator, Status: domain.CommunityMemberActive, AdminRights: domain.CreatorChannelAdminRights(), Date: c.Date} + view := domain.CommunityView{Community: c, Self: self, Links: []domain.CommunityPeerLink{link}, AdminsCount: 1} + if serviceMessage != nil { + view.ServiceMessages = append(view.ServiceMessages, *serviceMessage) + } + return view, nil + }) +} + +func lockCommunityActor(ctx context.Context, tx pgx.Tx, actorUserID, communityID int64) (domain.Community, domain.CommunityMember, error) { + c, err := communityByID(ctx, tx, communityID, true) + if err != nil { + return domain.Community{}, domain.CommunityMember{}, err + } + m, ok, err := derivedCommunityMember(ctx, tx, c, actorUserID) + if err != nil { + return domain.Community{}, domain.CommunityMember{}, err + } + if !ok || !m.Active() { + return domain.Community{}, domain.CommunityMember{}, domain.ErrCommunityPrivate + } + return c, m, nil +} + +func (s *CommunityStore) ToggleCommunityPeerLink(ctx context.Context, req domain.CommunityTogglePeerLinkRequest) (domain.CommunityTogglePeerLinkResult, error) { + return withCommunityTx(ctx, s, func(tx pgx.Tx) (domain.CommunityTogglePeerLinkResult, error) { + c, actor, err := lockCommunityActor(ctx, tx, req.ActorUserID, req.CommunityID) + if err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + if req.Deleted { + if !actor.CanManageLinkedPeers() { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityAdminRequired + } + cmd, err := tx.Exec(ctx, `DELETE FROM community_peer_links WHERE community_id=$1 AND peer_type=$2 AND peer_id=$3`, c.ID, string(req.Peer.Type), req.Peer.ID) + if err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + if cmd.RowsAffected() == 0 { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityPeerInvalid + } + if err := setPeerLinkedCommunity(ctx, tx, req.Peer, 0); err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, req.Peer, req.ActorUserID, req.Date, 0) + if err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + return domain.CommunityTogglePeerLinkResult{Community: c, Peer: req.Peer, ServiceMessage: serviceMessage, Removed: true}, nil + } + if actor.CanManageLinkedPeers() { + link, err := insertCommunityLink(ctx, tx, c.ID, req.ActorUserID, req.Peer, req.Visibility, req.Date) + if err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, req.Peer, req.ActorUserID, req.Date, c.ID) + if err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + return domain.CommunityTogglePeerLinkResult{Community: c, Peer: req.Peer, Link: &link, ServiceMessage: serviceMessage}, nil + } + if c.DefaultBannedRights.ManageLinkedPeers { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityAdminRequired + } + if err := validateCommunityPeerForLink(ctx, tx, req.ActorUserID, req.Peer, true); err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + if _, err := tx.Exec(ctx, ` +INSERT INTO community_peer_link_requests(community_id,peer_type,peer_id,requested_by,visibility,date) +VALUES($1,$2,$3,$4,$5,$6) +ON CONFLICT(community_id,peer_type,peer_id) DO UPDATE SET requested_by=EXCLUDED.requested_by, visibility=EXCLUDED.visibility, date=EXCLUDED.date, created_at=now()`, + c.ID, string(req.Peer.Type), req.Peer.ID, req.ActorUserID, string(req.Visibility), req.Date); err != nil { + return domain.CommunityTogglePeerLinkResult{}, fmt.Errorf("save community link request: %w", err) + } + return domain.CommunityTogglePeerLinkResult{Community: c, Peer: req.Peer, RequestCreated: true}, nil + }) +} + +func (s *CommunityStore) SetCommunityCollapsed(ctx context.Context, userID, communityID int64, collapsed bool) (domain.CommunityView, bool, error) { + changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) { + if _, _, err := lockCommunityActor(ctx, tx, userID, communityID); err != nil { + return false, err + } + var old bool + err := tx.QueryRow(ctx, `SELECT collapsed FROM community_user_states WHERE community_id=$1 AND user_id=$2 FOR UPDATE`, communityID, userID).Scan(&old) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return false, err + } + if err == nil && old == collapsed { + return false, nil + } + _, err = tx.Exec(ctx, ` +INSERT INTO community_user_states(community_id,user_id,collapsed,pinned,pinned_order) +VALUES($1,$2,$3,false,0) +ON CONFLICT(community_id,user_id) DO UPDATE SET collapsed=EXCLUDED.collapsed, + pinned=CASE WHEN EXCLUDED.collapsed THEN community_user_states.pinned ELSE false END, + pinned_order=CASE WHEN EXCLUDED.collapsed THEN community_user_states.pinned_order ELSE 0 END, + updated_at=now()`, communityID, userID, collapsed) + return true, err + }) + if err != nil { + return domain.CommunityView{}, false, err + } + view, err := s.GetCommunity(ctx, userID, communityID) + return view, changed, err +} + +type communityRequestCursor struct { + Date int `json:"d"` + Type string `json:"t"` + ID int64 `json:"i"` +} + +func decodeCommunityRequestCursor(raw string) (communityRequestCursor, error) { + if strings.TrimSpace(raw) == "" { + return communityRequestCursor{}, nil + } + b, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return communityRequestCursor{}, domain.ErrCommunityInvalid + } + var c communityRequestCursor + if json.Unmarshal(b, &c) != nil || c.Date <= 0 || c.ID <= 0 { + return communityRequestCursor{}, domain.ErrCommunityInvalid + } + return c, nil +} + +func encodeCommunityRequestCursor(c communityRequestCursor) string { + b, _ := json.Marshal(c) + return base64.RawURLEncoding.EncodeToString(b) +} + +func (s *CommunityStore) ListCommunityPeerLinkRequests(ctx context.Context, viewerUserID, communityID int64, offset string, limit int) (domain.CommunityPeerLinkRequestPage, error) { + view, err := s.GetCommunity(ctx, viewerUserID, communityID) + if err != nil { + return domain.CommunityPeerLinkRequestPage{}, err + } + if !view.Self.CanManageLinkedPeers() { + return domain.CommunityPeerLinkRequestPage{}, domain.ErrCommunityAdminRequired + } + cursor, err := decodeCommunityRequestCursor(offset) + if err != nil { + return domain.CommunityPeerLinkRequestPage{}, err + } + if limit <= 0 || limit > domain.MaxCommunityLinkRequests { + limit = domain.MaxCommunityLinkRequests + } + var total int + if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM community_peer_link_requests WHERE community_id=$1`, communityID).Scan(&total); err != nil { + return domain.CommunityPeerLinkRequestPage{}, err + } + rows, err := s.db.Query(ctx, ` +SELECT peer_type,peer_id,requested_by,visibility,date +FROM community_peer_link_requests +WHERE community_id=$1 AND ($2::int=0 OR (date,peer_type,peer_id) < ($2,$3,$4)) +ORDER BY date DESC,peer_type DESC,peer_id DESC LIMIT $5`, communityID, cursor.Date, cursor.Type, cursor.ID, limit+1) + if err != nil { + return domain.CommunityPeerLinkRequestPage{}, err + } + defer rows.Close() + page := domain.CommunityPeerLinkRequestPage{TotalCount: total} + for rows.Next() { + var typ, visibility string + var peerID, requestedBy int64 + var date int + if err := rows.Scan(&typ, &peerID, &requestedBy, &visibility, &date); err != nil { + return domain.CommunityPeerLinkRequestPage{}, err + } + page.Requests = append(page.Requests, domain.CommunityPeerLinkRequest{CommunityID: communityID, Peer: domain.Peer{Type: domain.PeerType(typ), ID: peerID}, RequestedBy: requestedBy, Visibility: domain.CommunityPeerVisibility(visibility), Date: date}) + } + if len(page.Requests) > limit { + last := page.Requests[limit-1] + page.NextOffset = encodeCommunityRequestCursor(communityRequestCursor{Date: last.Date, Type: string(last.Peer.Type), ID: last.Peer.ID}) + page.Requests = page.Requests[:limit] + } + channelIDs, userIDs := make([]int64, 0), make([]int64, 0) + for _, req := range page.Requests { + userIDs = append(userIDs, req.RequestedBy) + if req.Peer.Type == domain.PeerTypeChannel { + channelIDs = append(channelIDs, req.Peer.ID) + } else { + userIDs = append(userIDs, req.Peer.ID) + } + } + page.Channels, err = listChannelsByIDs(ctx, s.db, channelIDs) + if err != nil { + return domain.CommunityPeerLinkRequestPage{}, err + } + page.Users, err = listUsersByIDs(ctx, s.db, uniqueInt64s(userIDs)) + return page, err +} + +func requestForUpdate(ctx context.Context, tx pgx.Tx, communityID int64, peer domain.Peer) (domain.CommunityPeerLinkRequest, error) { + var typ, visibility string + var out domain.CommunityPeerLinkRequest + err := tx.QueryRow(ctx, ` +SELECT peer_type,peer_id,requested_by,visibility,date FROM community_peer_link_requests +WHERE community_id=$1 AND peer_type=$2 AND peer_id=$3 FOR UPDATE`, communityID, string(peer.Type), peer.ID). + Scan(&typ, &out.Peer.ID, &out.RequestedBy, &visibility, &out.Date) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.CommunityPeerLinkRequest{}, domain.ErrCommunityRequestMissing + } + return domain.CommunityPeerLinkRequest{}, err + } + out.CommunityID, out.Peer.Type, out.Visibility = communityID, domain.PeerType(typ), domain.CommunityPeerVisibility(visibility) + return out, nil +} + +func (s *CommunityStore) DecideCommunityPeerLinkRequest(ctx context.Context, actorUserID, communityID int64, peer domain.Peer, reject bool, date int) (domain.CommunityTogglePeerLinkResult, error) { + return withCommunityTx(ctx, s, func(tx pgx.Tx) (domain.CommunityTogglePeerLinkResult, error) { + c, actor, err := lockCommunityActor(ctx, tx, actorUserID, communityID) + if err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + if !actor.CanManageLinkedPeers() { + return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityAdminRequired + } + req, err := requestForUpdate(ctx, tx, communityID, peer) + if err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + if _, err := tx.Exec(ctx, `DELETE FROM community_peer_link_requests WHERE community_id=$1 AND peer_type=$2 AND peer_id=$3`, communityID, string(peer.Type), peer.ID); err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + if reject { + return domain.CommunityTogglePeerLinkResult{Community: c, Peer: peer, RequestedBy: req.RequestedBy}, nil + } + link, err := insertCommunityLink(ctx, tx, communityID, req.RequestedBy, peer, req.Visibility, date) + if err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, peer, actorUserID, date, c.ID) + if err != nil { + return domain.CommunityTogglePeerLinkResult{}, err + } + return domain.CommunityTogglePeerLinkResult{Community: c, Peer: peer, RequestedBy: req.RequestedBy, Link: &link, ServiceMessage: serviceMessage}, nil + }) +} + +func (s *CommunityStore) DecideAllCommunityPeerLinkRequests(ctx context.Context, actorUserID, communityID int64, reject bool, date int) ([]domain.CommunityTogglePeerLinkResult, error) { + return withCommunityTx(ctx, s, func(tx pgx.Tx) ([]domain.CommunityTogglePeerLinkResult, error) { + c, actor, err := lockCommunityActor(ctx, tx, actorUserID, communityID) + if err != nil { + return nil, err + } + if !actor.CanManageLinkedPeers() { + return nil, domain.ErrCommunityAdminRequired + } + rows, err := tx.Query(ctx, `SELECT peer_type,peer_id,requested_by,visibility,date FROM community_peer_link_requests WHERE community_id=$1 ORDER BY date,peer_type,peer_id FOR UPDATE`, communityID) + if err != nil { + return nil, err + } + requests := make([]domain.CommunityPeerLinkRequest, 0) + for rows.Next() { + var typ, visibility string + var r domain.CommunityPeerLinkRequest + if err := rows.Scan(&typ, &r.Peer.ID, &r.RequestedBy, &visibility, &r.Date); err != nil { + rows.Close() + return nil, err + } + r.CommunityID, r.Peer.Type, r.Visibility = communityID, domain.PeerType(typ), domain.CommunityPeerVisibility(visibility) + requests = append(requests, r) + } + rows.Close() + if reject { + _, err := tx.Exec(ctx, `DELETE FROM community_peer_link_requests WHERE community_id=$1`, communityID) + return make([]domain.CommunityTogglePeerLinkResult, len(requests)), err + } + var currentChannels, currentBots int + if err := tx.QueryRow(ctx, `SELECT COUNT(*) FILTER(WHERE peer_type='channel')::int, COUNT(*) FILTER(WHERE peer_type='user')::int FROM community_peer_links WHERE community_id=$1`, communityID).Scan(¤tChannels, ¤tBots); err != nil { + return nil, err + } + for _, r := range requests { + if r.Peer.Type == domain.PeerTypeChannel { + currentChannels++ + } else { + currentBots++ + } + if currentChannels > domain.MaxCommunityPeers || currentBots > domain.MaxCommunityBotPeers { + return nil, domain.ErrCommunityPeersTooMuch + } + if err := validateCommunityPeerForLink(ctx, tx, r.RequestedBy, r.Peer, true); err != nil { + return nil, err + } + } + out := make([]domain.CommunityTogglePeerLinkResult, 0, len(requests)) + for _, r := range requests { + link, err := insertCommunityLink(ctx, tx, communityID, r.RequestedBy, r.Peer, r.Visibility, date) + if err != nil { + return nil, err + } + serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, r.Peer, actorUserID, date, c.ID) + if err != nil { + return nil, err + } + out = append(out, domain.CommunityTogglePeerLinkResult{Community: c, Peer: r.Peer, RequestedBy: r.RequestedBy, Link: &link, ServiceMessage: serviceMessage}) + } + _, err = tx.Exec(ctx, `DELETE FROM community_peer_link_requests WHERE community_id=$1`, communityID) + return out, err + }) +} + +func (s *CommunityStore) GetCommunityParticipantJoinedChats(ctx context.Context, viewerUserID, communityID, participantUserID int64) (domain.CommunityParticipantJoinedChats, error) { + view, err := s.GetCommunity(ctx, viewerUserID, communityID) + if err != nil { + return domain.CommunityParticipantJoinedChats{}, err + } + if !view.Self.CanBanUsers() && viewerUserID != participantUserID { + return domain.CommunityParticipantJoinedChats{}, domain.ErrCommunityAdminRequired + } + rows, err := s.db.Query(ctx, ` +SELECT l.peer_id, cm.role +FROM community_peer_links l +JOIN channel_members cm ON cm.channel_id=l.peer_id AND cm.user_id=$2 AND cm.status='active' +WHERE l.community_id=$1 AND l.peer_type='channel' +ORDER BY l.peer_id`, communityID, participantUserID) + if err != nil { + return domain.CommunityParticipantJoinedChats{}, err + } + defer rows.Close() + out := domain.CommunityParticipantJoinedChats{} + ids := make([]int64, 0) + for rows.Next() { + var id int64 + var role string + if err := rows.Scan(&id, &role); err != nil { + return out, err + } + ids = append(ids, id) + out.JoinedChatIDs = append(out.JoinedChatIDs, id) + if role == string(domain.ChannelRoleCreator) { + out.CreatorChatIDs = append(out.CreatorChatIDs, id) + } + } + if err := rows.Err(); err != nil { + return out, err + } + out.Channels, err = listChannelsByIDs(ctx, s.db, ids) + if err != nil { + return out, err + } + out.Users, err = listUsersByIDs(ctx, s.db, []int64{participantUserID}) + return out, err +} + +func (s *CommunityStore) banCommunityParticipantFromChannelTx(ctx context.Context, tx pgx.Tx, actorUserID, channelID, participantUserID int64, date int) (domain.EditChannelBannedResult, bool, error) { + channelStore := NewChannelStore(tx, WithChannelAllocators(s.ids, s.msgIDs)) + channel, err := getChannelByID(ctx, tx, channelID) + if err != nil { + return domain.EditChannelBannedResult{}, false, err + } + previous, err := channelStore.getChannelMember(ctx, tx, channelID, participantUserID) + if errors.Is(err, domain.ErrChannelPrivate) { + return domain.EditChannelBannedResult{}, false, nil + } + if err != nil { + return domain.EditChannelBannedResult{}, false, err + } + if previous.Status != domain.ChannelMemberActive { + return domain.EditChannelBannedResult{}, false, nil + } + member := previous + member.InviterUserID = actorUserID + member.Role = domain.ChannelRoleMember + member.Status = domain.ChannelMemberKicked + member.LeftAt = date + member.BannedRights = domain.ChannelBannedRights{ViewMessages: true, UntilDate: 0} + if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { + return domain.EditChannelBannedResult{}, false, err + } + if err := channelStore.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, UserID: actorUserID, Date: date, Type: domain.ChannelAdminLogParticipantKick, + PrevParticipant: &previous, NewParticipant: &member, + }); err != nil { + return domain.EditChannelBannedResult{}, false, err + } + channel, err = refreshChannelCountsTx(ctx, tx, channel) + if err != nil { + return domain.EditChannelBannedResult{}, false, err + } + event := transientChannelParticipantEvent(channel.ID, actorUserID, previous, member, date) + if err := clearChannelMentionsForUserTx(ctx, tx, channelID, participantUserID); err != nil { + return domain.EditChannelBannedResult{}, false, err + } + var serviceMessage domain.ChannelMessage + var serviceEvent domain.ChannelUpdateEvent + if channel.Megagroup { + serviceMessage, serviceEvent, err = channelStore.insertServiceMessage(ctx, tx, channel, actorUserID, date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChatDelete, UserIDs: []int64{participantUserID}, + }) + if err != nil { + return domain.EditChannelBannedResult{}, false, err + } + channel.TopMessageID, channel.Pts = serviceMessage.ID, serviceEvent.Pts + } + recipients, err := channelStore.listActiveChannelMemberIDs(ctx, tx, channelID, 0) + if err != nil { + return domain.EditChannelBannedResult{}, false, err + } + recipients = append(recipients, participantUserID) + return domain.EditChannelBannedResult{ + Channel: channel, Previous: previous, Participant: member, Event: event, + Recipients: recipients, Date: date, Message: serviceMessage, ServiceEvent: serviceEvent, + }, true, nil +} + +func (s *CommunityStore) ToggleCommunityParticipantBanned(ctx context.Context, actorUserID, communityID, participantUserID int64, unban bool, date int) (domain.CommunityParticipantBanResult, error) { + return withCommunityTx(ctx, s, func(tx pgx.Tx) (domain.CommunityParticipantBanResult, error) { + c, actor, err := lockCommunityActor(ctx, tx, actorUserID, communityID) + if err != nil { + return domain.CommunityParticipantBanResult{}, err + } + if !actor.CanBanUsers() || participantUserID == c.CreatorUserID { + return domain.CommunityParticipantBanResult{}, domain.ErrCommunityAdminRequired + } + if unban { + cmd, err := tx.Exec(ctx, `DELETE FROM community_members WHERE community_id=$1 AND user_id=$2 AND role='member' AND status='kicked'`, communityID, participantUserID) + return domain.CommunityParticipantBanResult{Changed: cmd.RowsAffected() > 0}, err + } + participant, found, err := derivedCommunityMember(ctx, tx, c, participantUserID) + if err != nil { + return domain.CommunityParticipantBanResult{}, err + } + if !found || (participant.Status != domain.CommunityMemberActive && participant.Status != domain.CommunityMemberKicked) { + return domain.CommunityParticipantBanResult{}, domain.ErrCommunityParticipantInvalid + } + var alreadyKicked bool + if err := tx.QueryRow(ctx, `SELECT EXISTS( +SELECT 1 FROM community_members WHERE community_id=$1 AND user_id=$2 AND role='member' AND status='kicked')`, communityID, participantUserID).Scan(&alreadyKicked); err != nil { + return domain.CommunityParticipantBanResult{}, err + } + // Chats owned by the banned participant (and their bots) leave the + // Community; the participant is kicked from every remaining linked chat. + rows, err := tx.Query(ctx, ` +SELECT l.peer_type,l.peer_id +FROM community_peer_links l +LEFT JOIN channels c ON l.peer_type='channel' AND c.id=l.peer_id +LEFT JOIN bots b ON l.peer_type='user' AND b.bot_user_id=l.peer_id +WHERE l.community_id=$1 AND (c.creator_user_id=$2 OR b.owner_user_id=$2) +FOR UPDATE OF l`, communityID, participantUserID) + if err != nil { + return domain.CommunityParticipantBanResult{}, err + } + owned := make([]domain.Peer, 0) + for rows.Next() { + var typ string + var id int64 + if err := rows.Scan(&typ, &id); err != nil { + rows.Close() + return domain.CommunityParticipantBanResult{}, err + } + owned = append(owned, domain.Peer{Type: domain.PeerType(typ), ID: id}) + } + rows.Close() + result := domain.CommunityParticipantBanResult{} + for _, p := range owned { + if _, err := tx.Exec(ctx, `DELETE FROM community_peer_links WHERE community_id=$1 AND peer_type=$2 AND peer_id=$3`, communityID, string(p.Type), p.ID); err != nil { + return domain.CommunityParticipantBanResult{}, err + } + if err := setPeerLinkedCommunity(ctx, tx, p, 0); err != nil { + return domain.CommunityParticipantBanResult{}, err + } + serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, p, actorUserID, date, 0) + if err != nil { + return domain.CommunityParticipantBanResult{}, err + } + result.RemovedLinks = append(result.RemovedLinks, domain.CommunityTogglePeerLinkResult{Community: c, Peer: p, Removed: true, ServiceMessage: serviceMessage}) + } + channelRows, err := tx.Query(ctx, `SELECT peer_id FROM community_peer_links WHERE community_id=$1 AND peer_type='channel' ORDER BY peer_id FOR UPDATE`, communityID) + if err != nil { + return domain.CommunityParticipantBanResult{}, err + } + channelIDs := make([]int64, 0) + for channelRows.Next() { + var channelID int64 + if err := channelRows.Scan(&channelID); err != nil { + channelRows.Close() + return domain.CommunityParticipantBanResult{}, err + } + channelIDs = append(channelIDs, channelID) + } + channelRows.Close() + for _, channelID := range channelIDs { + ban, changed, err := s.banCommunityParticipantFromChannelTx(ctx, tx, actorUserID, channelID, participantUserID, date) + if err != nil { + return domain.CommunityParticipantBanResult{}, err + } + if changed { + result.ChannelBans = append(result.ChannelBans, ban) + } + } + if !alreadyKicked { + if _, err := tx.Exec(ctx, ` +INSERT INTO community_members(community_id,user_id,role,status,admin_rights,date) +VALUES($1,$2,'member','kicked','{}',$3) +ON CONFLICT(community_id,user_id) DO UPDATE SET role='member',status='kicked',admin_rights='{}',rank='',date=EXCLUDED.date,updated_at=now()`, communityID, participantUserID, date); err != nil { + return domain.CommunityParticipantBanResult{}, err + } + } + result.Changed = !alreadyKicked || len(result.ChannelBans) > 0 || len(result.RemovedLinks) > 0 + return result, nil + }) +} + +func communityParticipantHash(items []domain.CommunityMember) int64 { + h := fnv.New64a() + for _, m := range items { + fmt.Fprintf(h, "%d:%s:%s;", m.UserID, m.Role, m.Status) + } + return int64(h.Sum64() & 0x7fffffffffffffff) +} + +func (s *CommunityStore) ListCommunityParticipants(ctx context.Context, viewerUserID, communityID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.CommunityParticipantList, error) { + view, err := s.GetCommunity(ctx, viewerUserID, communityID) + if err != nil { + return domain.CommunityParticipantList{}, err + } + restricted := filter.Kind == domain.ChannelParticipantsKicked || filter.Kind == domain.ChannelParticipantsBanned + if restricted && !view.Self.CanManageLinkedPeers() { + return domain.CommunityParticipantList{}, domain.ErrCommunityAdminRequired + } + where := "am.status='active'" + switch filter.Kind { + case domain.ChannelParticipantsAdmins: + where = "am.status='active' AND am.role IN ('creator','admin')" + case domain.ChannelParticipantsKicked, domain.ChannelParticipantsBanned: + where = "am.status='kicked'" + } + query := ` +WITH derived AS ( + SELECT cm.user_id FROM community_peer_links l JOIN channel_members cm ON l.peer_type='channel' AND cm.channel_id=l.peer_id + WHERE l.community_id=$1 AND cm.status='active' + UNION + SELECT d.user_id FROM community_peer_links l JOIN dialogs d ON l.peer_type='user' AND d.peer_id=l.peer_id AND d.peer_type='user' + WHERE l.community_id=$1 AND d.top_message_id>0 +), all_members AS ( + SELECT community_id,user_id,role,status,admin_rights,rank,date FROM community_members WHERE community_id=$1 + UNION ALL + SELECT $1,d.user_id,'member','active','{}'::jsonb,'',0 FROM derived d + WHERE NOT EXISTS(SELECT 1 FROM community_members m WHERE m.community_id=$1 AND m.user_id=d.user_id) +) +SELECT am.community_id,am.user_id,am.role,am.status,am.admin_rights::text,am.rank,am.date,COUNT(*) OVER()::int +FROM all_members am +JOIN users u ON u.id=am.user_id +WHERE ` + where + ` + AND ($4='' OR strpos(lower(concat_ws(' ',am.user_id::text,u.first_name,u.last_name,u.username,u.phone)),lower($4))>0) +ORDER BY CASE am.role WHEN 'creator' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END,am.user_id OFFSET $2 LIMIT $3` + rows, err := s.db.Query(ctx, query, communityID, offset, limit, strings.TrimSpace(filter.Query)) + if err != nil { + return domain.CommunityParticipantList{}, err + } + defer rows.Close() + out := domain.CommunityParticipantList{Community: view.Community} + ids := make([]int64, 0) + for rows.Next() { + var m domain.CommunityMember + var role, status, rights string + var count int + if err := rows.Scan(&m.CommunityID, &m.UserID, &role, &status, &rights, &m.Rank, &m.Date, &count); err != nil { + return out, err + } + m.Role, m.Status = domain.CommunityMemberRole(role), domain.CommunityMemberStatus(status) + _ = json.Unmarshal([]byte(rights), &m.AdminRights) + out.Count = count + out.Participants = append(out.Participants, m) + ids = append(ids, m.UserID) + } + out.Users, err = listUsersByIDs(ctx, s.db, ids) + out.Hash = communityParticipantHash(out.Participants) + return out, err +} + +func (s *CommunityStore) EditCommunityTitle(ctx context.Context, actorUserID, communityID int64, title string) (domain.CommunityView, bool, error) { + changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) { + c, m, e := lockCommunityActor(ctx, tx, actorUserID, communityID) + if e != nil { + return false, e + } + if !m.CanChangeInfo() { + return false, domain.ErrCommunityAdminRequired + } + if c.Title == title { + return false, nil + } + _, e = tx.Exec(ctx, `UPDATE communities SET title=$2,updated_at=now() WHERE id=$1`, communityID, title) + return true, e + }) + if err != nil { + return domain.CommunityView{}, false, err + } + v, err := s.GetCommunity(ctx, actorUserID, communityID) + return v, changed, err +} + +func (s *CommunityStore) EditCommunityAbout(ctx context.Context, actorUserID, communityID int64, about string) (domain.CommunityView, bool, error) { + changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) { + c, m, e := lockCommunityActor(ctx, tx, actorUserID, communityID) + if e != nil { + return false, e + } + if !m.CanChangeInfo() { + return false, domain.ErrCommunityAdminRequired + } + if c.About == about { + return false, nil + } + _, e = tx.Exec(ctx, `UPDATE communities SET about=$2,updated_at=now() WHERE id=$1`, communityID, about) + return true, e + }) + if err != nil { + return domain.CommunityView{}, false, err + } + v, err := s.GetCommunity(ctx, actorUserID, communityID) + return v, changed, err +} + +func zeroCommunityAdminRights(r domain.ChannelAdminRights) bool { + return r == (domain.ChannelAdminRights{}) +} + +func (s *CommunityStore) EditCommunityAdmin(ctx context.Context, req domain.CommunityEditAdminRequest) (domain.CommunityView, bool, error) { + changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) { + c, actor, e := lockCommunityActor(ctx, tx, req.ActorUserID, req.CommunityID) + if e != nil { + return false, e + } + if req.UserID == req.ActorUserID && actor.Role == domain.CommunityRoleAdmin && zeroCommunityAdminRights(req.Rights) { + cmd, e := tx.Exec(ctx, `DELETE FROM community_members WHERE community_id=$1 AND user_id=$2 AND role='admin'`, req.CommunityID, req.UserID) + return cmd.RowsAffected() > 0, e + } + if !actor.CanAddAdmins() { + return false, domain.ErrCommunityAdminRequired + } + if req.UserID == c.CreatorUserID { + return false, domain.ErrCommunityCreatorRequired + } + if zeroCommunityAdminRights(req.Rights) { + cmd, e := tx.Exec(ctx, `DELETE FROM community_members WHERE community_id=$1 AND user_id=$2 AND role='admin'`, req.CommunityID, req.UserID) + return cmd.RowsAffected() > 0, e + } + var exists bool + if e := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE id=$1 AND deleted_at IS NULL)`, req.UserID).Scan(&exists); e != nil || !exists { + return false, domain.ErrCommunityParticipantInvalid + } + rights, _ := json.Marshal(req.Rights) + _, e = tx.Exec(ctx, ` +INSERT INTO community_members(community_id,user_id,role,status,admin_rights,rank,date) +VALUES($1,$2,'admin','active',$3,$4,$5) +ON CONFLICT(community_id,user_id) DO UPDATE SET role='admin',status='active',admin_rights=EXCLUDED.admin_rights,rank=EXCLUDED.rank,date=EXCLUDED.date,updated_at=now()`, req.CommunityID, req.UserID, rights, req.Rank, req.Date) + return true, e + }) + if err != nil { + return domain.CommunityView{}, false, err + } + v, err := s.GetCommunity(ctx, req.ActorUserID, req.CommunityID) + if changed && req.UserID == req.ActorUserID && errors.Is(err, domain.ErrCommunityPrivate) { + c, loadErr := communityByID(ctx, s.db, req.CommunityID, false) + if loadErr != nil { + return domain.CommunityView{}, false, loadErr + } + return domain.CommunityView{Community: c, Forbidden: true}, true, nil + } + return v, changed, err +} + +func (s *CommunityStore) EditCommunityDefaultBannedRights(ctx context.Context, actorUserID, communityID int64, rights domain.ChannelBannedRights) (domain.CommunityView, bool, error) { + changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) { + c, m, e := lockCommunityActor(ctx, tx, actorUserID, communityID) + if e != nil { + return false, e + } + if !m.CanChangeInfo() { + return false, domain.ErrCommunityAdminRequired + } + if c.DefaultBannedRights == rights { + return false, nil + } + raw, _ := json.Marshal(rights) + _, e = tx.Exec(ctx, `UPDATE communities SET default_banned_rights=$2,updated_at=now() WHERE id=$1`, communityID, raw) + return true, e + }) + if err != nil { + return domain.CommunityView{}, false, err + } + v, err := s.GetCommunity(ctx, actorUserID, communityID) + return v, changed, err +} + +func (s *CommunityStore) SetCommunityPhoto(ctx context.Context, actorUserID, communityID int64, photo *domain.Photo, date int) (domain.CommunityView, bool, error) { + changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) { + c, m, e := lockCommunityActor(ctx, tx, actorUserID, communityID) + if e != nil { + return false, e + } + if !m.CanChangeInfo() { + return false, domain.ErrCommunityAdminRequired + } + id, dc := int64(0), 0 + var stripped []byte + if photo != nil { + id, dc = photo.ID, photo.DCID + stripped = domain.StrippedFromSizes(photo.Sizes) + } + if c.PhotoID == id && c.PhotoDCID == dc && string(c.PhotoStripped) == string(stripped) { + return false, nil + } + _, e = tx.Exec(ctx, `UPDATE communities SET photo_id=$2,photo_dc_id=$3,photo_stripped=$4,updated_at=now() WHERE id=$1`, communityID, id, dc, stripped) + return true, e + }) + if err != nil { + return domain.CommunityView{}, false, err + } + v, err := s.GetCommunity(ctx, actorUserID, communityID) + return v, changed, err +} + +func (s *CommunityStore) DeleteCommunity(ctx context.Context, actorUserID, communityID int64, date int) (domain.CommunityView, []domain.Peer, error) { + type result struct { + view domain.CommunityView + peers []domain.Peer + } + r, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (result, error) { + c, m, e := lockCommunityActor(ctx, tx, actorUserID, communityID) + if e != nil { + return result{}, e + } + if m.Role != domain.CommunityRoleCreator { + return result{}, domain.ErrCommunityCreatorRequired + } + links, e := communityLinkRows(ctx, tx, c, m) + if e != nil { + return result{}, e + } + peers := make([]domain.Peer, 0, len(links)) + serviceMessages := make([]domain.SendChannelMessageResult, 0, len(links)) + for _, l := range links { + peers = append(peers, l.Peer) + if e := setPeerLinkedCommunity(ctx, tx, l.Peer, 0); e != nil { + return result{}, e + } + serviceMessage, e := s.appendCommunityServiceMessageTx(ctx, tx, l.Peer, actorUserID, date, 0) + if e != nil { + return result{}, e + } + if serviceMessage != nil { + serviceMessages = append(serviceMessages, *serviceMessage) + } + } + if _, e = tx.Exec(ctx, `DELETE FROM community_peer_links WHERE community_id=$1`, communityID); e != nil { + return result{}, e + } + if _, e = tx.Exec(ctx, `DELETE FROM community_peer_link_requests WHERE community_id=$1`, communityID); e != nil { + return result{}, e + } + if _, e = tx.Exec(ctx, `DELETE FROM community_user_states WHERE community_id=$1`, communityID); e != nil { + return result{}, e + } + if _, e = tx.Exec(ctx, `UPDATE communities SET deleted=true,title='',about='',updated_at=now() WHERE id=$1`, communityID); e != nil { + return result{}, e + } + c.Deleted = true + c.Title = "" + c.About = "" + return result{view: domain.CommunityView{Community: c, Self: m, Forbidden: true, ServiceMessages: serviceMessages}, peers: peers}, nil + }) + return r.view, r.peers, err +} + +func (s *CommunityStore) SetCommunityPinned(ctx context.Context, userID, communityID int64, pinned bool) (bool, error) { + return withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) { + if _, _, e := lockCommunityActor(ctx, tx, userID, communityID); e != nil { + return false, e + } + var collapsed, old bool + var order int + e := tx.QueryRow(ctx, `SELECT collapsed,pinned,pinned_order FROM community_user_states WHERE community_id=$1 AND user_id=$2 FOR UPDATE`, communityID, userID).Scan(&collapsed, &old, &order) + if e != nil { + return false, domain.ErrCommunityInvalid + } + if !collapsed { + return false, domain.ErrCommunityInvalid + } + if old == pinned { + return false, nil + } + if pinned { + if e := tx.QueryRow(ctx, `SELECT GREATEST(1000000000,COALESCE(MAX(pinned_order),0))+1 FROM community_user_states WHERE user_id=$1 AND pinned`, userID).Scan(&order); e != nil { + return false, e + } + } else { + order = 0 + } + _, e = tx.Exec(ctx, `UPDATE community_user_states SET pinned=$3,pinned_order=$4,updated_at=now() WHERE community_id=$1 AND user_id=$2`, communityID, userID, pinned, order) + return true, e + }) +} + +func (s *CommunityStore) ReorderCommunityPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) (bool, error) { + return withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) { + seen := map[int64]struct{}{} + for _, peer := range order { + if peer.Type != domain.PeerTypeCommunity { + continue + } + if peer.ID == 0 { + return false, domain.ErrCommunityInvalid + } + if _, ok := seen[peer.ID]; ok { + return false, domain.ErrCommunityInvalid + } + seen[peer.ID] = struct{}{} + } + rows, e := tx.Query(ctx, `SELECT community_id,pinned_order FROM community_user_states WHERE user_id=$1 AND pinned FOR UPDATE`, userID) + if e != nil { + return false, e + } + old := map[int64]int{} + for rows.Next() { + var id int64 + var pinnedOrder int + if e := rows.Scan(&id, &pinnedOrder); e != nil { + rows.Close() + return false, e + } + old[id] = pinnedOrder + } + rows.Close() + changed := false + if force { + if _, e = tx.Exec(ctx, `UPDATE community_user_states SET pinned=false,pinned_order=0,updated_at=now() WHERE user_id=$1 AND pinned`, userID); e != nil { + return false, e + } + changed = len(old) > 0 + } + for i, peer := range order { + if peer.Type != domain.PeerTypeCommunity { + continue + } + pinnedOrder := len(order) - i + if old[peer.ID] == pinnedOrder && !force { + continue + } + cmd, e := tx.Exec(ctx, `UPDATE community_user_states SET pinned=true,pinned_order=$3,updated_at=now() WHERE user_id=$1 AND community_id=$2 AND collapsed`, userID, peer.ID, pinnedOrder) + if e != nil { + return false, e + } + if cmd.RowsAffected() != 1 { + return false, domain.ErrCommunityInvalid + } + changed = true + } + return changed, nil + }) +} + +func (s *CommunityStore) CommunitySearchScope(ctx context.Context, viewerUserID, communityID int64) (domain.CommunitySearchScope, error) { + v, err := s.GetCommunity(ctx, viewerUserID, communityID) + if err != nil { + return domain.CommunitySearchScope{}, err + } + out := domain.CommunitySearchScope{CommunityID: communityID} + for _, l := range v.Links { + if l.Peer.Type == domain.PeerTypeChannel { + if l.CanViewHistory { + out.ChannelIDs = append(out.ChannelIDs, l.Peer.ID) + } + } else { + out.BotUserIDs = append(out.BotUserIDs, l.Peer.ID) + } + } + return out, nil +} + +func uniqueInt64s(ids []int64) []int64 { + seen := map[int64]struct{}{} + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} diff --git a/internal/store/postgres/community_integration_test.go b/internal/store/postgres/community_integration_test.go new file mode 100644 index 00000000..e4f8d903 --- /dev/null +++ b/internal/store/postgres/community_integration_test.go @@ -0,0 +1,138 @@ +package postgres + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +func TestCommunityStoreLifecycleIsAtomicInPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 801, Phone: "+1888" + suffix + "01", FirstName: "CommunityOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{AccessHash: 802, Phone: "+1888" + suffix + "02", FirstName: "SearchableMember"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + channels := NewChannelStore(pool) + initial, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Community Initial " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1_800_200_000, + }) + if err != nil { + t.Fatalf("create initial channel: %v", err) + } + owned, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: member.ID, + Title: "Community Owned " + suffix, + Megagroup: true, + Date: 1_800_200_001, + }) + if err != nil { + t.Fatalf("create owned channel: %v", err) + } + owned.Channel, err = channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ + UserID: member.ID, ChannelID: owned.Channel.ID, Username: "communitypreview" + suffix, + }) + if err != nil { + t.Fatalf("make owned channel public: %v", err) + } + if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: member.ID, ChannelID: owned.Channel.ID, RandomID: 9_900_000_001, + Message: "community public preview search", Date: 1_800_200_001, + }); err != nil { + t.Fatalf("send public preview message: %v", err) + } + var communityID int64 + t.Cleanup(func() { + if communityID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM communities WHERE id=$1", communityID) + } + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id=ANY($1::bigint[])", []int64{initial.Channel.ID, owned.Channel.ID}) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id=ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + store := NewCommunityStore(pool, nil, nil) + created, err := store.CreateCommunity(ctx, domain.CreateCommunityRequest{ + CreatorUserID: owner.ID, + Title: "Postgres Community " + suffix, + InitialPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: initial.Channel.ID}, + Visibility: domain.CommunityPeerHidden, + Date: 1_800_200_002, + }) + if err != nil { + t.Fatalf("create community: %v", err) + } + communityID = created.Community.ID + if len(created.ServiceMessages) != 1 || created.ServiceMessages[0].Event.Pts == 0 { + t.Fatalf("create service messages = %+v", created.ServiceMessages) + } + var linkedID int64 + if err := pool.QueryRow(ctx, "SELECT linked_community_id FROM channels WHERE id=$1", initial.Channel.ID).Scan(&linkedID); err != nil || linkedID != communityID { + t.Fatalf("initial linked_community_id = %d err=%v, want %d", linkedID, err, communityID) + } + + requested, err := store.ToggleCommunityPeerLink(ctx, domain.CommunityTogglePeerLinkRequest{ + ActorUserID: member.ID, + CommunityID: communityID, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: owned.Channel.ID}, + Visibility: domain.CommunityPeerVisible, + Date: 1_800_200_003, + }) + if err != nil || !requested.RequestCreated { + t.Fatalf("create peer link request = %+v err=%v", requested, err) + } + approved, err := store.DecideCommunityPeerLinkRequest(ctx, owner.ID, communityID, requested.Peer, false, 1_800_200_004) + if err != nil || approved.Link == nil || approved.RequestedBy != member.ID || approved.ServiceMessage == nil { + t.Fatalf("approve link request = %+v err=%v", approved, err) + } + search, err := channels.SearchJoinedMessages(ctx, owner.ID, domain.ChannelGlobalSearchRequest{ + Query: "public preview", ChannelIDs: []int64{owned.Channel.ID}, RestrictChannelIDs: true, + AllowPublicPreview: true, Limit: 20, + }) + if err != nil || len(search.Messages) != 1 || search.Messages[0].ChannelID != owned.Channel.ID { + t.Fatalf("community public-preview search = %+v err=%v", search.Messages, err) + } + + participants, err := store.ListCommunityParticipants(ctx, owner.ID, communityID, domain.ChannelParticipantsFilter{ + Kind: domain.ChannelParticipantsSearch, Query: "SEARCHABLE", + }, 0, 20) + if err != nil || participants.Count != 1 || len(participants.Participants) != 1 || participants.Participants[0].UserID != member.ID { + t.Fatalf("participant search = %+v err=%v", participants, err) + } + admins, err := store.ListCommunityParticipants(ctx, member.ID, communityID, domain.ChannelParticipantsFilter{ + Kind: domain.ChannelParticipantsAdmins, + }, 0, 100) + if err != nil || admins.Count != 1 || len(admins.Participants) != 1 || admins.Participants[0].UserID != owner.ID { + t.Fatalf("member-visible Community admins = %+v err=%v", admins, err) + } + if _, err := store.ListCommunityParticipants(ctx, member.ID, communityID, domain.ChannelParticipantsFilter{ + Kind: domain.ChannelParticipantsBanned, + }, 0, 100); !errors.Is(err, domain.ErrCommunityAdminRequired) { + t.Fatalf("member Community banned list error = %v, want admin required", err) + } + + ban, err := store.ToggleCommunityParticipantBanned(ctx, owner.ID, communityID, member.ID, false, 1_800_200_005) + if err != nil { + t.Fatalf("ban participant: %v", err) + } + if !ban.Changed || len(ban.ChannelBans) != 1 || len(ban.RemovedLinks) != 1 { + t.Fatalf("ban result = %+v", ban) + } + if err := pool.QueryRow(ctx, "SELECT linked_community_id FROM channels WHERE id=$1", owned.Channel.ID).Scan(&linkedID); err != nil || linkedID != 0 { + t.Fatalf("owned linked_community_id after ban = %d err=%v, want 0", linkedID, err) + } + if _, err := store.GetCommunity(ctx, member.ID, communityID); !errors.Is(err, domain.ErrCommunityPrivate) { + t.Fatalf("banned member get community error = %v, want private", err) + } +} diff --git a/internal/store/postgres/message_history.go b/internal/store/postgres/message_history.go index c1c4de04..e9a8151c 100644 --- a/internal/store/postgres/message_history.go +++ b/internal/store/postgres/message_history.go @@ -100,21 +100,23 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma var rows []sqlcgen.ListMessagesByUserRow if addOffset >= 0 { bw, err := s.q.ListMessagesBackward(ctx, sqlcgen.ListMessagesBackwardParams{ - OwnerUserID: userID, - HasPeer: filter.HasPeer, - PeerType: string(filter.Peer.Type), - PeerID: filter.Peer.ID, - Query: filter.Query, - MaxID: pgInt32NonNegative(filter.MaxID), - MinID: pgInt32NonNegative(filter.MinID), - PinnedOnly: filter.PinnedOnly, - MusicOnly: filter.MusicOnly, - SavedPeerType: savedPeerType, - SavedPeerID: savedPeerID, - OffsetDate: pgInt32NonNegative(filter.OffsetDate), - OffsetID: pgInt32NonNegative(filter.OffsetID), - RowOffset: pgInt32Bounded(addOffset), - LimitCount: int32(queryLimit), + OwnerUserID: userID, + HasPeer: filter.HasPeer, + PeerType: string(filter.Peer.Type), + PeerID: filter.Peer.ID, + RestrictPeerIds: filter.RestrictPeerIDs, + PeerIds: filter.PeerIDs, + Query: filter.Query, + MaxID: pgInt32NonNegative(filter.MaxID), + MinID: pgInt32NonNegative(filter.MinID), + PinnedOnly: filter.PinnedOnly, + MusicOnly: filter.MusicOnly, + SavedPeerType: savedPeerType, + SavedPeerID: savedPeerID, + OffsetDate: pgInt32NonNegative(filter.OffsetDate), + OffsetID: pgInt32NonNegative(filter.OffsetID), + RowOffset: pgInt32Bounded(addOffset), + LimitCount: int32(queryLimit), }) if err != nil { return domain.MessageList{}, fmt.Errorf("list messages (backward): %w", err) @@ -125,17 +127,19 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma } if filter.NeedTotalCount { total, err := s.q.CountMessagesByUser(ctx, sqlcgen.CountMessagesByUserParams{ - OwnerUserID: userID, - HasPeer: filter.HasPeer, - PeerType: string(filter.Peer.Type), - PeerID: filter.Peer.ID, - Query: filter.Query, - MaxID: pgInt32NonNegative(filter.MaxID), - MinID: pgInt32NonNegative(filter.MinID), - PinnedOnly: filter.PinnedOnly, - MusicOnly: filter.MusicOnly, - SavedPeerType: savedPeerType, - SavedPeerID: savedPeerID, + OwnerUserID: userID, + HasPeer: filter.HasPeer, + PeerType: string(filter.Peer.Type), + PeerID: filter.Peer.ID, + RestrictPeerIds: filter.RestrictPeerIDs, + PeerIds: filter.PeerIDs, + Query: filter.Query, + MaxID: pgInt32NonNegative(filter.MaxID), + MinID: pgInt32NonNegative(filter.MinID), + PinnedOnly: filter.PinnedOnly, + MusicOnly: filter.MusicOnly, + SavedPeerType: savedPeerType, + SavedPeerID: savedPeerID, }) if err != nil { return domain.MessageList{}, fmt.Errorf("count messages: %w", err) @@ -149,22 +153,24 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma } else { var err error rows, err = s.q.ListMessagesByUser(ctx, sqlcgen.ListMessagesByUserParams{ - OwnerUserID: userID, - HasPeer: filter.HasPeer, - PeerType: string(filter.Peer.Type), - PeerID: filter.Peer.ID, - Query: filter.Query, - OffsetID: pgInt32NonNegative(filter.OffsetID), - OffsetDate: pgInt32NonNegative(filter.OffsetDate), - MaxID: pgInt32NonNegative(filter.MaxID), - MinID: pgInt32NonNegative(filter.MinID), - AddOffset: pgInt32Bounded(addOffset), - LimitCount: int32(queryLimit), - PinnedOnly: filter.PinnedOnly, - MusicOnly: filter.MusicOnly, - NeedTotalCount: filter.NeedTotalCount, - SavedPeerType: savedPeerType, - SavedPeerID: savedPeerID, + OwnerUserID: userID, + HasPeer: filter.HasPeer, + PeerType: string(filter.Peer.Type), + PeerID: filter.Peer.ID, + RestrictPeerIds: filter.RestrictPeerIDs, + PeerIds: filter.PeerIDs, + Query: filter.Query, + OffsetID: pgInt32NonNegative(filter.OffsetID), + OffsetDate: pgInt32NonNegative(filter.OffsetDate), + MaxID: pgInt32NonNegative(filter.MaxID), + MinID: pgInt32NonNegative(filter.MinID), + AddOffset: pgInt32Bounded(addOffset), + LimitCount: int32(queryLimit), + PinnedOnly: filter.PinnedOnly, + MusicOnly: filter.MusicOnly, + NeedTotalCount: filter.NeedTotalCount, + SavedPeerType: savedPeerType, + SavedPeerID: savedPeerID, }) if err != nil { return domain.MessageList{}, fmt.Errorf("list messages: %w", err) diff --git a/internal/store/postgres/queries/message.sql b/internal/store/postgres/queries/message.sql index bfc8e284..65c30d09 100644 --- a/internal/store/postgres/queries/message.sql +++ b/internal/store/postgres/queries/message.sql @@ -537,6 +537,10 @@ base AS NOT MATERIALIZED ( NOT sqlc.arg(has_peer)::boolean OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint) ) + AND ( + NOT sqlc.arg(restrict_peer_ids)::boolean + OR (m.peer_type = 'user' AND m.peer_id = ANY(sqlc.arg(peer_ids)::bigint[])) + ) AND ( sqlc.arg(query)::text = '' OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%') @@ -798,6 +802,10 @@ WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint NOT sqlc.arg(has_peer)::boolean OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint) ) + AND ( + NOT sqlc.arg(restrict_peer_ids)::boolean + OR (m.peer_type = 'user' AND m.peer_id = ANY(sqlc.arg(peer_ids)::bigint[])) + ) AND ( sqlc.arg(query)::text = '' OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%') @@ -840,6 +848,10 @@ WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint NOT sqlc.arg(has_peer)::boolean OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint) ) + AND ( + NOT sqlc.arg(restrict_peer_ids)::boolean + OR (m.peer_type = 'user' AND m.peer_id = ANY(sqlc.arg(peer_ids)::bigint[])) + ) AND ( sqlc.arg(query)::text = '' OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%') diff --git a/internal/store/postgres/queries/user.sql b/internal/store/postgres/queries/user.sql index 4d4d64ea..a21189fd 100644 --- a/internal/store/postgres/queries/user.sql +++ b/internal/store/postgres/queries/user.sql @@ -45,6 +45,7 @@ WITH matched AS ( u.profile_color_set, u.profile_color, u.profile_color_background_emoji_id, + u.linked_community_id, u.last_seen_at, (c.contact_user_id IS NOT NULL)::boolean AS contact, COALESCE(c.mutual, false)::boolean AS mutual, @@ -96,6 +97,7 @@ SELECT profile_color_set, profile_color, profile_color_background_emoji_id, + linked_community_id, last_seen_at, contact, mutual diff --git a/internal/store/postgres/sqlcgen/bot.sql.go b/internal/store/postgres/sqlcgen/bot.sql.go index 4ade2e28..ce1e506e 100644 --- a/internal/store/postgres/sqlcgen/bot.sql.go +++ b/internal/store/postgres/sqlcgen/bot.sql.go @@ -167,7 +167,7 @@ func (q *Queries) InsertBot(ctx context.Context, arg InsertBotParams) error { const insertBotUser = `-- name: InsertBotUser :one INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, is_bot, bot_info_version) VALUES ($1, '', $2, '', $3, '', TRUE, 1) -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id ` type InsertBotUserParams struct { @@ -215,6 +215,7 @@ func (q *Queries) InsertBotUser(ctx context.Context, arg InsertBotUserParams) (U &i.AccountDeleteAt, &i.EmojiStatusCollectibleID, &i.EmojiStatusCollectible, + &i.LinkedCommunityID, ) return i, err } diff --git a/internal/store/postgres/sqlcgen/message.sql.go b/internal/store/postgres/sqlcgen/message.sql.go index 0c22293c..ed0eb1c9 100644 --- a/internal/store/postgres/sqlcgen/message.sql.go +++ b/internal/store/postgres/sqlcgen/message.sql.go @@ -19,14 +19,18 @@ WHERE m.owner_user_id = $1::bigint OR (m.peer_type = $3::text AND m.peer_id = $4::bigint) ) AND ( - $5::text = '' - OR m.body ILIKE ('%' || $5::text || '%') + NOT $5::boolean + OR (m.peer_type = 'user' AND m.peer_id = ANY($6::bigint[])) ) - AND ($6::int <= 0 OR m.box_id < $6::int) - AND ($7::int <= 0 OR m.box_id > $7::int) - AND (NOT $8::boolean OR m.pinned) AND ( - NOT $9::boolean + $7::text = '' + OR m.body ILIKE ('%' || $7::text || '%') + ) + AND ($8::int <= 0 OR m.box_id < $8::int) + AND ($9::int <= 0 OR m.box_id > $9::int) + AND (NOT $10::boolean OR m.pinned) + AND ( + NOT $11::boolean OR ( m.media->>'kind' = 'document' AND EXISTS ( @@ -38,23 +42,25 @@ WHERE m.owner_user_id = $1::bigint ) ) AND ( - $10::text = '' - OR (m.saved_peer_type = $10::text AND m.saved_peer_id = $11::bigint) + $12::text = '' + OR (m.saved_peer_type = $12::text AND m.saved_peer_id = $13::bigint) ) ` type CountMessagesByUserParams struct { - OwnerUserID int64 - HasPeer bool - PeerType string - PeerID int64 - Query string - MaxID int32 - MinID int32 - PinnedOnly bool - MusicOnly bool - SavedPeerType string - SavedPeerID int64 + OwnerUserID int64 + HasPeer bool + PeerType string + PeerID int64 + RestrictPeerIds bool + PeerIds []int64 + Query string + MaxID int32 + MinID int32 + PinnedOnly bool + MusicOnly bool + SavedPeerType string + SavedPeerID int64 } // ListMessagesByUser total CTE 的独立化:相同 base 过滤(不含分页 anchor), @@ -65,6 +71,8 @@ func (q *Queries) CountMessagesByUser(ctx context.Context, arg CountMessagesByUs arg.HasPeer, arg.PeerType, arg.PeerID, + arg.RestrictPeerIds, + arg.PeerIds, arg.Query, arg.MaxID, arg.MinID, @@ -2280,14 +2288,18 @@ WHERE m.owner_user_id = $1::bigint OR (m.peer_type = $3::text AND m.peer_id = $4::bigint) ) AND ( - $5::text = '' - OR m.body ILIKE ('%' || $5::text || '%') + NOT $5::boolean + OR (m.peer_type = 'user' AND m.peer_id = ANY($6::bigint[])) ) - AND ($6::int <= 0 OR m.box_id < $6::int) - AND ($7::int <= 0 OR m.box_id > $7::int) - AND (NOT $8::boolean OR m.pinned) AND ( - NOT $9::boolean + $7::text = '' + OR m.body ILIKE ('%' || $7::text || '%') + ) + AND ($8::int <= 0 OR m.box_id < $8::int) + AND ($9::int <= 0 OR m.box_id > $9::int) + AND (NOT $10::boolean OR m.pinned) + AND ( + NOT $11::boolean OR ( m.media->>'kind' = 'document' AND EXISTS ( @@ -2299,34 +2311,36 @@ WHERE m.owner_user_id = $1::bigint ) ) AND ( - $10::text = '' - OR (m.saved_peer_type = $10::text AND m.saved_peer_id = $11::bigint) + $12::text = '' + OR (m.saved_peer_type = $12::text AND m.saved_peer_id = $13::bigint) ) AND ( - ($12::int > 0 AND m.message_date < $12::int) - OR ($12::int <= 0 AND ($13::int <= 0 OR m.box_id < $13::int)) + ($14::int > 0 AND m.message_date < $14::int) + OR ($14::int <= 0 AND ($15::int <= 0 OR m.box_id < $15::int)) ) ORDER BY m.box_id DESC -OFFSET GREATEST($14::int, 0) -LIMIT $15::int +OFFSET GREATEST($16::int, 0) +LIMIT $17::int ` type ListMessagesBackwardParams struct { - OwnerUserID int64 - HasPeer bool - PeerType string - PeerID int64 - Query string - MaxID int32 - MinID int32 - PinnedOnly bool - MusicOnly bool - SavedPeerType string - SavedPeerID int64 - OffsetDate int32 - OffsetID int32 - RowOffset int32 - LimitCount int32 + OwnerUserID int64 + HasPeer bool + PeerType string + PeerID int64 + RestrictPeerIds bool + PeerIds []int64 + Query string + MaxID int32 + MinID int32 + PinnedOnly bool + MusicOnly bool + SavedPeerType string + SavedPeerID int64 + OffsetDate int32 + OffsetID int32 + RowOffset int32 + LimitCount int32 } type ListMessagesBackwardRow struct { @@ -2416,6 +2430,8 @@ func (q *Queries) ListMessagesBackward(ctx context.Context, arg ListMessagesBack arg.HasPeer, arg.PeerType, arg.PeerID, + arg.RestrictPeerIds, + arg.PeerIds, arg.Query, arg.MaxID, arg.MinID, @@ -2618,14 +2634,18 @@ base AS NOT MATERIALIZED ( OR (m.peer_type = $7::text AND m.peer_id = $8::bigint) ) AND ( - $9::text = '' - OR m.body ILIKE ('%' || $9::text || '%') + NOT $9::boolean + OR (m.peer_type = 'user' AND m.peer_id = ANY($10::bigint[])) ) - AND ($10::int <= 0 OR m.box_id < $10::int) - AND ($11::int <= 0 OR m.box_id > $11::int) - AND (NOT $12::boolean OR m.pinned) AND ( - NOT $13::boolean + $11::text = '' + OR m.body ILIKE ('%' || $11::text || '%') + ) + AND ($12::int <= 0 OR m.box_id < $12::int) + AND ($13::int <= 0 OR m.box_id > $13::int) + AND (NOT $14::boolean OR m.pinned) + AND ( + NOT $15::boolean OR ( m.media->>'kind' = 'document' AND EXISTS ( @@ -2637,14 +2657,14 @@ base AS NOT MATERIALIZED ( ) ) AND ( - $14::text = '' - OR (m.saved_peer_type = $14::text AND m.saved_peer_id = $15::bigint) + $16::text = '' + OR (m.saved_peer_type = $16::text AND m.saved_peer_id = $17::bigint) ) ), total AS ( SELECT count(*)::int AS total_count FROM base - WHERE $16::boolean + WHERE $18::boolean ), backward AS ( SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.hide_edited, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at @@ -2791,22 +2811,24 @@ ORDER BY box_id DESC ` type ListMessagesByUserParams struct { - OwnerUserID int64 - OffsetID int32 - OffsetDate int32 - AddOffset int32 - LimitCount int32 - HasPeer bool - PeerType string - PeerID int64 - Query string - MaxID int32 - MinID int32 - PinnedOnly bool - MusicOnly bool - SavedPeerType string - SavedPeerID int64 - NeedTotalCount bool + OwnerUserID int64 + OffsetID int32 + OffsetDate int32 + AddOffset int32 + LimitCount int32 + HasPeer bool + PeerType string + PeerID int64 + RestrictPeerIds bool + PeerIds []int64 + Query string + MaxID int32 + MinID int32 + PinnedOnly bool + MusicOnly bool + SavedPeerType string + SavedPeerID int64 + NeedTotalCount bool } type ListMessagesByUserRow struct { @@ -2896,6 +2918,8 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser arg.HasPeer, arg.PeerType, arg.PeerID, + arg.RestrictPeerIds, + arg.PeerIds, arg.Query, arg.MaxID, arg.MinID, diff --git a/internal/store/postgres/sqlcgen/models.go b/internal/store/postgres/sqlcgen/models.go index a7e5af97..651e544d 100644 --- a/internal/store/postgres/sqlcgen/models.go +++ b/internal/store/postgres/sqlcgen/models.go @@ -311,6 +311,7 @@ type BotApiUpdate struct { CallbackInlineOwnerID int64 CallbackInlineMessageID int32 CallbackInlineAccessHash int64 + EphemeralPayload []byte } type BotApiUpdateState struct { @@ -323,6 +324,21 @@ type BotApiUpdateState struct { PollExpiresAt pgtype.Timestamptz } +type BotApiWebhook struct { + BotUserID int64 + Url string + SecretToken string + MaxConnections int32 + AllowedUpdates []string + FailureCount int32 + LastErrorDate int32 + LastErrorMessage string + NextAttemptAt pgtype.Timestamptz + DeliveryOwner string + DeliveryExpiresAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + type BotApp struct { ID int64 BotUserID int64 @@ -487,6 +503,7 @@ type Channel struct { LinkedMonoforumID int64 Wallpaper []byte Verified bool + LinkedCommunityID int64 } type ChannelAdminLogEvent struct { @@ -844,6 +861,62 @@ type ChatlistMembership struct { UpdatedAt pgtype.Timestamptz } +type Community struct { + ID int64 + AccessHash int64 + CreatorUserID int64 + Title string + About string + DefaultBannedRights []byte + PhotoID int64 + PhotoDcID int32 + PhotoStripped []byte + Date int32 + Deleted bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type CommunityMember struct { + CommunityID int64 + UserID int64 + Role string + Status string + AdminRights []byte + Rank string + Date int32 + UpdatedAt pgtype.Timestamptz +} + +type CommunityPeerLink struct { + CommunityID int64 + PeerType string + PeerID int64 + Visibility string + CreatedBy int64 + Date int32 + CreatedAt pgtype.Timestamptz +} + +type CommunityPeerLinkRequest struct { + CommunityID int64 + PeerType string + PeerID int64 + RequestedBy int64 + Visibility string + Date int32 + CreatedAt pgtype.Timestamptz +} + +type CommunityUserState struct { + CommunityID int64 + UserID int64 + Collapsed bool + Pinned bool + PinnedOrder int32 + UpdatedAt pgtype.Timestamptz +} + type Contact struct { UserID int64 ContactUserID int64 @@ -1019,6 +1092,21 @@ type EncryptedStateEventDelivery struct { AuthKeyID int64 } +type EphemeralAbuseReport struct { + ID int64 + ReporterUserID int64 + ChannelID int64 + EphemeralMessageID int32 + SenderUserID int64 + ReceiverUserID int64 + ReportOption string + ReportComment string + CommentHash []byte + PayloadHash []byte + Evidence []byte + CreatedAt pgtype.Timestamptz +} + type FileBlob struct { LocationKey string Backend string @@ -1785,6 +1873,18 @@ type StarGiftOffer struct { ResolutionNotified bool } +type StarGiftPatternDocumentRepair struct { + OldDocumentID int64 + NewDocumentID int64 + RepairedAt pgtype.Timestamptz +} + +type StarGiftPatternPreviewDocumentRepair struct { + OldDocumentID int64 + NewDocumentID int64 + RepairedAt pgtype.Timestamptz +} + type StarGiftPrepaidUpgradeCommand struct { PayerUserID int64 CommandKey string @@ -2000,6 +2100,28 @@ type StoryView struct { UpdatedAt pgtype.Timestamptz } +type TelesrvCollectiblePatternCorrectionEvent struct { + UserID int64 + Pts int32 +} + +type TelesrvPatternPreviewCorrectionEvent struct { + UserID int64 + Pts int32 +} + +type TelesrvPatternPreviewRepairedWearer struct { + UserID int64 + OldDocumentID int64 + NewDocumentID int64 +} + +type TelesrvRepairedCollectibleWearer struct { + UserID int64 + OldDocumentID int64 + NewDocumentID int64 +} + type TempAuthKeyBinding struct { TempAuthKeyID int64 PermAuthKeyID int64 @@ -2160,6 +2282,7 @@ type User struct { AccountDeleteAt pgtype.Timestamptz EmojiStatusCollectibleID *int64 EmojiStatusCollectible []byte + LinkedCommunityID int64 } type UserBusinessProfile struct { @@ -2298,13 +2421,17 @@ type WebviewCustomMethodQuery struct { } type WebviewRequestedButton struct { - WebappReqID string - BotUserID int64 - UserID int64 - ButtonID int32 - Text string - PeerType string - MaxQuantity int32 - CreatedAt pgtype.Timestamptz - ExpiresAt pgtype.Timestamptz + WebappReqID string + BotUserID int64 + UserID int64 + ButtonID int32 + Text string + PeerType string + MaxQuantity int32 + CreatedAt pgtype.Timestamptz + ExpiresAt pgtype.Timestamptz + PeerFilter []byte + NameRequested bool + UsernameRequested bool + PhotoRequested bool } diff --git a/internal/store/postgres/sqlcgen/user.sql.go b/internal/store/postgres/sqlcgen/user.sql.go index 4d8891e6..324361e7 100644 --- a/internal/store/postgres/sqlcgen/user.sql.go +++ b/internal/store/postgres/sqlcgen/user.sql.go @@ -14,7 +14,7 @@ import ( const createUser = `-- name: CreateUser :one INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, premium_expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id ` type CreateUserParams struct { @@ -74,12 +74,13 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.AccountDeleteAt, &i.EmojiStatusCollectibleID, &i.EmojiStatusCollectible, + &i.LinkedCommunityID, ) return i, err } const getUserByID = `-- name: GetUserByID :one -SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible FROM users WHERE id = $1 +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id FROM users WHERE id = $1 ` func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { @@ -121,12 +122,13 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { &i.AccountDeleteAt, &i.EmojiStatusCollectibleID, &i.EmojiStatusCollectible, + &i.LinkedCommunityID, ) return i, err } const getUserByPhone = `-- name: GetUserByPhone :one -SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible FROM users WHERE phone = $1 AND deleted_at IS NULL +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id FROM users WHERE phone = $1 AND deleted_at IS NULL ` func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) { @@ -168,12 +170,13 @@ func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error &i.AccountDeleteAt, &i.EmojiStatusCollectibleID, &i.EmojiStatusCollectible, + &i.LinkedCommunityID, ) return i, err } const getUserByUsername = `-- name: GetUserByUsername :one -SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL ` func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, error) { @@ -215,12 +218,13 @@ func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, er &i.AccountDeleteAt, &i.EmojiStatusCollectibleID, &i.EmojiStatusCollectible, + &i.LinkedCommunityID, ) return i, err } const getUsersByIDs = `-- name: GetUsersByIDs :many -SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id FROM users WHERE id = ANY($1::bigint[]) ORDER BY id @@ -271,6 +275,7 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error &i.AccountDeleteAt, &i.EmojiStatusCollectibleID, &i.EmojiStatusCollectible, + &i.LinkedCommunityID, ); err != nil { return nil, err } @@ -283,7 +288,7 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error } const getUsersByPhones = `-- name: GetUsersByPhones :many -SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id FROM users WHERE phone = ANY($1::text[]) AND deleted_at IS NULL ORDER BY id @@ -334,6 +339,7 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User &i.AccountDeleteAt, &i.EmojiStatusCollectibleID, &i.EmojiStatusCollectible, + &i.LinkedCommunityID, ); err != nil { return nil, err } @@ -371,6 +377,7 @@ WITH matched AS ( u.profile_color_set, u.profile_color, u.profile_color_background_emoji_id, + u.linked_community_id, u.last_seen_at, (c.contact_user_id IS NOT NULL)::boolean AS contact, COALESCE(c.mutual, false)::boolean AS mutual, @@ -422,6 +429,7 @@ SELECT profile_color_set, profile_color, profile_color_background_emoji_id, + linked_community_id, last_seen_at, contact, mutual @@ -462,6 +470,7 @@ type SearchUsersRow struct { ProfileColorSet bool ProfileColor int32 ProfileColorBackgroundEmojiID int64 + LinkedCommunityID int64 LastSeenAt int64 Contact bool Mutual bool @@ -506,6 +515,7 @@ func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]Sea &i.ProfileColorSet, &i.ProfileColor, &i.ProfileColorBackgroundEmojiID, + &i.LinkedCommunityID, &i.LastSeenAt, &i.Contact, &i.Mutual, @@ -525,7 +535,7 @@ UPDATE users SET premium_expires_at = $1::timestamptz, updated_at = now() WHERE id = $2::bigint AND deleted_at IS NULL -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id ` type SetUserPremiumUntilParams struct { @@ -572,6 +582,7 @@ func (q *Queries) SetUserPremiumUntil(ctx context.Context, arg SetUserPremiumUnt &i.AccountDeleteAt, &i.EmojiStatusCollectibleID, &i.EmojiStatusCollectible, + &i.LinkedCommunityID, ) return i, err } @@ -581,7 +592,7 @@ UPDATE users SET verified = $1::boolean, updated_at = now() WHERE id = $2::bigint AND deleted_at IS NULL -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id ` type SetUserVerifiedParams struct { @@ -628,6 +639,7 @@ func (q *Queries) SetUserVerified(ctx context.Context, arg SetUserVerifiedParams &i.AccountDeleteAt, &i.EmojiStatusCollectibleID, &i.EmojiStatusCollectible, + &i.LinkedCommunityID, ) return i, err } @@ -644,7 +656,7 @@ WHERE id IN ( ORDER BY premium_expires_at LIMIT $2::int ) -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id ` type SweepExpiredPremiumParams struct { @@ -697,6 +709,7 @@ func (q *Queries) SweepExpiredPremium(ctx context.Context, arg SweepExpiredPremi &i.AccountDeleteAt, &i.EmojiStatusCollectibleID, &i.EmojiStatusCollectible, + &i.LinkedCommunityID, ); err != nil { return nil, err } @@ -715,7 +728,7 @@ SET birthday_day = $1::int, birthday_year = $3::int, updated_at = now() WHERE id = $4::bigint AND deleted_at IS NULL -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id ` type UpdateUserBirthdayParams struct { @@ -769,6 +782,7 @@ func (q *Queries) UpdateUserBirthday(ctx context.Context, arg UpdateUserBirthday &i.AccountDeleteAt, &i.EmojiStatusCollectibleID, &i.EmojiStatusCollectible, + &i.LinkedCommunityID, ) return i, err } @@ -780,7 +794,7 @@ SET color_set = $1::boolean, color_background_emoji_id = $3::bigint, updated_at = now() WHERE id = $4::bigint AND deleted_at IS NULL -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id ` type UpdateUserColorParams struct { @@ -834,6 +848,7 @@ func (q *Queries) UpdateUserColor(ctx context.Context, arg UpdateUserColorParams &i.AccountDeleteAt, &i.EmojiStatusCollectibleID, &i.EmojiStatusCollectible, + &i.LinkedCommunityID, ) return i, err } @@ -846,7 +861,7 @@ SET emoji_status_document_id = $1::bigint, emoji_status_collectible = $4::jsonb, updated_at = now() WHERE id = $5::bigint AND deleted_at IS NULL -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id ` type UpdateUserEmojiStatusParams struct { @@ -902,6 +917,7 @@ func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmoji &i.AccountDeleteAt, &i.EmojiStatusCollectibleID, &i.EmojiStatusCollectible, + &i.LinkedCommunityID, ) return i, err } @@ -928,7 +944,7 @@ UPDATE users SET personal_channel_id = $1::bigint, updated_at = now() WHERE id = $2::bigint AND deleted_at IS NULL -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id ` type UpdateUserPersonalChannelParams struct { @@ -975,6 +991,7 @@ func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserP &i.AccountDeleteAt, &i.EmojiStatusCollectibleID, &i.EmojiStatusCollectible, + &i.LinkedCommunityID, ) return i, err } @@ -984,7 +1001,7 @@ UPDATE users SET phone = $1::text, updated_at = now() WHERE id = $2::bigint AND deleted_at IS NULL -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id ` type UpdateUserPhoneParams struct { @@ -1031,6 +1048,7 @@ func (q *Queries) UpdateUserPhone(ctx context.Context, arg UpdateUserPhoneParams &i.AccountDeleteAt, &i.EmojiStatusCollectibleID, &i.EmojiStatusCollectible, + &i.LinkedCommunityID, ) return i, err } @@ -1042,7 +1060,7 @@ SET first_name = $2, about = $4, updated_at = now() WHERE id = $1 AND deleted_at IS NULL -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id ` type UpdateUserProfileParams struct { @@ -1096,6 +1114,7 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa &i.AccountDeleteAt, &i.EmojiStatusCollectibleID, &i.EmojiStatusCollectible, + &i.LinkedCommunityID, ) return i, err } @@ -1107,7 +1126,7 @@ SET profile_color_set = $1::boolean, profile_color_background_emoji_id = $3::bigint, updated_at = now() WHERE id = $4::bigint AND deleted_at IS NULL -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id ` type UpdateUserProfileColorParams struct { @@ -1161,6 +1180,7 @@ func (q *Queries) UpdateUserProfileColor(ctx context.Context, arg UpdateUserProf &i.AccountDeleteAt, &i.EmojiStatusCollectibleID, &i.EmojiStatusCollectible, + &i.LinkedCommunityID, ) return i, err } @@ -1170,7 +1190,7 @@ UPDATE users SET username = $2, updated_at = now() WHERE id = $1 AND deleted_at IS NULL -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible, linked_community_id ` type UpdateUserUsernameParams struct { @@ -1217,6 +1237,7 @@ func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsername &i.AccountDeleteAt, &i.EmojiStatusCollectibleID, &i.EmojiStatusCollectible, + &i.LinkedCommunityID, ) return i, err } diff --git a/internal/store/postgres/user.go b/internal/store/postgres/user.go index afc5391e..1f74af52 100644 --- a/internal/store/postgres/user.go +++ b/internal/store/postgres/user.go @@ -155,6 +155,7 @@ func (s *UserStore) Search(ctx context.Context, currentUserID int64, query, phon EmojiStatusCollectible: collectible, Color: peerColorFromModel(row.ColorSet, row.Color, row.ColorBackgroundEmojiID), ProfileColor: peerColorFromModel(row.ProfileColorSet, row.ProfileColor, row.ProfileColorBackgroundEmojiID), + LinkedCommunityID: row.LinkedCommunityID, LastSeenAt: int(row.LastSeenAt), Contact: row.Contact, Mutual: row.Mutual, @@ -555,6 +556,7 @@ func userFromModel(r sqlcgen.User) domain.User { EmojiStatusCollectible: collectible, Birthday: domain.Birthday{Day: int(r.BirthdayDay), Month: int(r.BirthdayMonth), Year: int(r.BirthdayYear)}, PersonalChannelID: r.PersonalChannelID, + LinkedCommunityID: r.LinkedCommunityID, Color: peerColorFromModel(r.ColorSet, r.Color, r.ColorBackgroundEmojiID), ProfileColor: peerColorFromModel(r.ProfileColorSet, r.ProfileColor, r.ProfileColorBackgroundEmojiID), LastSeenAt: int(r.LastSeenAt),