feat: sync community aggregates

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

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

View file

@ -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),

View file

@ -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;

View file

@ -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.

View file

@ -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 {

View file

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

View file

@ -280,6 +280,9 @@ type ChannelBannedRights struct {
SendPlain bool
EditRank bool
SendReactions bool
// 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
}
@ -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
@ -2040,6 +2051,12 @@ type ChannelSearchPostsRequest struct {
// over channel/supergroup messages visible to the current account.
type ChannelGlobalSearchRequest struct {
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

View file

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

View file

@ -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

View file

@ -242,6 +242,10 @@ type MessageFilter struct {
// SavedPeer 非零时仅返回 self-chat 中该 saved 子会话的消息
// messages.getSavedHistoryPeer 必须同时是 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 是私聊文本/媒体发送命令。

View file

@ -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 表示无头像。

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

@ -297,6 +297,12 @@ func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageAction
return &tg.MessageActionSetChatWallPaper{Wallpaper: wallpaper}
}
return nil
case domain.ChannelActionChangeCommunity:
out := &tg.MessageActionChangeCommunity{}
if action.CommunityID != 0 {
out.SetCommunityID(action.CommunityID)
}
return out
default:
return nil
}
@ -431,6 +437,9 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
if ch.LinkedMonoforumID != 0 && ch.BroadcastMessagesAllowed {
out.SetLinkedMonoforumID(ch.LinkedMonoforumID)
}
if ch.LinkedCommunityID != 0 {
out.SetLinkedCommunityID(ch.LinkedCommunityID)
}
if ch.Username != "" {
out.SetUsername(ch.Username)
out.SetUsernames(tgUsernames(ch.Username))
@ -874,6 +883,7 @@ func tgChatBannedRights(rights domain.ChannelBannedRights) tg.ChatBannedRights {
SendPlain: rights.SendPlain,
EditRank: rights.EditRank,
SendReactions: rights.SendReactions,
ManageLinkedPeers: rights.ManageLinkedPeers,
UntilDate: rights.UntilDate,
}
}
@ -910,6 +920,7 @@ func domainChannelBannedRights(rights tg.ChatBannedRights) domain.ChannelBannedR
SendPlain: rights.SendPlain,
EditRank: rights.EditRank,
SendReactions: rights.SendReactions,
ManageLinkedPeers: rights.ManageLinkedPeers,
UntilDate: rights.UntilDate,
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.

View file

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

View file

@ -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

View file

@ -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 {

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -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,

View file

@ -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

View file

@ -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(`
@ -243,14 +247,17 @@ AND EXISTS (
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)
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)`
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`

View file

@ -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,

File diff suppressed because it is too large Load diff

View file

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

View file

@ -104,6 +104,8 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
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),
@ -129,6 +131,8 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
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),
@ -153,6 +157,8 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
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),

View file

@ -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 || '%')

View file

@ -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

View file

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

View file

@ -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,8 +42,8 @@ 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)
)
`
@ -48,6 +52,8 @@ type CountMessagesByUserParams struct {
HasPeer bool
PeerType string
PeerID int64
RestrictPeerIds bool
PeerIds []int64
Query string
MaxID int32
MinID int32
@ -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,16 +2311,16 @@ 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 {
@ -2316,6 +2328,8 @@ type ListMessagesBackwardParams struct {
HasPeer bool
PeerType string
PeerID int64
RestrictPeerIds bool
PeerIds []int64
Query string
MaxID int32
MinID int32
@ -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
@ -2799,6 +2819,8 @@ type ListMessagesByUserParams struct {
HasPeer bool
PeerType string
PeerID int64
RestrictPeerIds bool
PeerIds []int64
Query string
MaxID int32
MinID int32
@ -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,

View file

@ -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 {
@ -2307,4 +2430,8 @@ type WebviewRequestedButton struct {
MaxQuantity int32
CreatedAt pgtype.Timestamptz
ExpiresAt pgtype.Timestamptz
PeerFilter []byte
NameRequested bool
UsernameRequested bool
PhotoRequested bool
}

View file

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

View file

@ -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),