Sync telesrv 36eda30 (feat(communities): implement Layer 228 community aggregates). Skipped telesrv docs changes per public sync rules; normalized the public appearance seed label.
1374 lines
54 KiB
Go
1374 lines
54 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"hash/fnv"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store"
|
|
"telesrv/internal/store/postgres/sqlcgen"
|
|
)
|
|
|
|
type CommunityStore struct {
|
|
db sqlcgen.DBTX
|
|
ids store.ChannelIDAllocator
|
|
msgIDs store.ChannelMessageIDAllocator
|
|
}
|
|
|
|
func NewCommunityStore(db sqlcgen.DBTX, ids store.ChannelIDAllocator, msgIDs store.ChannelMessageIDAllocator) *CommunityStore {
|
|
if ids == nil {
|
|
ids = pgChannelIDAllocator{db: db}
|
|
}
|
|
if msgIDs == nil {
|
|
msgIDs = pgChannelMessageIDAllocator{db: db}
|
|
}
|
|
return &CommunityStore{db: db, ids: ids, msgIDs: msgIDs}
|
|
}
|
|
|
|
func (s *CommunityStore) appendCommunityServiceMessageTx(ctx context.Context, tx pgx.Tx, peer domain.Peer, actorUserID int64, date int, communityID int64) (*domain.SendChannelMessageResult, error) {
|
|
if peer.Type != domain.PeerTypeChannel {
|
|
return nil, nil
|
|
}
|
|
channel, err := getChannelByID(ctx, tx, peer.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
channelStore := NewChannelStore(tx, WithChannelAllocators(s.ids, s.msgIDs))
|
|
message, event, err := channelStore.insertServiceMessage(ctx, tx, channel, actorUserID, date, domain.ChannelMessageAction{
|
|
Type: domain.ChannelActionChangeCommunity,
|
|
CommunityID: communityID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
channel.TopMessageID = message.ID
|
|
channel.Pts = event.Pts
|
|
// The Community transaction has already validated the actor and holds the
|
|
// linked channel row. Use the internal membership scan here: the public
|
|
// method treats viewerUserID=0 as an unauthenticated private-channel read.
|
|
recipients, err := channelStore.listActiveChannelMemberIDs(ctx, tx, channel.ID, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &domain.SendChannelMessageResult{Channel: channel, Message: message, Event: event, Recipients: recipients}, nil
|
|
}
|
|
|
|
const communityColumns = `id, access_hash, creator_user_id, title, about,
|
|
default_banned_rights::text, photo_id, photo_dc_id, photo_stripped, date, deleted`
|
|
|
|
func scanCommunity(row rowScanner) (domain.Community, error) {
|
|
var c domain.Community
|
|
var rights string
|
|
if err := row.Scan(&c.ID, &c.AccessHash, &c.CreatorUserID, &c.Title, &c.About,
|
|
&rights, &c.PhotoID, &c.PhotoDCID, &c.PhotoStripped, &c.Date, &c.Deleted); err != nil {
|
|
return domain.Community{}, err
|
|
}
|
|
if err := json.Unmarshal([]byte(rights), &c.DefaultBannedRights); err != nil {
|
|
return domain.Community{}, fmt.Errorf("decode community banned rights: %w", err)
|
|
}
|
|
c.PhotoStripped = append([]byte(nil), c.PhotoStripped...)
|
|
return c, nil
|
|
}
|
|
|
|
func scanCommunityMember(row rowScanner) (domain.CommunityMember, error) {
|
|
var m domain.CommunityMember
|
|
var role, status, rights string
|
|
if err := row.Scan(&m.CommunityID, &m.UserID, &role, &status, &rights, &m.Rank, &m.Date); err != nil {
|
|
return domain.CommunityMember{}, err
|
|
}
|
|
m.Role = domain.CommunityMemberRole(role)
|
|
m.Status = domain.CommunityMemberStatus(status)
|
|
if err := json.Unmarshal([]byte(rights), &m.AdminRights); err != nil {
|
|
return domain.CommunityMember{}, fmt.Errorf("decode community admin rights: %w", err)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (s *CommunityStore) begin(ctx context.Context) (pgx.Tx, error) {
|
|
b, ok := s.db.(txBeginner)
|
|
if !ok {
|
|
return nil, errors.New("community store requires transaction-capable db")
|
|
}
|
|
return b.Begin(ctx)
|
|
}
|
|
|
|
func withCommunityTx[T any](ctx context.Context, s *CommunityStore, fn func(pgx.Tx) (T, error)) (T, error) {
|
|
var zero T
|
|
tx, err := s.begin(ctx)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
_ = tx.Rollback(ctx)
|
|
}
|
|
}()
|
|
out, err := fn(tx)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return zero, fmt.Errorf("commit community transaction: %w", err)
|
|
}
|
|
committed = true
|
|
return out, nil
|
|
}
|
|
|
|
func communityByID(ctx context.Context, db sqlcgen.DBTX, id int64, forUpdate bool) (domain.Community, error) {
|
|
lock := ""
|
|
if forUpdate {
|
|
lock = " FOR UPDATE"
|
|
}
|
|
c, err := scanCommunity(db.QueryRow(ctx, `SELECT `+communityColumns+` FROM communities WHERE id=$1 AND NOT deleted`+lock, id))
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.Community{}, domain.ErrCommunityInvalid
|
|
}
|
|
return domain.Community{}, fmt.Errorf("get community: %w", err)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func explicitCommunityMember(ctx context.Context, db sqlcgen.DBTX, communityID, userID int64) (domain.CommunityMember, bool, error) {
|
|
m, err := scanCommunityMember(db.QueryRow(ctx, `
|
|
SELECT community_id, user_id, role, status, admin_rights::text, rank, date
|
|
FROM community_members WHERE community_id=$1 AND user_id=$2`, communityID, userID))
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.CommunityMember{}, false, nil
|
|
}
|
|
return domain.CommunityMember{}, false, fmt.Errorf("get community member: %w", err)
|
|
}
|
|
return m, true, nil
|
|
}
|
|
|
|
func derivedCommunityMember(ctx context.Context, db sqlcgen.DBTX, c domain.Community, userID int64) (domain.CommunityMember, bool, error) {
|
|
if userID == 0 {
|
|
return domain.CommunityMember{}, false, nil
|
|
}
|
|
if m, ok, err := explicitCommunityMember(ctx, db, c.ID, userID); err != nil || ok {
|
|
return m, ok, err
|
|
}
|
|
var joined bool
|
|
err := db.QueryRow(ctx, `
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM community_peer_links l
|
|
JOIN channel_members cm ON l.peer_type='channel' AND cm.channel_id=l.peer_id
|
|
WHERE l.community_id=$1 AND cm.user_id=$2 AND cm.status='active'
|
|
UNION ALL
|
|
SELECT 1
|
|
FROM community_peer_links l
|
|
JOIN dialogs d ON l.peer_type='user' AND d.peer_id=l.peer_id AND d.peer_type='user'
|
|
WHERE l.community_id=$1 AND d.user_id=$2 AND d.top_message_id > 0
|
|
LIMIT 1
|
|
)`, c.ID, userID).Scan(&joined)
|
|
if err != nil {
|
|
return domain.CommunityMember{}, false, fmt.Errorf("derive community member: %w", err)
|
|
}
|
|
if !joined {
|
|
return domain.CommunityMember{}, false, nil
|
|
}
|
|
return domain.CommunityMember{
|
|
CommunityID: c.ID, UserID: userID, Role: domain.CommunityRoleMember,
|
|
Status: domain.CommunityMemberActive, Date: c.Date,
|
|
}, true, nil
|
|
}
|
|
|
|
func communityState(ctx context.Context, db sqlcgen.DBTX, communityID, userID int64) (domain.CommunityUserState, error) {
|
|
state := domain.CommunityUserState{CommunityID: communityID, UserID: userID}
|
|
err := db.QueryRow(ctx, `
|
|
SELECT collapsed, pinned, pinned_order FROM community_user_states
|
|
WHERE community_id=$1 AND user_id=$2`, communityID, userID).
|
|
Scan(&state.Collapsed, &state.Pinned, &state.PinnedOrder)
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.CommunityUserState{}, fmt.Errorf("get community state: %w", err)
|
|
}
|
|
return state, nil
|
|
}
|
|
|
|
func communityLinkRows(ctx context.Context, db sqlcgen.DBTX, c domain.Community, viewer domain.CommunityMember) ([]domain.CommunityPeerLink, error) {
|
|
rows, err := db.Query(ctx, `
|
|
SELECT l.peer_type, l.peer_id, l.visibility, l.created_by, l.date,
|
|
CASE
|
|
WHEN l.peer_type='channel' THEN EXISTS (
|
|
SELECT 1 FROM channel_members cm
|
|
WHERE cm.channel_id=l.peer_id AND cm.user_id=$2 AND cm.status='active')
|
|
ELSE EXISTS (
|
|
SELECT 1 FROM dialogs d
|
|
WHERE d.user_id=$2 AND d.peer_type='user' AND d.peer_id=l.peer_id AND d.top_message_id > 0)
|
|
END AS joined,
|
|
CASE
|
|
WHEN l.peer_type='user' THEN true
|
|
ELSE EXISTS (
|
|
SELECT 1 FROM channels c
|
|
WHERE c.id=l.peer_id AND NOT c.deleted AND COALESCE(c.username,'') <> '')
|
|
END AS inherently_viewable
|
|
FROM community_peer_links l
|
|
WHERE l.community_id=$1
|
|
ORDER BY l.date, l.peer_type, l.peer_id`, c.ID, viewer.UserID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list community links: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
canManage := viewer.CanManageLinkedPeers()
|
|
out := make([]domain.CommunityPeerLink, 0)
|
|
for rows.Next() {
|
|
var typ, visibility string
|
|
var id, createdBy int64
|
|
var date int
|
|
var joined, inherentlyViewable bool
|
|
if err := rows.Scan(&typ, &id, &visibility, &createdBy, &date, &joined, &inherentlyViewable); err != nil {
|
|
return nil, fmt.Errorf("scan community link: %w", err)
|
|
}
|
|
if visibility == string(domain.CommunityPeerHidden) && !joined && !canManage {
|
|
continue
|
|
}
|
|
out = append(out, domain.CommunityPeerLink{
|
|
CommunityID: c.ID, Peer: domain.Peer{Type: domain.PeerType(typ), ID: id},
|
|
Visibility: domain.CommunityPeerVisibility(visibility), CanViewHistory: joined || inherentlyViewable,
|
|
CreatedBy: createdBy, Date: date,
|
|
})
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func communityView(ctx context.Context, db sqlcgen.DBTX, viewerUserID, communityID int64) (domain.CommunityView, error) {
|
|
c, err := communityByID(ctx, db, communityID, false)
|
|
if err != nil {
|
|
return domain.CommunityView{}, err
|
|
}
|
|
self, joined, err := derivedCommunityMember(ctx, db, c, viewerUserID)
|
|
if err != nil {
|
|
return domain.CommunityView{}, err
|
|
}
|
|
if !joined || !self.Active() {
|
|
return domain.CommunityView{Community: c, Self: self, Forbidden: true}, domain.ErrCommunityPrivate
|
|
}
|
|
state, err := communityState(ctx, db, c.ID, viewerUserID)
|
|
if err != nil {
|
|
return domain.CommunityView{}, err
|
|
}
|
|
links, err := communityLinkRows(ctx, db, c, self)
|
|
if err != nil {
|
|
return domain.CommunityView{}, err
|
|
}
|
|
view := domain.CommunityView{Community: c, Self: self, State: state, Links: links}
|
|
channelIDs, userIDs := make([]int64, 0, len(links)), make([]int64, 0, len(links))
|
|
for _, link := range links {
|
|
if link.Peer.Type == domain.PeerTypeChannel {
|
|
channelIDs = append(channelIDs, link.Peer.ID)
|
|
} else {
|
|
userIDs = append(userIDs, link.Peer.ID)
|
|
}
|
|
}
|
|
view.Channels, err = listChannelsByIDs(ctx, db, channelIDs)
|
|
if err != nil {
|
|
return domain.CommunityView{}, err
|
|
}
|
|
view.Users, err = listUsersByIDs(ctx, db, userIDs)
|
|
if err != nil {
|
|
return domain.CommunityView{}, err
|
|
}
|
|
err = db.QueryRow(ctx, `
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE status='active' AND role IN ('creator','admin'))::int,
|
|
COUNT(*) FILTER (WHERE status='kicked')::int,
|
|
(SELECT COUNT(*)::int FROM community_peer_link_requests WHERE community_id=$1)
|
|
FROM community_members WHERE community_id=$1`, c.ID).
|
|
Scan(&view.AdminsCount, &view.KickedCount, &view.PendingRequests)
|
|
if err != nil {
|
|
return domain.CommunityView{}, fmt.Errorf("get community counts: %w", err)
|
|
}
|
|
return view, nil
|
|
}
|
|
|
|
func (s *CommunityStore) GetCommunity(ctx context.Context, viewerUserID, communityID int64) (domain.CommunityView, error) {
|
|
return communityView(ctx, s.db, viewerUserID, communityID)
|
|
}
|
|
|
|
func (s *CommunityStore) GetCommunities(ctx context.Context, viewerUserID int64, ids []int64) ([]domain.CommunityView, error) {
|
|
seen := make(map[int64]struct{}, len(ids))
|
|
out := make([]domain.CommunityView, 0, len(ids))
|
|
for _, id := range ids {
|
|
if id == 0 {
|
|
continue
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
view, err := communityView(ctx, s.db, viewerUserID, id)
|
|
if errors.Is(err, domain.ErrCommunityInvalid) || errors.Is(err, domain.ErrCommunityPrivate) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, view)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *CommunityStore) ListJoinedCommunities(ctx context.Context, viewerUserID int64) ([]domain.CommunityView, error) {
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT DISTINCT c.id
|
|
FROM communities c
|
|
LEFT JOIN community_members explicit ON explicit.community_id=c.id AND explicit.user_id=$1
|
|
WHERE NOT c.deleted AND (
|
|
(explicit.status='active')
|
|
OR (explicit.user_id IS NULL AND EXISTS (
|
|
SELECT 1 FROM community_peer_links l
|
|
JOIN channel_members cm ON l.peer_type='channel' AND cm.channel_id=l.peer_id
|
|
WHERE l.community_id=c.id AND cm.user_id=$1 AND cm.status='active'))
|
|
OR (explicit.user_id IS NULL AND EXISTS (
|
|
SELECT 1 FROM community_peer_links l
|
|
JOIN dialogs d ON l.peer_type='user' AND d.peer_id=l.peer_id AND d.peer_type='user'
|
|
WHERE l.community_id=c.id AND d.user_id=$1 AND d.top_message_id > 0))
|
|
)
|
|
ORDER BY c.id`, viewerUserID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list joined communities: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
ids := make([]int64, 0)
|
|
for rows.Next() {
|
|
var id int64
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.GetCommunities(ctx, viewerUserID, ids)
|
|
}
|
|
|
|
func validateCommunityPeerForLink(ctx context.Context, db sqlcgen.DBTX, actorUserID int64, peer domain.Peer, lock bool) error {
|
|
lockSQL := ""
|
|
if lock {
|
|
lockSQL = " FOR UPDATE"
|
|
}
|
|
switch peer.Type {
|
|
case domain.PeerTypeChannel:
|
|
var linked int64
|
|
var deleted, monoforum bool
|
|
if err := db.QueryRow(ctx, `SELECT linked_community_id, deleted, monoforum FROM channels WHERE id=$1`+lockSQL, peer.ID).
|
|
Scan(&linked, &deleted, &monoforum); err != nil {
|
|
return domain.ErrCommunityPeerInvalid
|
|
}
|
|
if deleted || monoforum {
|
|
return domain.ErrCommunityPeerInvalid
|
|
}
|
|
if linked != 0 {
|
|
return domain.ErrCommunityPeerLinked
|
|
}
|
|
var allowed bool
|
|
if err := db.QueryRow(ctx, `SELECT EXISTS (
|
|
SELECT 1 FROM channel_members WHERE channel_id=$1 AND user_id=$2 AND status='active' AND role IN ('creator','admin'))`, peer.ID, actorUserID).Scan(&allowed); err != nil || !allowed {
|
|
return domain.ErrCommunityAdminRequired
|
|
}
|
|
case domain.PeerTypeUser:
|
|
var bot, deleted bool
|
|
var linked int64
|
|
if err := db.QueryRow(ctx, `SELECT is_bot, deleted_at IS NOT NULL, linked_community_id FROM users WHERE id=$1`+lockSQL, peer.ID).
|
|
Scan(&bot, &deleted, &linked); err != nil || !bot || deleted {
|
|
return domain.ErrCommunityPeerInvalid
|
|
}
|
|
if linked != 0 {
|
|
return domain.ErrCommunityPeerLinked
|
|
}
|
|
var owned bool
|
|
if err := db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM bots WHERE bot_user_id=$1 AND owner_user_id=$2)`, peer.ID, actorUserID).Scan(&owned); err != nil || !owned {
|
|
return domain.ErrCommunityAdminRequired
|
|
}
|
|
default:
|
|
return domain.ErrCommunityPeerInvalid
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func setPeerLinkedCommunity(ctx context.Context, db sqlcgen.DBTX, peer domain.Peer, communityID int64) error {
|
|
var tag string
|
|
switch peer.Type {
|
|
case domain.PeerTypeChannel:
|
|
tag = "UPDATE channels SET linked_community_id=$2, updated_at=now() WHERE id=$1"
|
|
case domain.PeerTypeUser:
|
|
tag = "UPDATE users SET linked_community_id=$2, updated_at=now() WHERE id=$1"
|
|
default:
|
|
return domain.ErrCommunityPeerInvalid
|
|
}
|
|
cmd, err := db.Exec(ctx, tag, peer.ID, communityID)
|
|
if err != nil {
|
|
return fmt.Errorf("set linked community: %w", err)
|
|
}
|
|
if cmd.RowsAffected() != 1 {
|
|
return domain.ErrCommunityPeerInvalid
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func insertCommunityLink(ctx context.Context, db sqlcgen.DBTX, communityID, actorUserID int64, peer domain.Peer, visibility domain.CommunityPeerVisibility, date int) (domain.CommunityPeerLink, error) {
|
|
if err := validateCommunityPeerForLink(ctx, db, actorUserID, peer, true); err != nil {
|
|
return domain.CommunityPeerLink{}, err
|
|
}
|
|
var peers, bots int
|
|
if err := db.QueryRow(ctx, `SELECT
|
|
COUNT(*) FILTER (WHERE peer_type='channel')::int,
|
|
COUNT(*) FILTER (WHERE peer_type='user')::int
|
|
FROM community_peer_links WHERE community_id=$1`, communityID).Scan(&peers, &bots); err != nil {
|
|
return domain.CommunityPeerLink{}, err
|
|
}
|
|
if (peer.Type == domain.PeerTypeChannel && peers >= domain.MaxCommunityPeers) ||
|
|
(peer.Type == domain.PeerTypeUser && bots >= domain.MaxCommunityBotPeers) {
|
|
return domain.CommunityPeerLink{}, domain.ErrCommunityPeersTooMuch
|
|
}
|
|
if _, err := db.Exec(ctx, `
|
|
INSERT INTO community_peer_links(community_id,peer_type,peer_id,visibility,created_by,date)
|
|
VALUES($1,$2,$3,$4,$5,$6)`, communityID, string(peer.Type), peer.ID, string(visibility), actorUserID, date); err != nil {
|
|
if isUniqueViolation(err) {
|
|
return domain.CommunityPeerLink{}, domain.ErrCommunityPeerLinked
|
|
}
|
|
return domain.CommunityPeerLink{}, fmt.Errorf("insert community link: %w", err)
|
|
}
|
|
if err := setPeerLinkedCommunity(ctx, db, peer, communityID); err != nil {
|
|
return domain.CommunityPeerLink{}, err
|
|
}
|
|
return domain.CommunityPeerLink{CommunityID: communityID, Peer: peer, Visibility: visibility, CanViewHistory: true, CreatedBy: actorUserID, Date: date}, nil
|
|
}
|
|
|
|
func (s *CommunityStore) CreateCommunity(ctx context.Context, req domain.CreateCommunityRequest) (domain.CommunityView, error) {
|
|
return withCommunityTx(ctx, s, func(tx pgx.Tx) (domain.CommunityView, error) {
|
|
if err := validateCommunityPeerForLink(ctx, tx, req.CreatorUserID, req.InitialPeer, true); err != nil {
|
|
return domain.CommunityView{}, err
|
|
}
|
|
id, err := s.ids.NextChannelID(ctx)
|
|
if err != nil {
|
|
return domain.CommunityView{}, fmt.Errorf("allocate community id: %w", err)
|
|
}
|
|
hash, err := randomChannelAccessHash()
|
|
if err != nil {
|
|
return domain.CommunityView{}, fmt.Errorf("community access hash: %w", err)
|
|
}
|
|
c := domain.Community{ID: id, AccessHash: hash, CreatorUserID: req.CreatorUserID, Title: req.Title, About: req.About, Date: req.Date}
|
|
rights, _ := json.Marshal(c.DefaultBannedRights)
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO communities(id,access_hash,creator_user_id,title,about,default_banned_rights,date)
|
|
VALUES($1,$2,$3,$4,$5,$6,$7)`, c.ID, c.AccessHash, c.CreatorUserID, c.Title, c.About, rights, c.Date); err != nil {
|
|
return domain.CommunityView{}, fmt.Errorf("insert community: %w", err)
|
|
}
|
|
adminRights, _ := json.Marshal(domain.CreatorChannelAdminRights())
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO community_members(community_id,user_id,role,status,admin_rights,date)
|
|
VALUES($1,$2,'creator','active',$3,$4)`, c.ID, c.CreatorUserID, adminRights, c.Date); err != nil {
|
|
return domain.CommunityView{}, fmt.Errorf("insert community creator: %w", err)
|
|
}
|
|
link, err := insertCommunityLink(ctx, tx, c.ID, c.CreatorUserID, req.InitialPeer, req.Visibility, req.Date)
|
|
if err != nil {
|
|
return domain.CommunityView{}, err
|
|
}
|
|
serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, req.InitialPeer, req.CreatorUserID, req.Date, c.ID)
|
|
if err != nil {
|
|
return domain.CommunityView{}, err
|
|
}
|
|
self := domain.CommunityMember{CommunityID: c.ID, UserID: c.CreatorUserID, Role: domain.CommunityRoleCreator, Status: domain.CommunityMemberActive, AdminRights: domain.CreatorChannelAdminRights(), Date: c.Date}
|
|
view := domain.CommunityView{Community: c, Self: self, Links: []domain.CommunityPeerLink{link}, AdminsCount: 1}
|
|
if serviceMessage != nil {
|
|
view.ServiceMessages = append(view.ServiceMessages, *serviceMessage)
|
|
}
|
|
return view, nil
|
|
})
|
|
}
|
|
|
|
func lockCommunityActor(ctx context.Context, tx pgx.Tx, actorUserID, communityID int64) (domain.Community, domain.CommunityMember, error) {
|
|
c, err := communityByID(ctx, tx, communityID, true)
|
|
if err != nil {
|
|
return domain.Community{}, domain.CommunityMember{}, err
|
|
}
|
|
m, ok, err := derivedCommunityMember(ctx, tx, c, actorUserID)
|
|
if err != nil {
|
|
return domain.Community{}, domain.CommunityMember{}, err
|
|
}
|
|
if !ok || !m.Active() {
|
|
return domain.Community{}, domain.CommunityMember{}, domain.ErrCommunityPrivate
|
|
}
|
|
return c, m, nil
|
|
}
|
|
|
|
func (s *CommunityStore) ToggleCommunityPeerLink(ctx context.Context, req domain.CommunityTogglePeerLinkRequest) (domain.CommunityTogglePeerLinkResult, error) {
|
|
return withCommunityTx(ctx, s, func(tx pgx.Tx) (domain.CommunityTogglePeerLinkResult, error) {
|
|
c, actor, err := lockCommunityActor(ctx, tx, req.ActorUserID, req.CommunityID)
|
|
if err != nil {
|
|
return domain.CommunityTogglePeerLinkResult{}, err
|
|
}
|
|
if req.Deleted {
|
|
if !actor.CanManageLinkedPeers() {
|
|
return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityAdminRequired
|
|
}
|
|
cmd, err := tx.Exec(ctx, `DELETE FROM community_peer_links WHERE community_id=$1 AND peer_type=$2 AND peer_id=$3`, c.ID, string(req.Peer.Type), req.Peer.ID)
|
|
if err != nil {
|
|
return domain.CommunityTogglePeerLinkResult{}, err
|
|
}
|
|
if cmd.RowsAffected() == 0 {
|
|
return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityPeerInvalid
|
|
}
|
|
if err := setPeerLinkedCommunity(ctx, tx, req.Peer, 0); err != nil {
|
|
return domain.CommunityTogglePeerLinkResult{}, err
|
|
}
|
|
serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, req.Peer, req.ActorUserID, req.Date, 0)
|
|
if err != nil {
|
|
return domain.CommunityTogglePeerLinkResult{}, err
|
|
}
|
|
return domain.CommunityTogglePeerLinkResult{Community: c, Peer: req.Peer, ServiceMessage: serviceMessage, Removed: true}, nil
|
|
}
|
|
if actor.CanManageLinkedPeers() {
|
|
link, err := insertCommunityLink(ctx, tx, c.ID, req.ActorUserID, req.Peer, req.Visibility, req.Date)
|
|
if err != nil {
|
|
return domain.CommunityTogglePeerLinkResult{}, err
|
|
}
|
|
serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, req.Peer, req.ActorUserID, req.Date, c.ID)
|
|
if err != nil {
|
|
return domain.CommunityTogglePeerLinkResult{}, err
|
|
}
|
|
return domain.CommunityTogglePeerLinkResult{Community: c, Peer: req.Peer, Link: &link, ServiceMessage: serviceMessage}, nil
|
|
}
|
|
if c.DefaultBannedRights.ManageLinkedPeers {
|
|
return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityAdminRequired
|
|
}
|
|
if err := validateCommunityPeerForLink(ctx, tx, req.ActorUserID, req.Peer, true); err != nil {
|
|
return domain.CommunityTogglePeerLinkResult{}, err
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO community_peer_link_requests(community_id,peer_type,peer_id,requested_by,visibility,date)
|
|
VALUES($1,$2,$3,$4,$5,$6)
|
|
ON CONFLICT(community_id,peer_type,peer_id) DO UPDATE SET requested_by=EXCLUDED.requested_by, visibility=EXCLUDED.visibility, date=EXCLUDED.date, created_at=now()`,
|
|
c.ID, string(req.Peer.Type), req.Peer.ID, req.ActorUserID, string(req.Visibility), req.Date); err != nil {
|
|
return domain.CommunityTogglePeerLinkResult{}, fmt.Errorf("save community link request: %w", err)
|
|
}
|
|
return domain.CommunityTogglePeerLinkResult{Community: c, Peer: req.Peer, RequestCreated: true}, nil
|
|
})
|
|
}
|
|
|
|
func (s *CommunityStore) SetCommunityCollapsed(ctx context.Context, userID, communityID int64, collapsed bool) (domain.CommunityView, bool, error) {
|
|
changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) {
|
|
if _, _, err := lockCommunityActor(ctx, tx, userID, communityID); err != nil {
|
|
return false, err
|
|
}
|
|
var old bool
|
|
err := tx.QueryRow(ctx, `SELECT collapsed FROM community_user_states WHERE community_id=$1 AND user_id=$2 FOR UPDATE`, communityID, userID).Scan(&old)
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return false, err
|
|
}
|
|
if err == nil && old == collapsed {
|
|
return false, nil
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO community_user_states(community_id,user_id,collapsed,pinned,pinned_order)
|
|
VALUES($1,$2,$3,false,0)
|
|
ON CONFLICT(community_id,user_id) DO UPDATE SET collapsed=EXCLUDED.collapsed,
|
|
pinned=CASE WHEN EXCLUDED.collapsed THEN community_user_states.pinned ELSE false END,
|
|
pinned_order=CASE WHEN EXCLUDED.collapsed THEN community_user_states.pinned_order ELSE 0 END,
|
|
updated_at=now()`, communityID, userID, collapsed)
|
|
return true, err
|
|
})
|
|
if err != nil {
|
|
return domain.CommunityView{}, false, err
|
|
}
|
|
view, err := s.GetCommunity(ctx, userID, communityID)
|
|
return view, changed, err
|
|
}
|
|
|
|
type communityRequestCursor struct {
|
|
Date int `json:"d"`
|
|
Type string `json:"t"`
|
|
ID int64 `json:"i"`
|
|
}
|
|
|
|
func decodeCommunityRequestCursor(raw string) (communityRequestCursor, error) {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return communityRequestCursor{}, nil
|
|
}
|
|
b, err := base64.RawURLEncoding.DecodeString(raw)
|
|
if err != nil {
|
|
return communityRequestCursor{}, domain.ErrCommunityInvalid
|
|
}
|
|
var c communityRequestCursor
|
|
if json.Unmarshal(b, &c) != nil || c.Date <= 0 || c.ID <= 0 {
|
|
return communityRequestCursor{}, domain.ErrCommunityInvalid
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func encodeCommunityRequestCursor(c communityRequestCursor) string {
|
|
b, _ := json.Marshal(c)
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
|
|
func (s *CommunityStore) ListCommunityPeerLinkRequests(ctx context.Context, viewerUserID, communityID int64, offset string, limit int) (domain.CommunityPeerLinkRequestPage, error) {
|
|
view, err := s.GetCommunity(ctx, viewerUserID, communityID)
|
|
if err != nil {
|
|
return domain.CommunityPeerLinkRequestPage{}, err
|
|
}
|
|
if !view.Self.CanManageLinkedPeers() {
|
|
return domain.CommunityPeerLinkRequestPage{}, domain.ErrCommunityAdminRequired
|
|
}
|
|
cursor, err := decodeCommunityRequestCursor(offset)
|
|
if err != nil {
|
|
return domain.CommunityPeerLinkRequestPage{}, err
|
|
}
|
|
if limit <= 0 || limit > domain.MaxCommunityLinkRequests {
|
|
limit = domain.MaxCommunityLinkRequests
|
|
}
|
|
var total int
|
|
if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM community_peer_link_requests WHERE community_id=$1`, communityID).Scan(&total); err != nil {
|
|
return domain.CommunityPeerLinkRequestPage{}, err
|
|
}
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT peer_type,peer_id,requested_by,visibility,date
|
|
FROM community_peer_link_requests
|
|
WHERE community_id=$1 AND ($2::int=0 OR (date,peer_type,peer_id) < ($2,$3,$4))
|
|
ORDER BY date DESC,peer_type DESC,peer_id DESC LIMIT $5`, communityID, cursor.Date, cursor.Type, cursor.ID, limit+1)
|
|
if err != nil {
|
|
return domain.CommunityPeerLinkRequestPage{}, err
|
|
}
|
|
defer rows.Close()
|
|
page := domain.CommunityPeerLinkRequestPage{TotalCount: total}
|
|
for rows.Next() {
|
|
var typ, visibility string
|
|
var peerID, requestedBy int64
|
|
var date int
|
|
if err := rows.Scan(&typ, &peerID, &requestedBy, &visibility, &date); err != nil {
|
|
return domain.CommunityPeerLinkRequestPage{}, err
|
|
}
|
|
page.Requests = append(page.Requests, domain.CommunityPeerLinkRequest{CommunityID: communityID, Peer: domain.Peer{Type: domain.PeerType(typ), ID: peerID}, RequestedBy: requestedBy, Visibility: domain.CommunityPeerVisibility(visibility), Date: date})
|
|
}
|
|
if len(page.Requests) > limit {
|
|
last := page.Requests[limit-1]
|
|
page.NextOffset = encodeCommunityRequestCursor(communityRequestCursor{Date: last.Date, Type: string(last.Peer.Type), ID: last.Peer.ID})
|
|
page.Requests = page.Requests[:limit]
|
|
}
|
|
channelIDs, userIDs := make([]int64, 0), make([]int64, 0)
|
|
for _, req := range page.Requests {
|
|
userIDs = append(userIDs, req.RequestedBy)
|
|
if req.Peer.Type == domain.PeerTypeChannel {
|
|
channelIDs = append(channelIDs, req.Peer.ID)
|
|
} else {
|
|
userIDs = append(userIDs, req.Peer.ID)
|
|
}
|
|
}
|
|
page.Channels, err = listChannelsByIDs(ctx, s.db, channelIDs)
|
|
if err != nil {
|
|
return domain.CommunityPeerLinkRequestPage{}, err
|
|
}
|
|
page.Users, err = listUsersByIDs(ctx, s.db, uniqueInt64s(userIDs))
|
|
return page, err
|
|
}
|
|
|
|
func requestForUpdate(ctx context.Context, tx pgx.Tx, communityID int64, peer domain.Peer) (domain.CommunityPeerLinkRequest, error) {
|
|
var typ, visibility string
|
|
var out domain.CommunityPeerLinkRequest
|
|
err := tx.QueryRow(ctx, `
|
|
SELECT peer_type,peer_id,requested_by,visibility,date FROM community_peer_link_requests
|
|
WHERE community_id=$1 AND peer_type=$2 AND peer_id=$3 FOR UPDATE`, communityID, string(peer.Type), peer.ID).
|
|
Scan(&typ, &out.Peer.ID, &out.RequestedBy, &visibility, &out.Date)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.CommunityPeerLinkRequest{}, domain.ErrCommunityRequestMissing
|
|
}
|
|
return domain.CommunityPeerLinkRequest{}, err
|
|
}
|
|
out.CommunityID, out.Peer.Type, out.Visibility = communityID, domain.PeerType(typ), domain.CommunityPeerVisibility(visibility)
|
|
return out, nil
|
|
}
|
|
|
|
func (s *CommunityStore) DecideCommunityPeerLinkRequest(ctx context.Context, actorUserID, communityID int64, peer domain.Peer, reject bool, date int) (domain.CommunityTogglePeerLinkResult, error) {
|
|
return withCommunityTx(ctx, s, func(tx pgx.Tx) (domain.CommunityTogglePeerLinkResult, error) {
|
|
c, actor, err := lockCommunityActor(ctx, tx, actorUserID, communityID)
|
|
if err != nil {
|
|
return domain.CommunityTogglePeerLinkResult{}, err
|
|
}
|
|
if !actor.CanManageLinkedPeers() {
|
|
return domain.CommunityTogglePeerLinkResult{}, domain.ErrCommunityAdminRequired
|
|
}
|
|
req, err := requestForUpdate(ctx, tx, communityID, peer)
|
|
if err != nil {
|
|
return domain.CommunityTogglePeerLinkResult{}, err
|
|
}
|
|
if _, err := tx.Exec(ctx, `DELETE FROM community_peer_link_requests WHERE community_id=$1 AND peer_type=$2 AND peer_id=$3`, communityID, string(peer.Type), peer.ID); err != nil {
|
|
return domain.CommunityTogglePeerLinkResult{}, err
|
|
}
|
|
if reject {
|
|
return domain.CommunityTogglePeerLinkResult{Community: c, Peer: peer, RequestedBy: req.RequestedBy}, nil
|
|
}
|
|
link, err := insertCommunityLink(ctx, tx, communityID, req.RequestedBy, peer, req.Visibility, date)
|
|
if err != nil {
|
|
return domain.CommunityTogglePeerLinkResult{}, err
|
|
}
|
|
serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, peer, actorUserID, date, c.ID)
|
|
if err != nil {
|
|
return domain.CommunityTogglePeerLinkResult{}, err
|
|
}
|
|
return domain.CommunityTogglePeerLinkResult{Community: c, Peer: peer, RequestedBy: req.RequestedBy, Link: &link, ServiceMessage: serviceMessage}, nil
|
|
})
|
|
}
|
|
|
|
func (s *CommunityStore) DecideAllCommunityPeerLinkRequests(ctx context.Context, actorUserID, communityID int64, reject bool, date int) ([]domain.CommunityTogglePeerLinkResult, error) {
|
|
return withCommunityTx(ctx, s, func(tx pgx.Tx) ([]domain.CommunityTogglePeerLinkResult, error) {
|
|
c, actor, err := lockCommunityActor(ctx, tx, actorUserID, communityID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !actor.CanManageLinkedPeers() {
|
|
return nil, domain.ErrCommunityAdminRequired
|
|
}
|
|
rows, err := tx.Query(ctx, `SELECT peer_type,peer_id,requested_by,visibility,date FROM community_peer_link_requests WHERE community_id=$1 ORDER BY date,peer_type,peer_id FOR UPDATE`, communityID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
requests := make([]domain.CommunityPeerLinkRequest, 0)
|
|
for rows.Next() {
|
|
var typ, visibility string
|
|
var r domain.CommunityPeerLinkRequest
|
|
if err := rows.Scan(&typ, &r.Peer.ID, &r.RequestedBy, &visibility, &r.Date); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
r.CommunityID, r.Peer.Type, r.Visibility = communityID, domain.PeerType(typ), domain.CommunityPeerVisibility(visibility)
|
|
requests = append(requests, r)
|
|
}
|
|
rows.Close()
|
|
if reject {
|
|
_, err := tx.Exec(ctx, `DELETE FROM community_peer_link_requests WHERE community_id=$1`, communityID)
|
|
return make([]domain.CommunityTogglePeerLinkResult, len(requests)), err
|
|
}
|
|
var currentChannels, currentBots int
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FILTER(WHERE peer_type='channel')::int, COUNT(*) FILTER(WHERE peer_type='user')::int FROM community_peer_links WHERE community_id=$1`, communityID).Scan(¤tChannels, ¤tBots); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, r := range requests {
|
|
if r.Peer.Type == domain.PeerTypeChannel {
|
|
currentChannels++
|
|
} else {
|
|
currentBots++
|
|
}
|
|
if currentChannels > domain.MaxCommunityPeers || currentBots > domain.MaxCommunityBotPeers {
|
|
return nil, domain.ErrCommunityPeersTooMuch
|
|
}
|
|
if err := validateCommunityPeerForLink(ctx, tx, r.RequestedBy, r.Peer, true); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
out := make([]domain.CommunityTogglePeerLinkResult, 0, len(requests))
|
|
for _, r := range requests {
|
|
link, err := insertCommunityLink(ctx, tx, communityID, r.RequestedBy, r.Peer, r.Visibility, date)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, r.Peer, actorUserID, date, c.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, domain.CommunityTogglePeerLinkResult{Community: c, Peer: r.Peer, RequestedBy: r.RequestedBy, Link: &link, ServiceMessage: serviceMessage})
|
|
}
|
|
_, err = tx.Exec(ctx, `DELETE FROM community_peer_link_requests WHERE community_id=$1`, communityID)
|
|
return out, err
|
|
})
|
|
}
|
|
|
|
func (s *CommunityStore) GetCommunityParticipantJoinedChats(ctx context.Context, viewerUserID, communityID, participantUserID int64) (domain.CommunityParticipantJoinedChats, error) {
|
|
view, err := s.GetCommunity(ctx, viewerUserID, communityID)
|
|
if err != nil {
|
|
return domain.CommunityParticipantJoinedChats{}, err
|
|
}
|
|
if !view.Self.CanBanUsers() && viewerUserID != participantUserID {
|
|
return domain.CommunityParticipantJoinedChats{}, domain.ErrCommunityAdminRequired
|
|
}
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT l.peer_id, cm.role
|
|
FROM community_peer_links l
|
|
JOIN channel_members cm ON cm.channel_id=l.peer_id AND cm.user_id=$2 AND cm.status='active'
|
|
WHERE l.community_id=$1 AND l.peer_type='channel'
|
|
ORDER BY l.peer_id`, communityID, participantUserID)
|
|
if err != nil {
|
|
return domain.CommunityParticipantJoinedChats{}, err
|
|
}
|
|
defer rows.Close()
|
|
out := domain.CommunityParticipantJoinedChats{}
|
|
ids := make([]int64, 0)
|
|
for rows.Next() {
|
|
var id int64
|
|
var role string
|
|
if err := rows.Scan(&id, &role); err != nil {
|
|
return out, err
|
|
}
|
|
ids = append(ids, id)
|
|
out.JoinedChatIDs = append(out.JoinedChatIDs, id)
|
|
if role == string(domain.ChannelRoleCreator) {
|
|
out.CreatorChatIDs = append(out.CreatorChatIDs, id)
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return out, err
|
|
}
|
|
out.Channels, err = listChannelsByIDs(ctx, s.db, ids)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
out.Users, err = listUsersByIDs(ctx, s.db, []int64{participantUserID})
|
|
return out, err
|
|
}
|
|
|
|
func (s *CommunityStore) banCommunityParticipantFromChannelTx(ctx context.Context, tx pgx.Tx, actorUserID, channelID, participantUserID int64, date int) (domain.EditChannelBannedResult, bool, error) {
|
|
channelStore := NewChannelStore(tx, WithChannelAllocators(s.ids, s.msgIDs))
|
|
channel, err := getChannelByID(ctx, tx, channelID)
|
|
if err != nil {
|
|
return domain.EditChannelBannedResult{}, false, err
|
|
}
|
|
previous, err := channelStore.getChannelMember(ctx, tx, channelID, participantUserID)
|
|
if errors.Is(err, domain.ErrChannelPrivate) {
|
|
return domain.EditChannelBannedResult{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return domain.EditChannelBannedResult{}, false, err
|
|
}
|
|
if previous.Status != domain.ChannelMemberActive {
|
|
return domain.EditChannelBannedResult{}, false, nil
|
|
}
|
|
member := previous
|
|
member.InviterUserID = actorUserID
|
|
member.Role = domain.ChannelRoleMember
|
|
member.Status = domain.ChannelMemberKicked
|
|
member.LeftAt = date
|
|
member.BannedRights = domain.ChannelBannedRights{ViewMessages: true, UntilDate: 0}
|
|
if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil {
|
|
return domain.EditChannelBannedResult{}, false, err
|
|
}
|
|
if err := channelStore.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
|
|
ChannelID: channelID, UserID: actorUserID, Date: date, Type: domain.ChannelAdminLogParticipantKick,
|
|
PrevParticipant: &previous, NewParticipant: &member,
|
|
}); err != nil {
|
|
return domain.EditChannelBannedResult{}, false, err
|
|
}
|
|
channel, err = refreshChannelCountsTx(ctx, tx, channel)
|
|
if err != nil {
|
|
return domain.EditChannelBannedResult{}, false, err
|
|
}
|
|
event := transientChannelParticipantEvent(channel.ID, actorUserID, previous, member, date)
|
|
if err := clearChannelMentionsForUserTx(ctx, tx, channelID, participantUserID); err != nil {
|
|
return domain.EditChannelBannedResult{}, false, err
|
|
}
|
|
var serviceMessage domain.ChannelMessage
|
|
var serviceEvent domain.ChannelUpdateEvent
|
|
if channel.Megagroup {
|
|
serviceMessage, serviceEvent, err = channelStore.insertServiceMessage(ctx, tx, channel, actorUserID, date, domain.ChannelMessageAction{
|
|
Type: domain.ChannelActionChatDelete, UserIDs: []int64{participantUserID},
|
|
})
|
|
if err != nil {
|
|
return domain.EditChannelBannedResult{}, false, err
|
|
}
|
|
channel.TopMessageID, channel.Pts = serviceMessage.ID, serviceEvent.Pts
|
|
}
|
|
recipients, err := channelStore.listActiveChannelMemberIDs(ctx, tx, channelID, 0)
|
|
if err != nil {
|
|
return domain.EditChannelBannedResult{}, false, err
|
|
}
|
|
recipients = append(recipients, participantUserID)
|
|
return domain.EditChannelBannedResult{
|
|
Channel: channel, Previous: previous, Participant: member, Event: event,
|
|
Recipients: recipients, Date: date, Message: serviceMessage, ServiceEvent: serviceEvent,
|
|
}, true, nil
|
|
}
|
|
|
|
func (s *CommunityStore) ToggleCommunityParticipantBanned(ctx context.Context, actorUserID, communityID, participantUserID int64, unban bool, date int) (domain.CommunityParticipantBanResult, error) {
|
|
return withCommunityTx(ctx, s, func(tx pgx.Tx) (domain.CommunityParticipantBanResult, error) {
|
|
c, actor, err := lockCommunityActor(ctx, tx, actorUserID, communityID)
|
|
if err != nil {
|
|
return domain.CommunityParticipantBanResult{}, err
|
|
}
|
|
if !actor.CanBanUsers() || participantUserID == c.CreatorUserID {
|
|
return domain.CommunityParticipantBanResult{}, domain.ErrCommunityAdminRequired
|
|
}
|
|
if unban {
|
|
cmd, err := tx.Exec(ctx, `DELETE FROM community_members WHERE community_id=$1 AND user_id=$2 AND role='member' AND status='kicked'`, communityID, participantUserID)
|
|
return domain.CommunityParticipantBanResult{Changed: cmd.RowsAffected() > 0}, err
|
|
}
|
|
participant, found, err := derivedCommunityMember(ctx, tx, c, participantUserID)
|
|
if err != nil {
|
|
return domain.CommunityParticipantBanResult{}, err
|
|
}
|
|
if !found || (participant.Status != domain.CommunityMemberActive && participant.Status != domain.CommunityMemberKicked) {
|
|
return domain.CommunityParticipantBanResult{}, domain.ErrCommunityParticipantInvalid
|
|
}
|
|
var alreadyKicked bool
|
|
if err := tx.QueryRow(ctx, `SELECT EXISTS(
|
|
SELECT 1 FROM community_members WHERE community_id=$1 AND user_id=$2 AND role='member' AND status='kicked')`, communityID, participantUserID).Scan(&alreadyKicked); err != nil {
|
|
return domain.CommunityParticipantBanResult{}, err
|
|
}
|
|
// Chats owned by the banned participant (and their bots) leave the
|
|
// Community; the participant is kicked from every remaining linked chat.
|
|
rows, err := tx.Query(ctx, `
|
|
SELECT l.peer_type,l.peer_id
|
|
FROM community_peer_links l
|
|
LEFT JOIN channels c ON l.peer_type='channel' AND c.id=l.peer_id
|
|
LEFT JOIN bots b ON l.peer_type='user' AND b.bot_user_id=l.peer_id
|
|
WHERE l.community_id=$1 AND (c.creator_user_id=$2 OR b.owner_user_id=$2)
|
|
FOR UPDATE OF l`, communityID, participantUserID)
|
|
if err != nil {
|
|
return domain.CommunityParticipantBanResult{}, err
|
|
}
|
|
owned := make([]domain.Peer, 0)
|
|
for rows.Next() {
|
|
var typ string
|
|
var id int64
|
|
if err := rows.Scan(&typ, &id); err != nil {
|
|
rows.Close()
|
|
return domain.CommunityParticipantBanResult{}, err
|
|
}
|
|
owned = append(owned, domain.Peer{Type: domain.PeerType(typ), ID: id})
|
|
}
|
|
rows.Close()
|
|
result := domain.CommunityParticipantBanResult{}
|
|
for _, p := range owned {
|
|
if _, err := tx.Exec(ctx, `DELETE FROM community_peer_links WHERE community_id=$1 AND peer_type=$2 AND peer_id=$3`, communityID, string(p.Type), p.ID); err != nil {
|
|
return domain.CommunityParticipantBanResult{}, err
|
|
}
|
|
if err := setPeerLinkedCommunity(ctx, tx, p, 0); err != nil {
|
|
return domain.CommunityParticipantBanResult{}, err
|
|
}
|
|
serviceMessage, err := s.appendCommunityServiceMessageTx(ctx, tx, p, actorUserID, date, 0)
|
|
if err != nil {
|
|
return domain.CommunityParticipantBanResult{}, err
|
|
}
|
|
result.RemovedLinks = append(result.RemovedLinks, domain.CommunityTogglePeerLinkResult{Community: c, Peer: p, Removed: true, ServiceMessage: serviceMessage})
|
|
}
|
|
channelRows, err := tx.Query(ctx, `SELECT peer_id FROM community_peer_links WHERE community_id=$1 AND peer_type='channel' ORDER BY peer_id FOR UPDATE`, communityID)
|
|
if err != nil {
|
|
return domain.CommunityParticipantBanResult{}, err
|
|
}
|
|
channelIDs := make([]int64, 0)
|
|
for channelRows.Next() {
|
|
var channelID int64
|
|
if err := channelRows.Scan(&channelID); err != nil {
|
|
channelRows.Close()
|
|
return domain.CommunityParticipantBanResult{}, err
|
|
}
|
|
channelIDs = append(channelIDs, channelID)
|
|
}
|
|
channelRows.Close()
|
|
for _, channelID := range channelIDs {
|
|
ban, changed, err := s.banCommunityParticipantFromChannelTx(ctx, tx, actorUserID, channelID, participantUserID, date)
|
|
if err != nil {
|
|
return domain.CommunityParticipantBanResult{}, err
|
|
}
|
|
if changed {
|
|
result.ChannelBans = append(result.ChannelBans, ban)
|
|
}
|
|
}
|
|
if !alreadyKicked {
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO community_members(community_id,user_id,role,status,admin_rights,date)
|
|
VALUES($1,$2,'member','kicked','{}',$3)
|
|
ON CONFLICT(community_id,user_id) DO UPDATE SET role='member',status='kicked',admin_rights='{}',rank='',date=EXCLUDED.date,updated_at=now()`, communityID, participantUserID, date); err != nil {
|
|
return domain.CommunityParticipantBanResult{}, err
|
|
}
|
|
}
|
|
result.Changed = !alreadyKicked || len(result.ChannelBans) > 0 || len(result.RemovedLinks) > 0
|
|
return result, nil
|
|
})
|
|
}
|
|
|
|
func communityParticipantHash(items []domain.CommunityMember) int64 {
|
|
h := fnv.New64a()
|
|
for _, m := range items {
|
|
fmt.Fprintf(h, "%d:%s:%s;", m.UserID, m.Role, m.Status)
|
|
}
|
|
return int64(h.Sum64() & 0x7fffffffffffffff)
|
|
}
|
|
|
|
func (s *CommunityStore) ListCommunityParticipants(ctx context.Context, viewerUserID, communityID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.CommunityParticipantList, error) {
|
|
view, err := s.GetCommunity(ctx, viewerUserID, communityID)
|
|
if err != nil {
|
|
return domain.CommunityParticipantList{}, err
|
|
}
|
|
restricted := filter.Kind == domain.ChannelParticipantsKicked || filter.Kind == domain.ChannelParticipantsBanned
|
|
if restricted && !view.Self.CanManageLinkedPeers() {
|
|
return domain.CommunityParticipantList{}, domain.ErrCommunityAdminRequired
|
|
}
|
|
where := "am.status='active'"
|
|
switch filter.Kind {
|
|
case domain.ChannelParticipantsAdmins:
|
|
where = "am.status='active' AND am.role IN ('creator','admin')"
|
|
case domain.ChannelParticipantsKicked, domain.ChannelParticipantsBanned:
|
|
where = "am.status='kicked'"
|
|
}
|
|
query := `
|
|
WITH derived AS (
|
|
SELECT cm.user_id FROM community_peer_links l JOIN channel_members cm ON l.peer_type='channel' AND cm.channel_id=l.peer_id
|
|
WHERE l.community_id=$1 AND cm.status='active'
|
|
UNION
|
|
SELECT d.user_id FROM community_peer_links l JOIN dialogs d ON l.peer_type='user' AND d.peer_id=l.peer_id AND d.peer_type='user'
|
|
WHERE l.community_id=$1 AND d.top_message_id>0
|
|
), all_members AS (
|
|
SELECT community_id,user_id,role,status,admin_rights,rank,date FROM community_members WHERE community_id=$1
|
|
UNION ALL
|
|
SELECT $1,d.user_id,'member','active','{}'::jsonb,'',0 FROM derived d
|
|
WHERE NOT EXISTS(SELECT 1 FROM community_members m WHERE m.community_id=$1 AND m.user_id=d.user_id)
|
|
)
|
|
SELECT am.community_id,am.user_id,am.role,am.status,am.admin_rights::text,am.rank,am.date,COUNT(*) OVER()::int
|
|
FROM all_members am
|
|
JOIN users u ON u.id=am.user_id
|
|
WHERE ` + where + `
|
|
AND ($4='' OR strpos(lower(concat_ws(' ',am.user_id::text,u.first_name,u.last_name,u.username,u.phone)),lower($4))>0)
|
|
ORDER BY CASE am.role WHEN 'creator' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END,am.user_id OFFSET $2 LIMIT $3`
|
|
rows, err := s.db.Query(ctx, query, communityID, offset, limit, strings.TrimSpace(filter.Query))
|
|
if err != nil {
|
|
return domain.CommunityParticipantList{}, err
|
|
}
|
|
defer rows.Close()
|
|
out := domain.CommunityParticipantList{Community: view.Community}
|
|
ids := make([]int64, 0)
|
|
for rows.Next() {
|
|
var m domain.CommunityMember
|
|
var role, status, rights string
|
|
var count int
|
|
if err := rows.Scan(&m.CommunityID, &m.UserID, &role, &status, &rights, &m.Rank, &m.Date, &count); err != nil {
|
|
return out, err
|
|
}
|
|
m.Role, m.Status = domain.CommunityMemberRole(role), domain.CommunityMemberStatus(status)
|
|
_ = json.Unmarshal([]byte(rights), &m.AdminRights)
|
|
out.Count = count
|
|
out.Participants = append(out.Participants, m)
|
|
ids = append(ids, m.UserID)
|
|
}
|
|
out.Users, err = listUsersByIDs(ctx, s.db, ids)
|
|
out.Hash = communityParticipantHash(out.Participants)
|
|
return out, err
|
|
}
|
|
|
|
func (s *CommunityStore) EditCommunityTitle(ctx context.Context, actorUserID, communityID int64, title string) (domain.CommunityView, bool, error) {
|
|
changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) {
|
|
c, m, e := lockCommunityActor(ctx, tx, actorUserID, communityID)
|
|
if e != nil {
|
|
return false, e
|
|
}
|
|
if !m.CanChangeInfo() {
|
|
return false, domain.ErrCommunityAdminRequired
|
|
}
|
|
if c.Title == title {
|
|
return false, nil
|
|
}
|
|
_, e = tx.Exec(ctx, `UPDATE communities SET title=$2,updated_at=now() WHERE id=$1`, communityID, title)
|
|
return true, e
|
|
})
|
|
if err != nil {
|
|
return domain.CommunityView{}, false, err
|
|
}
|
|
v, err := s.GetCommunity(ctx, actorUserID, communityID)
|
|
return v, changed, err
|
|
}
|
|
|
|
func (s *CommunityStore) EditCommunityAbout(ctx context.Context, actorUserID, communityID int64, about string) (domain.CommunityView, bool, error) {
|
|
changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) {
|
|
c, m, e := lockCommunityActor(ctx, tx, actorUserID, communityID)
|
|
if e != nil {
|
|
return false, e
|
|
}
|
|
if !m.CanChangeInfo() {
|
|
return false, domain.ErrCommunityAdminRequired
|
|
}
|
|
if c.About == about {
|
|
return false, nil
|
|
}
|
|
_, e = tx.Exec(ctx, `UPDATE communities SET about=$2,updated_at=now() WHERE id=$1`, communityID, about)
|
|
return true, e
|
|
})
|
|
if err != nil {
|
|
return domain.CommunityView{}, false, err
|
|
}
|
|
v, err := s.GetCommunity(ctx, actorUserID, communityID)
|
|
return v, changed, err
|
|
}
|
|
|
|
func zeroCommunityAdminRights(r domain.ChannelAdminRights) bool {
|
|
return r == (domain.ChannelAdminRights{})
|
|
}
|
|
|
|
func (s *CommunityStore) EditCommunityAdmin(ctx context.Context, req domain.CommunityEditAdminRequest) (domain.CommunityView, bool, error) {
|
|
changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) {
|
|
c, actor, e := lockCommunityActor(ctx, tx, req.ActorUserID, req.CommunityID)
|
|
if e != nil {
|
|
return false, e
|
|
}
|
|
if req.UserID == req.ActorUserID && actor.Role == domain.CommunityRoleAdmin && zeroCommunityAdminRights(req.Rights) {
|
|
cmd, e := tx.Exec(ctx, `DELETE FROM community_members WHERE community_id=$1 AND user_id=$2 AND role='admin'`, req.CommunityID, req.UserID)
|
|
return cmd.RowsAffected() > 0, e
|
|
}
|
|
if !actor.CanAddAdmins() {
|
|
return false, domain.ErrCommunityAdminRequired
|
|
}
|
|
if req.UserID == c.CreatorUserID {
|
|
return false, domain.ErrCommunityCreatorRequired
|
|
}
|
|
if zeroCommunityAdminRights(req.Rights) {
|
|
cmd, e := tx.Exec(ctx, `DELETE FROM community_members WHERE community_id=$1 AND user_id=$2 AND role='admin'`, req.CommunityID, req.UserID)
|
|
return cmd.RowsAffected() > 0, e
|
|
}
|
|
var exists bool
|
|
if e := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE id=$1 AND deleted_at IS NULL)`, req.UserID).Scan(&exists); e != nil || !exists {
|
|
return false, domain.ErrCommunityParticipantInvalid
|
|
}
|
|
rights, _ := json.Marshal(req.Rights)
|
|
_, e = tx.Exec(ctx, `
|
|
INSERT INTO community_members(community_id,user_id,role,status,admin_rights,rank,date)
|
|
VALUES($1,$2,'admin','active',$3,$4,$5)
|
|
ON CONFLICT(community_id,user_id) DO UPDATE SET role='admin',status='active',admin_rights=EXCLUDED.admin_rights,rank=EXCLUDED.rank,date=EXCLUDED.date,updated_at=now()`, req.CommunityID, req.UserID, rights, req.Rank, req.Date)
|
|
return true, e
|
|
})
|
|
if err != nil {
|
|
return domain.CommunityView{}, false, err
|
|
}
|
|
v, err := s.GetCommunity(ctx, req.ActorUserID, req.CommunityID)
|
|
if changed && req.UserID == req.ActorUserID && errors.Is(err, domain.ErrCommunityPrivate) {
|
|
c, loadErr := communityByID(ctx, s.db, req.CommunityID, false)
|
|
if loadErr != nil {
|
|
return domain.CommunityView{}, false, loadErr
|
|
}
|
|
return domain.CommunityView{Community: c, Forbidden: true}, true, nil
|
|
}
|
|
return v, changed, err
|
|
}
|
|
|
|
func (s *CommunityStore) EditCommunityDefaultBannedRights(ctx context.Context, actorUserID, communityID int64, rights domain.ChannelBannedRights) (domain.CommunityView, bool, error) {
|
|
changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) {
|
|
c, m, e := lockCommunityActor(ctx, tx, actorUserID, communityID)
|
|
if e != nil {
|
|
return false, e
|
|
}
|
|
if !m.CanChangeInfo() {
|
|
return false, domain.ErrCommunityAdminRequired
|
|
}
|
|
if c.DefaultBannedRights == rights {
|
|
return false, nil
|
|
}
|
|
raw, _ := json.Marshal(rights)
|
|
_, e = tx.Exec(ctx, `UPDATE communities SET default_banned_rights=$2,updated_at=now() WHERE id=$1`, communityID, raw)
|
|
return true, e
|
|
})
|
|
if err != nil {
|
|
return domain.CommunityView{}, false, err
|
|
}
|
|
v, err := s.GetCommunity(ctx, actorUserID, communityID)
|
|
return v, changed, err
|
|
}
|
|
|
|
func (s *CommunityStore) SetCommunityPhoto(ctx context.Context, actorUserID, communityID int64, photo *domain.Photo, date int) (domain.CommunityView, bool, error) {
|
|
changed, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) {
|
|
c, m, e := lockCommunityActor(ctx, tx, actorUserID, communityID)
|
|
if e != nil {
|
|
return false, e
|
|
}
|
|
if !m.CanChangeInfo() {
|
|
return false, domain.ErrCommunityAdminRequired
|
|
}
|
|
id, dc := int64(0), 0
|
|
var stripped []byte
|
|
if photo != nil {
|
|
id, dc = photo.ID, photo.DCID
|
|
stripped = domain.StrippedFromSizes(photo.Sizes)
|
|
}
|
|
if c.PhotoID == id && c.PhotoDCID == dc && string(c.PhotoStripped) == string(stripped) {
|
|
return false, nil
|
|
}
|
|
_, e = tx.Exec(ctx, `UPDATE communities SET photo_id=$2,photo_dc_id=$3,photo_stripped=$4,updated_at=now() WHERE id=$1`, communityID, id, dc, stripped)
|
|
return true, e
|
|
})
|
|
if err != nil {
|
|
return domain.CommunityView{}, false, err
|
|
}
|
|
v, err := s.GetCommunity(ctx, actorUserID, communityID)
|
|
return v, changed, err
|
|
}
|
|
|
|
func (s *CommunityStore) DeleteCommunity(ctx context.Context, actorUserID, communityID int64, date int) (domain.CommunityView, []domain.Peer, error) {
|
|
type result struct {
|
|
view domain.CommunityView
|
|
peers []domain.Peer
|
|
}
|
|
r, err := withCommunityTx(ctx, s, func(tx pgx.Tx) (result, error) {
|
|
c, m, e := lockCommunityActor(ctx, tx, actorUserID, communityID)
|
|
if e != nil {
|
|
return result{}, e
|
|
}
|
|
if m.Role != domain.CommunityRoleCreator {
|
|
return result{}, domain.ErrCommunityCreatorRequired
|
|
}
|
|
links, e := communityLinkRows(ctx, tx, c, m)
|
|
if e != nil {
|
|
return result{}, e
|
|
}
|
|
peers := make([]domain.Peer, 0, len(links))
|
|
serviceMessages := make([]domain.SendChannelMessageResult, 0, len(links))
|
|
for _, l := range links {
|
|
peers = append(peers, l.Peer)
|
|
if e := setPeerLinkedCommunity(ctx, tx, l.Peer, 0); e != nil {
|
|
return result{}, e
|
|
}
|
|
serviceMessage, e := s.appendCommunityServiceMessageTx(ctx, tx, l.Peer, actorUserID, date, 0)
|
|
if e != nil {
|
|
return result{}, e
|
|
}
|
|
if serviceMessage != nil {
|
|
serviceMessages = append(serviceMessages, *serviceMessage)
|
|
}
|
|
}
|
|
if _, e = tx.Exec(ctx, `DELETE FROM community_peer_links WHERE community_id=$1`, communityID); e != nil {
|
|
return result{}, e
|
|
}
|
|
if _, e = tx.Exec(ctx, `DELETE FROM community_peer_link_requests WHERE community_id=$1`, communityID); e != nil {
|
|
return result{}, e
|
|
}
|
|
if _, e = tx.Exec(ctx, `DELETE FROM community_user_states WHERE community_id=$1`, communityID); e != nil {
|
|
return result{}, e
|
|
}
|
|
if _, e = tx.Exec(ctx, `UPDATE communities SET deleted=true,title='',about='',updated_at=now() WHERE id=$1`, communityID); e != nil {
|
|
return result{}, e
|
|
}
|
|
c.Deleted = true
|
|
c.Title = ""
|
|
c.About = ""
|
|
return result{view: domain.CommunityView{Community: c, Self: m, Forbidden: true, ServiceMessages: serviceMessages}, peers: peers}, nil
|
|
})
|
|
return r.view, r.peers, err
|
|
}
|
|
|
|
func (s *CommunityStore) SetCommunityPinned(ctx context.Context, userID, communityID int64, pinned bool) (bool, error) {
|
|
return withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) {
|
|
if _, _, e := lockCommunityActor(ctx, tx, userID, communityID); e != nil {
|
|
return false, e
|
|
}
|
|
var collapsed, old bool
|
|
var order int
|
|
e := tx.QueryRow(ctx, `SELECT collapsed,pinned,pinned_order FROM community_user_states WHERE community_id=$1 AND user_id=$2 FOR UPDATE`, communityID, userID).Scan(&collapsed, &old, &order)
|
|
if e != nil {
|
|
return false, domain.ErrCommunityInvalid
|
|
}
|
|
if !collapsed {
|
|
return false, domain.ErrCommunityInvalid
|
|
}
|
|
if old == pinned {
|
|
return false, nil
|
|
}
|
|
if pinned {
|
|
if e := tx.QueryRow(ctx, `SELECT GREATEST(1000000000,COALESCE(MAX(pinned_order),0))+1 FROM community_user_states WHERE user_id=$1 AND pinned`, userID).Scan(&order); e != nil {
|
|
return false, e
|
|
}
|
|
} else {
|
|
order = 0
|
|
}
|
|
_, e = tx.Exec(ctx, `UPDATE community_user_states SET pinned=$3,pinned_order=$4,updated_at=now() WHERE community_id=$1 AND user_id=$2`, communityID, userID, pinned, order)
|
|
return true, e
|
|
})
|
|
}
|
|
|
|
func (s *CommunityStore) ReorderCommunityPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) (bool, error) {
|
|
return withCommunityTx(ctx, s, func(tx pgx.Tx) (bool, error) {
|
|
seen := map[int64]struct{}{}
|
|
for _, peer := range order {
|
|
if peer.Type != domain.PeerTypeCommunity {
|
|
continue
|
|
}
|
|
if peer.ID == 0 {
|
|
return false, domain.ErrCommunityInvalid
|
|
}
|
|
if _, ok := seen[peer.ID]; ok {
|
|
return false, domain.ErrCommunityInvalid
|
|
}
|
|
seen[peer.ID] = struct{}{}
|
|
}
|
|
rows, e := tx.Query(ctx, `SELECT community_id,pinned_order FROM community_user_states WHERE user_id=$1 AND pinned FOR UPDATE`, userID)
|
|
if e != nil {
|
|
return false, e
|
|
}
|
|
old := map[int64]int{}
|
|
for rows.Next() {
|
|
var id int64
|
|
var pinnedOrder int
|
|
if e := rows.Scan(&id, &pinnedOrder); e != nil {
|
|
rows.Close()
|
|
return false, e
|
|
}
|
|
old[id] = pinnedOrder
|
|
}
|
|
rows.Close()
|
|
changed := false
|
|
if force {
|
|
if _, e = tx.Exec(ctx, `UPDATE community_user_states SET pinned=false,pinned_order=0,updated_at=now() WHERE user_id=$1 AND pinned`, userID); e != nil {
|
|
return false, e
|
|
}
|
|
changed = len(old) > 0
|
|
}
|
|
for i, peer := range order {
|
|
if peer.Type != domain.PeerTypeCommunity {
|
|
continue
|
|
}
|
|
pinnedOrder := len(order) - i
|
|
if old[peer.ID] == pinnedOrder && !force {
|
|
continue
|
|
}
|
|
cmd, e := tx.Exec(ctx, `UPDATE community_user_states SET pinned=true,pinned_order=$3,updated_at=now() WHERE user_id=$1 AND community_id=$2 AND collapsed`, userID, peer.ID, pinnedOrder)
|
|
if e != nil {
|
|
return false, e
|
|
}
|
|
if cmd.RowsAffected() != 1 {
|
|
return false, domain.ErrCommunityInvalid
|
|
}
|
|
changed = true
|
|
}
|
|
return changed, nil
|
|
})
|
|
}
|
|
|
|
func (s *CommunityStore) CommunitySearchScope(ctx context.Context, viewerUserID, communityID int64) (domain.CommunitySearchScope, error) {
|
|
v, err := s.GetCommunity(ctx, viewerUserID, communityID)
|
|
if err != nil {
|
|
return domain.CommunitySearchScope{}, err
|
|
}
|
|
out := domain.CommunitySearchScope{CommunityID: communityID}
|
|
for _, l := range v.Links {
|
|
if l.Peer.Type == domain.PeerTypeChannel {
|
|
if l.CanViewHistory {
|
|
out.ChannelIDs = append(out.ChannelIDs, l.Peer.ID)
|
|
}
|
|
} else {
|
|
out.BotUserIDs = append(out.BotUserIDs, l.Peer.ID)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func uniqueInt64s(ids []int64) []int64 {
|
|
seen := map[int64]struct{}{}
|
|
out := make([]int64, 0, len(ids))
|
|
for _, id := range ids {
|
|
if id == 0 {
|
|
continue
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
out = append(out, id)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
|
return out
|
|
}
|