feat: sync recent call and channel fixes
This commit is contained in:
parent
e5e0080216
commit
866a87583e
65 changed files with 6680 additions and 229 deletions
|
|
@ -26,6 +26,7 @@ type ChannelStore interface {
|
|||
SetChannelWallpaper(ctx context.Context, req domain.SetChannelWallpaperRequest) (domain.SetChannelWallpaperResult, error)
|
||||
EditChannelAbout(ctx context.Context, req domain.EditChannelAboutRequest) (domain.Channel, error)
|
||||
EditChannelAdmin(ctx context.Context, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error)
|
||||
TransferChannelOwnership(ctx context.Context, req domain.TransferChannelOwnershipRequest) (domain.TransferChannelOwnershipResult, error)
|
||||
EditChannelMemberRank(ctx context.Context, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error)
|
||||
EditChannelBanned(ctx context.Context, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error)
|
||||
EditChannelDefaultBannedRights(ctx context.Context, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error)
|
||||
|
|
|
|||
|
|
@ -11,12 +11,21 @@ import (
|
|||
type GroupCallStore interface {
|
||||
// CreateGroupCall 建会;同频道已有活跃通话返回 domain.ErrGroupCallAlreadyStarted。
|
||||
CreateGroupCall(ctx context.Context, call domain.GroupCall) (domain.GroupCall, error)
|
||||
// CreateConferenceCall 建 ad-hoc conference;同一 creator+random_id 幂等返回既有活跃会。
|
||||
CreateConferenceCall(ctx context.Context, call domain.GroupCall) (domain.GroupCall, error)
|
||||
GetGroupCall(ctx context.Context, callID int64) (domain.GroupCall, bool, error)
|
||||
GetGroupCallBySlug(ctx context.Context, slug string) (domain.GroupCall, bool, error)
|
||||
GetGroupCallByInviteMessage(ctx context.Context, userID int64, msgID int) (domain.GroupCall, domain.GroupCallInvite, bool, error)
|
||||
// JoinGroupCall 加入/重进(同主键 upsert 换新 ssrc);ssrc 与他人撞活跃唯一
|
||||
// 约束返回 domain.ErrGroupCallSSRCDuplicate;version++。
|
||||
JoinGroupCall(ctx context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error)
|
||||
// LeaveGroupCall 置 left+version++;未在会返回 domain.ErrGroupCallNotJoined。
|
||||
// LeaveGroupCall 置 left+version++;conference 最后一名活跃参与者离开时同步转
|
||||
// discarded,普通 channel group call 允许空房间继续 active;未在会返回
|
||||
// domain.ErrGroupCallNotJoined。
|
||||
LeaveGroupCall(ctx context.Context, callID, userID int64, now int) (domain.GroupCallMutation, error)
|
||||
// RemoveConferenceCallParticipants 在同一事务内接受 conference E2E remove block、
|
||||
// 清理目标的 E2E 成员标记,并在 kick 时把活跃参与者置 left。
|
||||
RemoveConferenceCallParticipants(ctx context.Context, req domain.RemoveConferenceCallParticipantsRequest) (domain.RemoveConferenceCallParticipantsResult, error)
|
||||
// DiscardGroupCall 终结通话并清空参与者,返回终态 call 与此前活跃的参与者。
|
||||
DiscardGroupCall(ctx context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error)
|
||||
// TouchParticipant 刷新 checkGroupCall 保活水位,返回该用户当前活跃 ssrc 集合
|
||||
|
|
@ -36,7 +45,7 @@ type GroupCallStore interface {
|
|||
//(每清一人 version++)。注意调用方必须叠加 SFU 媒体面活性做双过期判定。
|
||||
SweepStaleParticipants(ctx context.Context, checkOlderThan, now int, limit int) ([]domain.GroupCallMutation, error)
|
||||
// ResetAllParticipants 服务端重启恢复:把全部活跃通话的参与者批量置 left
|
||||
//(每通话 version++),返回受影响的通话。
|
||||
//(每通话 version++),conference 若因此变空则同步转 discarded,返回受影响的通话。
|
||||
ResetAllParticipants(ctx context.Context, now int) ([]domain.GroupCall, error)
|
||||
// NextRaiseHandRating 分配全局单调递增的举手序号(举手排序用)。
|
||||
NextRaiseHandRating(ctx context.Context, callID int64) (int64, error)
|
||||
|
|
@ -45,4 +54,13 @@ type GroupCallStore interface {
|
|||
SetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64, override domain.GroupCallParticipantOverride, clear bool) error
|
||||
// GetParticipantOverride 取某 setter 对某 target 的覆盖。
|
||||
GetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64) (domain.GroupCallParticipantOverride, bool, error)
|
||||
// CreateConferenceInvite 记录一条 conference 私聊邀请与其 message box id。
|
||||
CreateConferenceInvite(ctx context.Context, invite domain.GroupCallInvite) (domain.GroupCallInvite, error)
|
||||
SetConferenceInviteStatus(ctx context.Context, callID int64, inviteeUserID int64, msgID int, status domain.GroupCallInviteStatus, now int) (domain.GroupCallInvite, bool, error)
|
||||
// ListConferenceRecipientUserIDs 返回 conference 在线推送/访问候选人。
|
||||
// active 态只包含 creator、当前活跃参与者、pending/accepted invite 相关人;
|
||||
// discarded 态包含所有历史参与者与 invite 相关人,允许客户端收尾轮询读取终态。
|
||||
ListConferenceRecipientUserIDs(ctx context.Context, callID int64) ([]int64, error)
|
||||
AppendGroupCallChainBlock(ctx context.Context, block domain.GroupCallChainBlock) (domain.GroupCallChainBlock, error)
|
||||
ListGroupCallChainBlocks(ctx context.Context, callID int64, subChainID, offset, limit int) (domain.GroupCallChainBlockPage, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,25 +48,12 @@ func (s *ChannelStore) CreateChannel(_ context.Context, req domain.CreateChannel
|
|||
}
|
||||
channel.HasLink = true
|
||||
creator := domain.ChannelMember{
|
||||
ChannelID: channelID,
|
||||
UserID: req.CreatorUserID,
|
||||
Role: domain.ChannelRoleCreator,
|
||||
Status: domain.ChannelMemberActive,
|
||||
JoinedAt: req.Date,
|
||||
AdminRights: domain.ChannelAdminRights{
|
||||
ChangeInfo: true,
|
||||
PostMessages: true,
|
||||
EditMessages: true,
|
||||
DeleteMessages: true,
|
||||
PostStories: true,
|
||||
EditStories: true,
|
||||
DeleteStories: true,
|
||||
BanUsers: true,
|
||||
InviteUsers: true,
|
||||
PinMessages: true,
|
||||
AddAdmins: true,
|
||||
ManageCall: true,
|
||||
},
|
||||
ChannelID: channelID,
|
||||
UserID: req.CreatorUserID,
|
||||
Role: domain.ChannelRoleCreator,
|
||||
Status: domain.ChannelMemberActive,
|
||||
JoinedAt: req.Date,
|
||||
AdminRights: domain.CreatorChannelAdminRights(),
|
||||
}
|
||||
s.channels[channelID] = channel
|
||||
s.invites[inviteHash] = domain.ChannelInvite{
|
||||
|
|
|
|||
|
|
@ -87,7 +87,6 @@ func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int6
|
|||
|
||||
type item struct {
|
||||
channel domain.Channel
|
||||
joined bool
|
||||
rank int
|
||||
}
|
||||
items := make([]item, 0, limit)
|
||||
|
|
@ -98,9 +97,11 @@ func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int6
|
|||
}
|
||||
member, joined := s.members[channelID][viewerUserID]
|
||||
joined = joined && member.Status == domain.ChannelMemberActive
|
||||
if joined {
|
||||
continue
|
||||
}
|
||||
items = append(items, item{
|
||||
channel: cloneChannel(channel),
|
||||
joined: joined,
|
||||
rank: rank,
|
||||
})
|
||||
}
|
||||
|
|
@ -108,9 +109,6 @@ func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int6
|
|||
if items[i].rank != items[j].rank {
|
||||
return items[i].rank < items[j].rank
|
||||
}
|
||||
if items[i].joined != items[j].joined {
|
||||
return items[i].joined
|
||||
}
|
||||
if items[i].channel.ParticipantsCount != items[j].channel.ParticipantsCount {
|
||||
return items[i].channel.ParticipantsCount > items[j].channel.ParticipantsCount
|
||||
}
|
||||
|
|
@ -122,14 +120,10 @@ func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int6
|
|||
|
||||
out := domain.PublicChannelSearchResult{}
|
||||
for _, item := range items {
|
||||
if len(out.MyResults)+len(out.Results) >= limit {
|
||||
if len(out.Results) >= limit {
|
||||
break
|
||||
}
|
||||
if item.joined {
|
||||
out.MyResults = append(out.MyResults, item.channel)
|
||||
} else {
|
||||
out.Results = append(out.Results, item.channel)
|
||||
}
|
||||
out.Results = append(out.Results, item.channel)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -311,7 +311,7 @@ func (s *ChannelStore) EditChannelAdmin(_ context.Context, req domain.EditChanne
|
|||
member.AvailableMinPts = minPts
|
||||
}
|
||||
}
|
||||
member.AdminRights = req.AdminRights
|
||||
member.AdminRights = domain.NormalizeFullMegagroupAdminRights(channel, req.AdminRights)
|
||||
member.Rank = req.Rank
|
||||
if zeroChannelAdminRights(req.AdminRights) {
|
||||
member.Role = domain.ChannelRoleMember
|
||||
|
|
@ -350,6 +350,89 @@ func (s *ChannelStore) EditChannelAdmin(_ context.Context, req domain.EditChanne
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) TransferChannelOwnership(_ context.Context, req domain.TransferChannelOwnershipRequest) (domain.TransferChannelOwnershipResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.NewOwnerID == 0 || req.NewOwnerID == req.UserID {
|
||||
return domain.TransferChannelOwnershipResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, err
|
||||
}
|
||||
previousOwner := s.members[req.ChannelID][req.UserID]
|
||||
if channel.CreatorUserID != req.UserID || previousOwner.Role != domain.ChannelRoleCreator {
|
||||
return domain.TransferChannelOwnershipResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
previousNewOwner, ok := s.members[req.ChannelID][req.NewOwnerID]
|
||||
if !ok || previousNewOwner.Status != domain.ChannelMemberActive || previousNewOwner.BannedRights.ViewMessages {
|
||||
return domain.TransferChannelOwnershipResult{}, domain.ErrUserNotParticipant
|
||||
}
|
||||
if previousNewOwner.Role == domain.ChannelRoleCreator {
|
||||
return domain.TransferChannelOwnershipResult{}, domain.ErrChannelNotModified
|
||||
}
|
||||
oldOwner := previousOwner
|
||||
oldOwner.Role = domain.ChannelRoleAdmin
|
||||
oldOwner.AdminRights = creatorChannelAdminRights()
|
||||
oldOwner.Rank = ""
|
||||
oldOwner.Status = domain.ChannelMemberActive
|
||||
oldOwner.LeftAt = 0
|
||||
if oldOwner.InviterUserID == 0 {
|
||||
oldOwner.InviterUserID = req.UserID
|
||||
}
|
||||
newOwner := previousNewOwner
|
||||
newOwner.Role = domain.ChannelRoleCreator
|
||||
newOwner.AdminRights = creatorChannelAdminRights()
|
||||
newOwner.Rank = ""
|
||||
newOwner.Status = domain.ChannelMemberActive
|
||||
newOwner.LeftAt = 0
|
||||
if newOwner.JoinedAt == 0 {
|
||||
newOwner.JoinedAt = req.Date
|
||||
}
|
||||
channel.CreatorUserID = req.NewOwnerID
|
||||
s.channels[req.ChannelID] = channel
|
||||
s.members[req.ChannelID][req.UserID] = oldOwner
|
||||
s.members[req.ChannelID][req.NewOwnerID] = newOwner
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: req.ChannelID,
|
||||
UserID: req.UserID,
|
||||
Date: req.Date,
|
||||
Type: domain.ChannelAdminLogParticipantPromote,
|
||||
PrevParticipant: ptrChannelMember(previousOwner),
|
||||
NewParticipant: ptrChannelMember(oldOwner),
|
||||
})
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: req.ChannelID,
|
||||
UserID: req.UserID,
|
||||
Date: req.Date,
|
||||
Type: domain.ChannelAdminLogParticipantPromote,
|
||||
PrevParticipant: ptrChannelMember(previousNewOwner),
|
||||
NewParticipant: ptrChannelMember(newOwner),
|
||||
})
|
||||
s.refreshChannelCountsLocked(req.ChannelID)
|
||||
channel = s.channels[req.ChannelID]
|
||||
if msg, ok := s.findMessageLocked(req.ChannelID, channel.TopMessageID); ok {
|
||||
s.upsertChannelDialogLocked(oldOwner.UserID, channel, msg, false)
|
||||
s.upsertChannelDialogLocked(newOwner.UserID, channel, msg, false)
|
||||
}
|
||||
events := []domain.ChannelUpdateEvent{
|
||||
transientChannelParticipantEvent(channel.ID, req.UserID, previousOwner, oldOwner, req.Date),
|
||||
transientChannelParticipantEvent(channel.ID, req.UserID, previousNewOwner, newOwner, req.Date),
|
||||
}
|
||||
recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0)
|
||||
recipients = append(recipients, req.UserID, req.NewOwnerID)
|
||||
return domain.TransferChannelOwnershipResult{
|
||||
Channel: channel,
|
||||
PreviousOwner: previousOwner,
|
||||
OldOwner: oldOwner,
|
||||
PreviousNewOwner: previousNewOwner,
|
||||
NewOwner: newOwner,
|
||||
Events: events,
|
||||
Recipients: recipients,
|
||||
Date: req.Date,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) EditChannelMemberRank(_ context.Context, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.MemberID == 0 {
|
||||
return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
|
||||
|
|
@ -966,20 +1049,7 @@ func zeroChannelBannedRights(rights domain.ChannelBannedRights) bool {
|
|||
}
|
||||
|
||||
func creatorChannelAdminRights() domain.ChannelAdminRights {
|
||||
return domain.ChannelAdminRights{
|
||||
ChangeInfo: true,
|
||||
PostMessages: true,
|
||||
EditMessages: true,
|
||||
DeleteMessages: true,
|
||||
PostStories: true,
|
||||
EditStories: true,
|
||||
DeleteStories: true,
|
||||
BanUsers: true,
|
||||
InviteUsers: true,
|
||||
PinMessages: true,
|
||||
AddAdmins: true,
|
||||
ManageCall: true,
|
||||
}
|
||||
return domain.CreatorChannelAdminRights()
|
||||
}
|
||||
|
||||
func cloneChannelMembers(in []domain.ChannelMember) []domain.ChannelMember {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
|
@ -18,11 +19,31 @@ type overrideKey struct {
|
|||
callID, setter, target int64
|
||||
}
|
||||
|
||||
type conferenceRandomKey struct {
|
||||
creatorID int64
|
||||
randomID int64
|
||||
}
|
||||
|
||||
type inviteMessageKey struct {
|
||||
userID int64
|
||||
msgID int
|
||||
}
|
||||
|
||||
type chainKey struct {
|
||||
callID int64
|
||||
subChainID int
|
||||
}
|
||||
|
||||
type GroupCallStore struct {
|
||||
mu sync.Mutex
|
||||
calls map[int64]domain.GroupCall
|
||||
activeByChan map[int64]int64 // channelID → active callID
|
||||
activeByChan map[int64]int64 // channelID → active callID
|
||||
bySlug map[string]int64
|
||||
byConferenceRnd map[conferenceRandomKey]int64
|
||||
participants map[int64]map[int64]domain.GroupCallParticipant // callID → userID → row
|
||||
invites map[int64][]domain.GroupCallInvite
|
||||
inviteByMessage map[inviteMessageKey]domain.GroupCallInvite
|
||||
chainBlocks map[chainKey][]domain.GroupCallChainBlock
|
||||
overrides map[overrideKey]domain.GroupCallParticipantOverride
|
||||
raiseHandSeq map[int64]int64 // callID → 单调举手序号
|
||||
nextSyntheticID int64
|
||||
|
|
@ -31,11 +52,16 @@ type GroupCallStore struct {
|
|||
// NewGroupCallStore 创建内存实现。
|
||||
func NewGroupCallStore() *GroupCallStore {
|
||||
return &GroupCallStore{
|
||||
calls: make(map[int64]domain.GroupCall),
|
||||
activeByChan: make(map[int64]int64),
|
||||
participants: make(map[int64]map[int64]domain.GroupCallParticipant),
|
||||
overrides: make(map[overrideKey]domain.GroupCallParticipantOverride),
|
||||
raiseHandSeq: make(map[int64]int64),
|
||||
calls: make(map[int64]domain.GroupCall),
|
||||
activeByChan: make(map[int64]int64),
|
||||
bySlug: make(map[string]int64),
|
||||
byConferenceRnd: make(map[conferenceRandomKey]int64),
|
||||
participants: make(map[int64]map[int64]domain.GroupCallParticipant),
|
||||
invites: make(map[int64][]domain.GroupCallInvite),
|
||||
inviteByMessage: make(map[inviteMessageKey]domain.GroupCallInvite),
|
||||
chainBlocks: make(map[chainKey][]domain.GroupCallChainBlock),
|
||||
overrides: make(map[overrideKey]domain.GroupCallParticipantOverride),
|
||||
raiseHandSeq: make(map[int64]int64),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +79,7 @@ func (s *GroupCallStore) CreateGroupCall(_ context.Context, call domain.GroupCal
|
|||
if _, exists := s.calls[call.ID]; exists {
|
||||
return domain.GroupCall{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
call.Kind = domain.GroupCallKindChannel
|
||||
call.State = domain.GroupCallStateActive
|
||||
if call.Version <= 0 {
|
||||
call.Version = 1
|
||||
|
|
@ -64,6 +91,40 @@ func (s *GroupCallStore) CreateGroupCall(_ context.Context, call domain.GroupCal
|
|||
return call, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) CreateConferenceCall(_ context.Context, call domain.GroupCall) (domain.GroupCall, error) {
|
||||
if call.ID == 0 || call.AccessHash == 0 || call.CreatorUserID == 0 || call.InviteSlug == "" || call.InviteLink == "" {
|
||||
return domain.GroupCall{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if call.RandomID != 0 {
|
||||
if id, ok := s.byConferenceRnd[conferenceRandomKey{creatorID: call.CreatorUserID, randomID: call.RandomID}]; ok {
|
||||
existing := s.calls[id]
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
if _, exists := s.calls[call.ID]; exists {
|
||||
return domain.GroupCall{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
if _, exists := s.bySlug[call.InviteSlug]; exists {
|
||||
return domain.GroupCall{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
call.Kind = domain.GroupCallKindConference
|
||||
call.ChannelID = 0
|
||||
call.State = domain.GroupCallStateActive
|
||||
if call.Version <= 0 {
|
||||
call.Version = 1
|
||||
}
|
||||
call.ParticipantsCount = 0
|
||||
s.calls[call.ID] = call
|
||||
s.bySlug[call.InviteSlug] = call.ID
|
||||
if call.RandomID != 0 {
|
||||
s.byConferenceRnd[conferenceRandomKey{creatorID: call.CreatorUserID, randomID: call.RandomID}] = call.ID
|
||||
}
|
||||
s.participants[call.ID] = make(map[int64]domain.GroupCallParticipant)
|
||||
return call, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) GetGroupCall(_ context.Context, callID int64) (domain.GroupCall, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
|
@ -71,6 +132,28 @@ func (s *GroupCallStore) GetGroupCall(_ context.Context, callID int64) (domain.G
|
|||
return call, ok, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) GetGroupCallBySlug(_ context.Context, slug string) (domain.GroupCall, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
id, ok := s.bySlug[slug]
|
||||
if !ok {
|
||||
return domain.GroupCall{}, false, nil
|
||||
}
|
||||
call, ok := s.calls[id]
|
||||
return call, ok, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) GetGroupCallByInviteMessage(_ context.Context, userID int64, msgID int) (domain.GroupCall, domain.GroupCallInvite, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
inv, ok := s.inviteByMessage[inviteMessageKey{userID: userID, msgID: msgID}]
|
||||
if !ok {
|
||||
return domain.GroupCall{}, domain.GroupCallInvite{}, false, nil
|
||||
}
|
||||
call, ok := s.calls[inv.CallID]
|
||||
return call, inv, ok, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) JoinGroupCall(_ context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error) {
|
||||
if req.SSRC == 0 {
|
||||
return domain.GroupCallMutation{}, domain.ErrGroupCallInvalid
|
||||
|
|
@ -102,6 +185,8 @@ func (s *GroupCallStore) JoinGroupCall(_ context.Context, req domain.JoinGroupCa
|
|||
// VideoJSON 整体替换、PresentationJSON 随全新行清空(rejoin 后客户端
|
||||
// 会重发 joinGroupCallPresentation,旧屏幕登记必须作废)。
|
||||
VideoJSON: append([]byte(nil), req.VideoJSON...),
|
||||
PublicKey: append([]byte(nil), req.PublicKey...),
|
||||
JoinBlock: append([]byte(nil), req.JoinBlock...),
|
||||
}
|
||||
if rejoining && wasActive {
|
||||
// 同设备换 ssrc 的 rejoin 保留原 join_date(列表排序稳定)。
|
||||
|
|
@ -113,6 +198,14 @@ func (s *GroupCallStore) JoinGroupCall(_ context.Context, req domain.JoinGroupCa
|
|||
p.MutedByAdmin = true
|
||||
}
|
||||
rows[req.UserID] = p
|
||||
for i, inv := range s.invites[req.CallID] {
|
||||
if inv.InviteeUserID == req.UserID && inv.Status == domain.GroupCallInvitePending {
|
||||
inv.Status = domain.GroupCallInviteAccepted
|
||||
inv.UpdatedAt = req.Now
|
||||
s.invites[req.CallID][i] = inv
|
||||
s.inviteByMessage[inviteMessageKey{userID: inv.InviteeUserID, msgID: inv.MessageID}] = inv
|
||||
}
|
||||
}
|
||||
if !wasActive {
|
||||
call.ParticipantsCount++
|
||||
}
|
||||
|
|
@ -139,10 +232,97 @@ func (s *GroupCallStore) LeaveGroupCall(_ context.Context, callID, userID int64,
|
|||
call.ParticipantsCount--
|
||||
}
|
||||
call.Version++
|
||||
discardEmptyConference(&call, now)
|
||||
s.calls[callID] = call
|
||||
return domain.GroupCallMutation{Call: call, Participant: p}, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) RemoveConferenceCallParticipants(_ context.Context, req domain.RemoveConferenceCallParticipantsRequest) (domain.RemoveConferenceCallParticipantsResult, error) {
|
||||
if req.CallID == 0 || len(req.TargetUserIDs) == 0 || req.OnlyLeft == req.Kick {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
call, ok := s.calls[req.CallID]
|
||||
if !ok || !call.Conference() {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
if !call.Active() {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, domain.ErrGroupCallDiscarded
|
||||
}
|
||||
rows := s.participants[req.CallID]
|
||||
targets := uniqueNonZeroInt64s(req.TargetUserIDs...)
|
||||
e2eTargets := make([]int64, 0, len(targets))
|
||||
mediaTargets := make([]int64, 0, len(targets))
|
||||
for _, targetID := range targets {
|
||||
p, ok := rows[targetID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
hasE2EMarker := len(p.JoinBlock) > 0
|
||||
if req.OnlyLeft {
|
||||
if p.Left && hasE2EMarker {
|
||||
e2eTargets = append(e2eTargets, targetID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if req.Kick {
|
||||
if hasE2EMarker {
|
||||
e2eTargets = append(e2eTargets, targetID)
|
||||
}
|
||||
if !p.Left {
|
||||
mediaTargets = append(mediaTargets, targetID)
|
||||
}
|
||||
}
|
||||
}
|
||||
out := domain.RemoveConferenceCallParticipantsResult{Call: call}
|
||||
if len(e2eTargets) > 0 {
|
||||
if len(req.Block) == 0 {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, domain.ErrConferenceChainInvalid
|
||||
}
|
||||
block, err := s.appendGroupCallChainBlockLocked(domain.GroupCallChainBlock{
|
||||
CallID: req.CallID,
|
||||
SubChainID: 0,
|
||||
Offset: -1,
|
||||
AuthorUserID: req.AuthorUserID,
|
||||
Block: req.Block,
|
||||
CreatedAt: req.Now,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, err
|
||||
}
|
||||
out.ChainBlock = block
|
||||
out.ChainBlockAppended = true
|
||||
for _, targetID := range e2eTargets {
|
||||
p := rows[targetID]
|
||||
p.PublicKey = nil
|
||||
p.JoinBlock = nil
|
||||
rows[targetID] = p
|
||||
}
|
||||
}
|
||||
if len(mediaTargets) > 0 {
|
||||
out.ParticipantsChanged = make([]domain.GroupCallParticipant, 0, len(mediaTargets))
|
||||
for _, targetID := range mediaTargets {
|
||||
p := rows[targetID]
|
||||
if p.Left {
|
||||
continue
|
||||
}
|
||||
p.Left = true
|
||||
p.ActiveDate = req.Now
|
||||
rows[targetID] = p
|
||||
if call.ParticipantsCount > 0 {
|
||||
call.ParticipantsCount--
|
||||
}
|
||||
call.Version++
|
||||
out.ParticipantsChanged = append(out.ParticipantsChanged, p)
|
||||
}
|
||||
discardEmptyConference(&call, req.Now)
|
||||
s.calls[req.CallID] = call
|
||||
out.Call = call
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) DiscardGroupCall(_ context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
|
@ -376,12 +556,22 @@ func (s *GroupCallStore) ResetAllParticipants(_ context.Context, now int) ([]dom
|
|||
}
|
||||
call.ParticipantsCount = 0
|
||||
call.Version++
|
||||
discardEmptyConference(&call, now)
|
||||
s.calls[callID] = call
|
||||
out = append(out, call)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func discardEmptyConference(call *domain.GroupCall, now int) {
|
||||
if call == nil || !call.Conference() || !call.Active() || call.ParticipantsCount > 0 {
|
||||
return
|
||||
}
|
||||
call.State = domain.GroupCallStateDiscarded
|
||||
call.DiscardedAt = now
|
||||
call.Duration = max(0, now-call.CreatedAt)
|
||||
}
|
||||
|
||||
func applyGroupCallParticipantUpdate(p *domain.GroupCallParticipant, u domain.GroupCallParticipantUpdate) bool {
|
||||
changed := false
|
||||
if u.Muted != nil && p.Muted != *u.Muted {
|
||||
|
|
@ -457,6 +647,162 @@ func (s *GroupCallStore) GetParticipantOverride(_ context.Context, callID, sette
|
|||
return ov, ok, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) CreateConferenceInvite(_ context.Context, invite domain.GroupCallInvite) (domain.GroupCallInvite, error) {
|
||||
if invite.CallID == 0 || invite.InviterUserID == 0 || invite.InviteeUserID == 0 || invite.MessageID == 0 {
|
||||
return domain.GroupCallInvite{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
call, ok := s.calls[invite.CallID]
|
||||
if !ok || !call.Conference() {
|
||||
return domain.GroupCallInvite{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
if invite.Status == "" {
|
||||
invite.Status = domain.GroupCallInvitePending
|
||||
}
|
||||
key := inviteMessageKey{userID: invite.InviteeUserID, msgID: invite.MessageID}
|
||||
if existing, ok := s.inviteByMessage[key]; ok {
|
||||
return existing, nil
|
||||
}
|
||||
s.invites[invite.CallID] = append(s.invites[invite.CallID], invite)
|
||||
s.inviteByMessage[key] = invite
|
||||
return invite, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) SetConferenceInviteStatus(_ context.Context, callID int64, inviteeUserID int64, msgID int, status domain.GroupCallInviteStatus, now int) (domain.GroupCallInvite, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
key := inviteMessageKey{userID: inviteeUserID, msgID: msgID}
|
||||
inv, ok := s.inviteByMessage[key]
|
||||
if !ok || inv.CallID != callID {
|
||||
return domain.GroupCallInvite{}, false, nil
|
||||
}
|
||||
if inv.Status == status {
|
||||
return inv, false, nil
|
||||
}
|
||||
inv.Status = status
|
||||
inv.UpdatedAt = now
|
||||
s.inviteByMessage[key] = inv
|
||||
for i, row := range s.invites[callID] {
|
||||
if row.InviteeUserID == inviteeUserID && row.MessageID == msgID {
|
||||
s.invites[callID][i] = inv
|
||||
break
|
||||
}
|
||||
}
|
||||
return inv, true, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) ListConferenceRecipientUserIDs(_ context.Context, callID int64) ([]int64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
call, ok := s.calls[callID]
|
||||
if !ok {
|
||||
return nil, domain.ErrGroupCallInvalid
|
||||
}
|
||||
includeHistorical := !call.Active()
|
||||
seen := map[int64]struct{}{}
|
||||
if call.CreatorUserID != 0 {
|
||||
seen[call.CreatorUserID] = struct{}{}
|
||||
}
|
||||
for userID, p := range s.participants[callID] {
|
||||
if includeHistorical || !p.Left {
|
||||
seen[userID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, inv := range s.invites[callID] {
|
||||
if includeHistorical || inv.Status == domain.GroupCallInvitePending || inv.Status == domain.GroupCallInviteAccepted {
|
||||
seen[inv.InviteeUserID] = struct{}{}
|
||||
seen[inv.InviterUserID] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := make([]int64, 0, len(seen))
|
||||
for id := range seen {
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) AppendGroupCallChainBlock(_ context.Context, block domain.GroupCallChainBlock) (domain.GroupCallChainBlock, error) {
|
||||
if block.CallID == 0 || len(block.Block) == 0 {
|
||||
return domain.GroupCallChainBlock{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.appendGroupCallChainBlockLocked(block)
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) appendGroupCallChainBlockLocked(block domain.GroupCallChainBlock) (domain.GroupCallChainBlock, error) {
|
||||
if call, ok := s.calls[block.CallID]; !ok || !call.Conference() {
|
||||
return domain.GroupCallChainBlock{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
key := chainKey{callID: block.CallID, subChainID: block.SubChainID}
|
||||
rows := s.chainBlocks[key]
|
||||
for _, row := range rows {
|
||||
if bytes.Equal(row.Block, block.Block) {
|
||||
return domain.GroupCallChainBlock{}, domain.ErrConferenceChainInvalid
|
||||
}
|
||||
}
|
||||
nextOffset := 0
|
||||
if len(rows) > 0 {
|
||||
nextOffset = rows[len(rows)-1].Offset + 1
|
||||
}
|
||||
if block.Offset < 0 {
|
||||
block.Offset = nextOffset
|
||||
}
|
||||
if block.Offset != nextOffset {
|
||||
return domain.GroupCallChainBlock{}, domain.ErrConferenceChainInvalid
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Offset == block.Offset {
|
||||
return domain.GroupCallChainBlock{}, domain.ErrConferenceChainInvalid
|
||||
}
|
||||
}
|
||||
block.Block = append([]byte(nil), block.Block...)
|
||||
s.chainBlocks[key] = append(rows, block)
|
||||
sort.Slice(s.chainBlocks[key], func(i, j int) bool {
|
||||
return s.chainBlocks[key][i].Offset < s.chainBlocks[key][j].Offset
|
||||
})
|
||||
return block, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) ListGroupCallChainBlocks(_ context.Context, callID int64, subChainID, offset, limit int) (domain.GroupCallChainBlockPage, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if call, ok := s.calls[callID]; !ok || !call.Conference() {
|
||||
return domain.GroupCallChainBlockPage{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
rows := s.chainBlocks[chainKey{callID: callID, subChainID: subChainID}]
|
||||
if offset == domain.GroupCallChainBlockLatestOffset {
|
||||
page := domain.GroupCallChainBlockPage{NextOffset: 0}
|
||||
if len(rows) == 0 {
|
||||
return page, nil
|
||||
}
|
||||
block := rows[len(rows)-1]
|
||||
page.Blocks = append(page.Blocks, block)
|
||||
page.NextOffset = block.Offset + 1
|
||||
return page, nil
|
||||
}
|
||||
if offset < 0 {
|
||||
return domain.GroupCallChainBlockPage{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
page := domain.GroupCallChainBlockPage{NextOffset: offset}
|
||||
for _, row := range rows {
|
||||
if row.Offset < offset {
|
||||
continue
|
||||
}
|
||||
page.Blocks = append(page.Blocks, row)
|
||||
page.NextOffset = row.Offset + 1
|
||||
if len(page.Blocks) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
|
|
|
|||
|
|
@ -150,18 +150,18 @@ func (s *ChannelStore) SearchPublicChannels(ctx context.Context, viewerUserID in
|
|||
queryPrefix := escapeLike(queryLower) + "%"
|
||||
queryLike := "%" + escapeLike(queryLower) + "%"
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT `+channelColumns+`,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM channel_members m
|
||||
WHERE m.channel_id = c.id
|
||||
AND m.user_id = $1
|
||||
AND m.status = 'active'
|
||||
) AS viewer_member
|
||||
SELECT `+channelColumns+`
|
||||
FROM channels c
|
||||
WHERE NOT c.deleted
|
||||
AND (c.broadcast OR c.megagroup)
|
||||
AND COALESCE(c.username, '') <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM channel_members m
|
||||
WHERE m.channel_id = c.id
|
||||
AND m.user_id = $1
|
||||
AND m.status = 'active'
|
||||
)
|
||||
AND (
|
||||
lower(c.username) = $2
|
||||
OR lower(c.username) LIKE $3 ESCAPE '\'
|
||||
|
|
@ -176,7 +176,6 @@ ORDER BY CASE
|
|||
WHEN lower(c.title) LIKE $3 ESCAPE '\' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
viewer_member DESC,
|
||||
c.participants_count DESC,
|
||||
c.date DESC,
|
||||
c.id DESC
|
||||
|
|
@ -186,19 +185,14 @@ LIMIT $5`, viewerUserID, queryLower, queryPrefix, queryLike, limit)
|
|||
}
|
||||
defer rows.Close()
|
||||
out := domain.PublicChannelSearchResult{
|
||||
MyResults: make([]domain.Channel, 0),
|
||||
Results: make([]domain.Channel, 0, limit),
|
||||
Results: make([]domain.Channel, 0, limit),
|
||||
}
|
||||
for rows.Next() {
|
||||
ch, viewerMember, err := scanChannelWithViewerMember(rows)
|
||||
ch, err := scanChannel(rows)
|
||||
if err != nil {
|
||||
return domain.PublicChannelSearchResult{}, err
|
||||
}
|
||||
if viewerMember {
|
||||
out.MyResults = append(out.MyResults, ch)
|
||||
} else {
|
||||
out.Results = append(out.Results, ch)
|
||||
}
|
||||
out.Results = append(out.Results, ch)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return domain.PublicChannelSearchResult{}, err
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ func (s *ChannelStore) EditChannelAdmin(ctx context.Context, req domain.EditChan
|
|||
member.AvailableMinPts = minPts
|
||||
}
|
||||
}
|
||||
member.AdminRights = req.AdminRights
|
||||
member.AdminRights = domain.NormalizeFullMegagroupAdminRights(channel, req.AdminRights)
|
||||
if zeroChannelAdminRights(req.AdminRights) {
|
||||
member.Role = domain.ChannelRoleMember
|
||||
member.Rank = ""
|
||||
|
|
@ -110,6 +110,146 @@ func (s *ChannelStore) EditChannelAdmin(ctx context.Context, req domain.EditChan
|
|||
return domain.EditChannelAdminResult{Channel: channel, Previous: previous, Participant: member, Event: event, Recipients: recipients, Date: req.Date}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) TransferChannelOwnership(ctx context.Context, req domain.TransferChannelOwnershipRequest) (domain.TransferChannelOwnershipResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.NewOwnerID == 0 || req.NewOwnerID == req.UserID {
|
||||
return domain.TransferChannelOwnershipResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return domain.TransferChannelOwnershipResult{}, fmt.Errorf("transfer channel ownership: db does not support transactions")
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = nowUnix()
|
||||
}
|
||||
tx, err := beginner.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, fmt.Errorf("begin transfer channel ownership: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
channel, previousOwner, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, err
|
||||
}
|
||||
if channel.CreatorUserID != req.UserID || previousOwner.Role != domain.ChannelRoleCreator {
|
||||
return domain.TransferChannelOwnershipResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
previousNewOwner, err := s.getChannelMember(ctx, tx, req.ChannelID, req.NewOwnerID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrChannelPrivate) {
|
||||
return domain.TransferChannelOwnershipResult{}, domain.ErrUserNotParticipant
|
||||
}
|
||||
return domain.TransferChannelOwnershipResult{}, err
|
||||
}
|
||||
if previousNewOwner.Status != domain.ChannelMemberActive || previousNewOwner.BannedRights.ViewMessages {
|
||||
return domain.TransferChannelOwnershipResult{}, domain.ErrUserNotParticipant
|
||||
}
|
||||
if previousNewOwner.Role == domain.ChannelRoleCreator {
|
||||
return domain.TransferChannelOwnershipResult{}, domain.ErrChannelNotModified
|
||||
}
|
||||
oldOwner := previousOwner
|
||||
oldOwner.Role = domain.ChannelRoleAdmin
|
||||
oldOwner.AdminRights = creatorChannelMember(req.ChannelID, req.UserID, req.Date).AdminRights
|
||||
oldOwner.Rank = ""
|
||||
oldOwner.Status = domain.ChannelMemberActive
|
||||
oldOwner.LeftAt = 0
|
||||
if oldOwner.InviterUserID == 0 {
|
||||
oldOwner.InviterUserID = req.UserID
|
||||
}
|
||||
newOwner := previousNewOwner
|
||||
newOwner.Role = domain.ChannelRoleCreator
|
||||
newOwner.AdminRights = creatorChannelMember(req.ChannelID, req.NewOwnerID, req.Date).AdminRights
|
||||
newOwner.Rank = ""
|
||||
newOwner.Status = domain.ChannelMemberActive
|
||||
newOwner.LeftAt = 0
|
||||
if newOwner.JoinedAt == 0 {
|
||||
newOwner.JoinedAt = req.Date
|
||||
}
|
||||
channel.CreatorUserID = req.NewOwnerID
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE channels
|
||||
SET creator_user_id = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND NOT deleted`, req.ChannelID, req.NewOwnerID); err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, fmt.Errorf("update channel creator: %w", err)
|
||||
}
|
||||
if err := upsertChannelMemberTx(ctx, tx, channel, oldOwner); err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, err
|
||||
}
|
||||
if err := upsertChannelMemberTx(ctx, tx, channel, newOwner); err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, err
|
||||
}
|
||||
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
|
||||
ChannelID: req.ChannelID,
|
||||
UserID: req.UserID,
|
||||
Date: req.Date,
|
||||
Type: domain.ChannelAdminLogParticipantPromote,
|
||||
PrevParticipant: &previousOwner,
|
||||
NewParticipant: &oldOwner,
|
||||
}); err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, err
|
||||
}
|
||||
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
|
||||
ChannelID: req.ChannelID,
|
||||
UserID: req.UserID,
|
||||
Date: req.Date,
|
||||
Type: domain.ChannelAdminLogParticipantPromote,
|
||||
PrevParticipant: &previousNewOwner,
|
||||
NewParticipant: &newOwner,
|
||||
}); err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, err
|
||||
}
|
||||
channel, err = refreshChannelCountsTx(ctx, tx, channel)
|
||||
if err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, err
|
||||
}
|
||||
msg, _ := s.getChannelMessage(ctx, tx, req.ChannelID, channel.TopMessageID)
|
||||
if err := upsertChannelDialogTx(ctx, tx, oldOwner.UserID, channel, msg, oldOwner.ReadInboxMaxID, oldOwner.ReadOutboxMaxID); err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, err
|
||||
}
|
||||
if err := upsertChannelDialogTx(ctx, tx, newOwner.UserID, channel, msg, newOwner.ReadInboxMaxID, newOwner.ReadOutboxMaxID); err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, err
|
||||
}
|
||||
recipients, err := s.listActiveChannelMemberIDs(ctx, tx, req.ChannelID, 0)
|
||||
if err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.TransferChannelOwnershipResult{}, fmt.Errorf("commit transfer channel ownership: %w", err)
|
||||
}
|
||||
committed = true
|
||||
if s.rowCache != nil {
|
||||
s.rowCache.delete(req.ChannelID)
|
||||
}
|
||||
if s.memberCache != nil {
|
||||
s.memberCache.delete(req.ChannelID, req.UserID)
|
||||
s.memberCache.delete(req.ChannelID, req.NewOwnerID)
|
||||
}
|
||||
if s.dialogCache != nil {
|
||||
s.dialogCache.delete(oldOwner.UserID, req.ChannelID)
|
||||
s.dialogCache.delete(newOwner.UserID, req.ChannelID)
|
||||
}
|
||||
events := []domain.ChannelUpdateEvent{
|
||||
transientChannelParticipantEvent(channel.ID, req.UserID, previousOwner, oldOwner, req.Date),
|
||||
transientChannelParticipantEvent(channel.ID, req.UserID, previousNewOwner, newOwner, req.Date),
|
||||
}
|
||||
recipients = append(recipients, req.UserID, req.NewOwnerID)
|
||||
return domain.TransferChannelOwnershipResult{
|
||||
Channel: channel,
|
||||
PreviousOwner: previousOwner,
|
||||
OldOwner: oldOwner,
|
||||
PreviousNewOwner: previousNewOwner,
|
||||
NewOwner: newOwner,
|
||||
Events: events,
|
||||
Recipients: recipients,
|
||||
Date: req.Date,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) EditChannelMemberRank(ctx context.Context, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || req.MemberID == 0 {
|
||||
return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
|
||||
|
|
|
|||
|
|
@ -351,25 +351,12 @@ func zeroChannelBannedRights(rights domain.ChannelBannedRights) bool {
|
|||
|
||||
func creatorChannelMember(channelID, userID int64, date int) domain.ChannelMember {
|
||||
return domain.ChannelMember{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
Role: domain.ChannelRoleCreator,
|
||||
Status: domain.ChannelMemberActive,
|
||||
JoinedAt: date,
|
||||
AdminRights: domain.ChannelAdminRights{
|
||||
ChangeInfo: true,
|
||||
PostMessages: true,
|
||||
EditMessages: true,
|
||||
DeleteMessages: true,
|
||||
PostStories: true,
|
||||
EditStories: true,
|
||||
DeleteStories: true,
|
||||
BanUsers: true,
|
||||
InviteUsers: true,
|
||||
PinMessages: true,
|
||||
AddAdmins: true,
|
||||
ManageCall: true,
|
||||
},
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
Role: domain.ChannelRoleCreator,
|
||||
Status: domain.ChannelMemberActive,
|
||||
JoinedAt: date,
|
||||
AdminRights: domain.CreatorChannelAdminRights(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
|
@ -24,21 +25,24 @@ func NewGroupCallStore(db sqlcgen.DBTX) *GroupCallStore {
|
|||
return &GroupCallStore{db: db}
|
||||
}
|
||||
|
||||
const groupCallColumns = `call_id, access_hash, channel_id, creator_user_id, state, title, join_muted,
|
||||
version, participants_count, created_at, discarded_at, duration, started_msg_id`
|
||||
const groupCallColumns = `call_id, access_hash, channel_id, creator_user_id, kind, state, title, join_muted,
|
||||
version, participants_count, created_at, discarded_at, duration, started_msg_id,
|
||||
invite_slug, invite_link, random_id, migrated_from_phone_call_id`
|
||||
|
||||
const groupCallParticipantColumns = `call_id, user_id, ssrc, join_date, active_date, muted, muted_by_admin,
|
||||
volume_by_admin, raise_hand_rating, video_json, presentation_json, left_call, last_check_date`
|
||||
volume_by_admin, raise_hand_rating, video_json, presentation_json, public_key, join_block, left_call, last_check_date`
|
||||
|
||||
func scanGroupCall(row rowScanner) (domain.GroupCall, error) {
|
||||
var c domain.GroupCall
|
||||
var state string
|
||||
var kind, state string
|
||||
if err := row.Scan(
|
||||
&c.ID, &c.AccessHash, &c.ChannelID, &c.CreatorUserID, &state, &c.Title, &c.JoinMuted,
|
||||
&c.ID, &c.AccessHash, &c.ChannelID, &c.CreatorUserID, &kind, &state, &c.Title, &c.JoinMuted,
|
||||
&c.Version, &c.ParticipantsCount, &c.CreatedAt, &c.DiscardedAt, &c.Duration, &c.StartedMsgID,
|
||||
&c.InviteSlug, &c.InviteLink, &c.RandomID, &c.MigratedFromPhoneCallID,
|
||||
); err != nil {
|
||||
return domain.GroupCall{}, err
|
||||
}
|
||||
c.Kind = domain.GroupCallKind(kind)
|
||||
c.State = domain.GroupCallState(state)
|
||||
return c, nil
|
||||
}
|
||||
|
|
@ -47,13 +51,39 @@ func scanGroupCallParticipant(row rowScanner) (domain.GroupCallParticipant, erro
|
|||
var p domain.GroupCallParticipant
|
||||
if err := row.Scan(
|
||||
&p.CallID, &p.UserID, &p.SSRC, &p.JoinDate, &p.ActiveDate, &p.Muted, &p.MutedByAdmin,
|
||||
&p.VolumeByAdmin, &p.RaiseHandRating, &p.VideoJSON, &p.PresentationJSON, &p.Left, &p.LastCheckDate,
|
||||
&p.VolumeByAdmin, &p.RaiseHandRating, &p.VideoJSON, &p.PresentationJSON, &p.PublicKey, &p.JoinBlock, &p.Left, &p.LastCheckDate,
|
||||
); err != nil {
|
||||
return domain.GroupCallParticipant{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func prefixedGroupCallColumns(alias string) string {
|
||||
parts := strings.Split(groupCallColumns, ",")
|
||||
for i, part := range parts {
|
||||
parts[i] = alias + "." + strings.TrimSpace(part)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func scanGroupCallInviteJoined(row rowScanner) (domain.GroupCall, domain.GroupCallInvite, error) {
|
||||
var c domain.GroupCall
|
||||
var inv domain.GroupCallInvite
|
||||
var kind, state, status string
|
||||
if err := row.Scan(
|
||||
&c.ID, &c.AccessHash, &c.ChannelID, &c.CreatorUserID, &kind, &state, &c.Title, &c.JoinMuted,
|
||||
&c.Version, &c.ParticipantsCount, &c.CreatedAt, &c.DiscardedAt, &c.Duration, &c.StartedMsgID,
|
||||
&c.InviteSlug, &c.InviteLink, &c.RandomID, &c.MigratedFromPhoneCallID,
|
||||
&inv.CallID, &inv.InviterUserID, &inv.InviteeUserID, &inv.MessageID, &status, &inv.Video, &inv.CreatedAt, &inv.UpdatedAt,
|
||||
); err != nil {
|
||||
return domain.GroupCall{}, domain.GroupCallInvite{}, err
|
||||
}
|
||||
c.Kind = domain.GroupCallKind(kind)
|
||||
c.State = domain.GroupCallState(state)
|
||||
inv.Status = domain.GroupCallInviteStatus(status)
|
||||
return c, inv, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) begin(ctx context.Context, op string) (pgx.Tx, error) {
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
|
|
@ -92,6 +122,31 @@ RETURNING `+groupCallColumns, callID, countDelta))
|
|||
return call, nil
|
||||
}
|
||||
|
||||
func bumpGroupCallParticipantsTx(ctx context.Context, tx pgx.Tx, callID int64, countDelta, now int) (domain.GroupCall, error) {
|
||||
call, err := scanGroupCall(tx.QueryRow(ctx, `
|
||||
UPDATE group_calls
|
||||
SET version = version + 1,
|
||||
participants_count = GREATEST(0, participants_count + $2),
|
||||
state = CASE
|
||||
WHEN kind = 'conference' AND state = 'active' AND GREATEST(0, participants_count + $2) = 0 THEN 'discarded'
|
||||
ELSE state
|
||||
END,
|
||||
discarded_at = CASE
|
||||
WHEN kind = 'conference' AND state = 'active' AND GREATEST(0, participants_count + $2) = 0 THEN $3
|
||||
ELSE discarded_at
|
||||
END,
|
||||
duration = CASE
|
||||
WHEN kind = 'conference' AND state = 'active' AND GREATEST(0, participants_count + $2) = 0 THEN GREATEST(0, $3 - created_at)
|
||||
ELSE duration
|
||||
END
|
||||
WHERE call_id = $1
|
||||
RETURNING `+groupCallColumns, callID, countDelta, now))
|
||||
if err != nil {
|
||||
return domain.GroupCall{}, fmt.Errorf("bump group call participants: %w", err)
|
||||
}
|
||||
return call, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) CreateGroupCall(ctx context.Context, call domain.GroupCall) (domain.GroupCall, error) {
|
||||
if call.ID == 0 || call.ChannelID == 0 || call.AccessHash == 0 {
|
||||
return domain.GroupCall{}, domain.ErrGroupCallInvalid
|
||||
|
|
@ -100,8 +155,8 @@ func (s *GroupCallStore) CreateGroupCall(ctx context.Context, call domain.GroupC
|
|||
call.Version = 1
|
||||
}
|
||||
_, err := s.db.Exec(ctx, `
|
||||
INSERT INTO group_calls (call_id, access_hash, channel_id, creator_user_id, state, title, join_muted, version, participants_count, created_at)
|
||||
VALUES ($1, $2, $3, $4, 'active', $5, $6, $7, 0, $8)`,
|
||||
INSERT INTO group_calls (call_id, access_hash, channel_id, creator_user_id, kind, state, title, join_muted, version, participants_count, created_at)
|
||||
VALUES ($1, $2, $3, $4, 'channel', 'active', $5, $6, $7, 0, $8)`,
|
||||
call.ID, call.AccessHash, call.ChannelID, call.CreatorUserID, call.Title, call.JoinMuted, call.Version, call.CreatedAt)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
|
|
@ -114,10 +169,67 @@ VALUES ($1, $2, $3, $4, 'active', $5, $6, $7, 0, $8)`,
|
|||
return domain.GroupCall{}, fmt.Errorf("insert group call: %w", err)
|
||||
}
|
||||
call.State = domain.GroupCallStateActive
|
||||
call.Kind = domain.GroupCallKindChannel
|
||||
call.ParticipantsCount = 0
|
||||
return call, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) CreateConferenceCall(ctx context.Context, call domain.GroupCall) (domain.GroupCall, error) {
|
||||
if call.ID == 0 || call.AccessHash == 0 || call.CreatorUserID == 0 || call.InviteSlug == "" || call.InviteLink == "" {
|
||||
return domain.GroupCall{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
if call.Version <= 0 {
|
||||
call.Version = 1
|
||||
}
|
||||
call.ChannelID = 0
|
||||
call.Kind = domain.GroupCallKindConference
|
||||
call.State = domain.GroupCallStateActive
|
||||
call.ParticipantsCount = 0
|
||||
created, err := scanGroupCall(s.db.QueryRow(ctx, `
|
||||
INSERT INTO group_calls (
|
||||
call_id, access_hash, channel_id, creator_user_id, kind, state, title, join_muted,
|
||||
version, participants_count, created_at, invite_slug, invite_link, random_id, migrated_from_phone_call_id
|
||||
) VALUES ($1, $2, 0, $3, 'conference', 'active', $4, FALSE, $5, 0, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING `+groupCallColumns,
|
||||
call.ID, call.AccessHash, call.CreatorUserID, call.Title, call.Version, call.CreatedAt,
|
||||
call.InviteSlug, call.InviteLink, call.RandomID, call.MigratedFromPhoneCallID))
|
||||
if err == nil {
|
||||
return created, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.GroupCall{}, fmt.Errorf("insert conference call: %w", err)
|
||||
}
|
||||
if call.RandomID != 0 {
|
||||
existing, found, getErr := s.getConferenceByRandom(ctx, call.CreatorUserID, call.RandomID)
|
||||
if getErr != nil {
|
||||
return domain.GroupCall{}, getErr
|
||||
}
|
||||
if found {
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
if existing, found, getErr := s.GetGroupCallBySlug(ctx, call.InviteSlug); getErr != nil {
|
||||
return domain.GroupCall{}, getErr
|
||||
} else if found {
|
||||
return existing, nil
|
||||
}
|
||||
return domain.GroupCall{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) getConferenceByRandom(ctx context.Context, creatorID, randomID int64) (domain.GroupCall, bool, error) {
|
||||
call, err := scanGroupCall(s.db.QueryRow(ctx,
|
||||
`SELECT `+groupCallColumns+` FROM group_calls WHERE kind = 'conference' AND creator_user_id = $1 AND random_id = $2`,
|
||||
creatorID, randomID))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.GroupCall{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.GroupCall{}, false, fmt.Errorf("get conference by random: %w", err)
|
||||
}
|
||||
return call, true, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) GetGroupCall(ctx context.Context, callID int64) (domain.GroupCall, bool, error) {
|
||||
call, err := scanGroupCall(s.db.QueryRow(ctx,
|
||||
`SELECT `+groupCallColumns+` FROM group_calls WHERE call_id = $1`, callID))
|
||||
|
|
@ -130,6 +242,34 @@ func (s *GroupCallStore) GetGroupCall(ctx context.Context, callID int64) (domain
|
|||
return call, true, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) GetGroupCallBySlug(ctx context.Context, slug string) (domain.GroupCall, bool, error) {
|
||||
call, err := scanGroupCall(s.db.QueryRow(ctx,
|
||||
`SELECT `+groupCallColumns+` FROM group_calls WHERE invite_slug = $1`, slug))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.GroupCall{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.GroupCall{}, false, fmt.Errorf("get group call by slug: %w", err)
|
||||
}
|
||||
return call, true, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) GetGroupCallByInviteMessage(ctx context.Context, userID int64, msgID int) (domain.GroupCall, domain.GroupCallInvite, bool, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
SELECT `+prefixedGroupCallColumns("c")+`, i.call_id, i.inviter_user_id, i.invitee_user_id, i.message_id, i.status, i.video, i.created_at, i.updated_at
|
||||
FROM group_call_invites i
|
||||
JOIN group_calls c ON c.call_id = i.call_id
|
||||
WHERE i.invitee_user_id = $1 AND i.message_id = $2`, userID, msgID)
|
||||
call, inv, err := scanGroupCallInviteJoined(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.GroupCall{}, domain.GroupCallInvite{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.GroupCall{}, domain.GroupCallInvite{}, false, fmt.Errorf("get group call by invite message: %w", err)
|
||||
}
|
||||
return call, inv, true, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) JoinGroupCall(ctx context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error) {
|
||||
if req.SSRC == 0 {
|
||||
return domain.GroupCallMutation{}, domain.ErrGroupCallInvalid
|
||||
|
|
@ -165,6 +305,8 @@ func (s *GroupCallStore) JoinGroupCall(ctx context.Context, req domain.JoinGroup
|
|||
JoinDate: req.Now,
|
||||
ActiveDate: req.Now,
|
||||
LastCheckDate: req.Now,
|
||||
PublicKey: append([]byte(nil), req.PublicKey...),
|
||||
JoinBlock: append([]byte(nil), req.JoinBlock...),
|
||||
}
|
||||
if wasActive {
|
||||
// 同人换 ssrc 的 rejoin 保留原 join_date(列表排序稳定)。
|
||||
|
|
@ -178,8 +320,8 @@ func (s *GroupCallStore) JoinGroupCall(ctx context.Context, req domain.JoinGroup
|
|||
// video_json 整体替换、presentation_json 清空(rejoin 后客户端会重发
|
||||
// joinGroupCallPresentation,旧屏幕登记必须作废)。
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO group_call_participants (call_id, user_id, ssrc, join_date, active_date, muted, muted_by_admin, volume_by_admin, raise_hand_rating, video_json, left_call, last_check_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 0, 0, $8, FALSE, $9)
|
||||
INSERT INTO group_call_participants (call_id, user_id, ssrc, join_date, active_date, muted, muted_by_admin, volume_by_admin, raise_hand_rating, video_json, public_key, join_block, left_call, last_check_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 0, 0, $8, $9, $10, FALSE, $11)
|
||||
ON CONFLICT (call_id, user_id) DO UPDATE SET
|
||||
ssrc = EXCLUDED.ssrc,
|
||||
join_date = EXCLUDED.join_date,
|
||||
|
|
@ -190,15 +332,25 @@ ON CONFLICT (call_id, user_id) DO UPDATE SET
|
|||
raise_hand_rating = 0,
|
||||
video_json = EXCLUDED.video_json,
|
||||
presentation_json = NULL,
|
||||
public_key = EXCLUDED.public_key,
|
||||
join_block = EXCLUDED.join_block,
|
||||
left_call = FALSE,
|
||||
last_check_date = EXCLUDED.last_check_date`,
|
||||
req.CallID, req.UserID, req.SSRC, p.JoinDate, p.ActiveDate, p.Muted, p.MutedByAdmin, nullableJSON(p.VideoJSON), p.LastCheckDate); err != nil {
|
||||
req.CallID, req.UserID, req.SSRC, p.JoinDate, p.ActiveDate, p.Muted, p.MutedByAdmin,
|
||||
nullableJSON(p.VideoJSON), nullableGroupCallBytes(p.PublicKey), nullableGroupCallBytes(p.JoinBlock), p.LastCheckDate); err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return domain.GroupCallMutation{}, domain.ErrGroupCallSSRCDuplicate
|
||||
}
|
||||
return domain.GroupCallMutation{}, fmt.Errorf("upsert group call participant: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE group_call_invites
|
||||
SET status = 'accepted', updated_at = $3
|
||||
WHERE call_id = $1 AND invitee_user_id = $2 AND status = 'pending'`,
|
||||
req.CallID, req.UserID, req.Now); err != nil {
|
||||
return domain.GroupCallMutation{}, fmt.Errorf("accept conference invites: %w", err)
|
||||
}
|
||||
countDelta := 0
|
||||
if !wasActive {
|
||||
countDelta = 1
|
||||
|
|
@ -239,7 +391,7 @@ RETURNING `+groupCallParticipantColumns, callID, userID, now))
|
|||
if err != nil {
|
||||
return domain.GroupCallMutation{}, fmt.Errorf("leave group call participant: %w", err)
|
||||
}
|
||||
call, err := bumpGroupCallVersionTx(ctx, tx, callID, -1)
|
||||
call, err := bumpGroupCallParticipantsTx(ctx, tx, callID, -1, now)
|
||||
if err != nil {
|
||||
return domain.GroupCallMutation{}, err
|
||||
}
|
||||
|
|
@ -250,6 +402,143 @@ RETURNING `+groupCallParticipantColumns, callID, userID, now))
|
|||
return domain.GroupCallMutation{Call: call, Participant: p}, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) RemoveConferenceCallParticipants(ctx context.Context, req domain.RemoveConferenceCallParticipantsRequest) (domain.RemoveConferenceCallParticipantsResult, error) {
|
||||
if req.CallID == 0 || len(req.TargetUserIDs) == 0 || req.OnlyLeft == req.Kick {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
tx, err := s.begin(ctx, "remove conference call participants")
|
||||
if err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
call, err := lockGroupCallTx(ctx, tx, req.CallID)
|
||||
if err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, err
|
||||
}
|
||||
if !call.Conference() {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
if !call.Active() {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, domain.ErrGroupCallDiscarded
|
||||
}
|
||||
targets := uniqueNonZeroInt64s(req.TargetUserIDs...)
|
||||
if len(targets) == 0 {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, fmt.Errorf("commit no-op conference participant removal: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return domain.RemoveConferenceCallParticipantsResult{Call: call}, nil
|
||||
}
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT `+groupCallParticipantColumns+`
|
||||
FROM group_call_participants
|
||||
WHERE call_id = $1 AND user_id = ANY($2)
|
||||
FOR UPDATE`, req.CallID, targets)
|
||||
if err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, fmt.Errorf("lock conference participants: %w", err)
|
||||
}
|
||||
byID := make(map[int64]domain.GroupCallParticipant, len(targets))
|
||||
for rows.Next() {
|
||||
p, err := scanGroupCallParticipant(rows)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, err
|
||||
}
|
||||
byID[p.UserID] = p
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, err
|
||||
}
|
||||
e2eTargets := make([]int64, 0, len(targets))
|
||||
mediaTargets := make([]int64, 0, len(targets))
|
||||
for _, targetID := range targets {
|
||||
p, ok := byID[targetID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
hasE2EMarker := len(p.JoinBlock) > 0
|
||||
if req.OnlyLeft {
|
||||
if p.Left && hasE2EMarker {
|
||||
e2eTargets = append(e2eTargets, targetID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if req.Kick {
|
||||
if hasE2EMarker {
|
||||
e2eTargets = append(e2eTargets, targetID)
|
||||
}
|
||||
if !p.Left {
|
||||
mediaTargets = append(mediaTargets, targetID)
|
||||
}
|
||||
}
|
||||
}
|
||||
out := domain.RemoveConferenceCallParticipantsResult{Call: call}
|
||||
if len(e2eTargets) > 0 {
|
||||
if len(req.Block) == 0 {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, domain.ErrConferenceChainInvalid
|
||||
}
|
||||
block, err := appendGroupCallChainBlockTx(ctx, tx, domain.GroupCallChainBlock{
|
||||
CallID: req.CallID,
|
||||
SubChainID: 0,
|
||||
Offset: -1,
|
||||
AuthorUserID: req.AuthorUserID,
|
||||
Block: req.Block,
|
||||
CreatedAt: req.Now,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, err
|
||||
}
|
||||
out.ChainBlock = block
|
||||
out.ChainBlockAppended = true
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE group_call_participants
|
||||
SET public_key = NULL, join_block = NULL
|
||||
WHERE call_id = $1 AND user_id = ANY($2)`, req.CallID, e2eTargets); err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, fmt.Errorf("clear conference e2e participants: %w", err)
|
||||
}
|
||||
}
|
||||
if len(mediaTargets) > 0 {
|
||||
rows, err := tx.Query(ctx, `
|
||||
UPDATE group_call_participants
|
||||
SET left_call = TRUE, active_date = $3
|
||||
WHERE call_id = $1 AND user_id = ANY($2) AND NOT left_call
|
||||
RETURNING `+groupCallParticipantColumns, req.CallID, mediaTargets, req.Now)
|
||||
if err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, fmt.Errorf("leave kicked conference participants: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
p, err := scanGroupCallParticipant(rows)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, err
|
||||
}
|
||||
out.ParticipantsChanged = append(out.ParticipantsChanged, p)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, err
|
||||
}
|
||||
if len(out.ParticipantsChanged) > 0 {
|
||||
call, err = bumpGroupCallParticipantsTx(ctx, tx, req.CallID, -len(out.ParticipantsChanged), req.Now)
|
||||
if err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, err
|
||||
}
|
||||
out.Call = call
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.RemoveConferenceCallParticipantsResult{}, fmt.Errorf("commit conference participant removal: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) DiscardGroupCall(ctx context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error) {
|
||||
tx, err := s.begin(ctx, "discard group call")
|
||||
if err != nil {
|
||||
|
|
@ -562,8 +851,23 @@ WHERE call_id = $1 AND NOT left_call`, callID, now); err != nil {
|
|||
return out, fmt.Errorf("reset group call participants: %w", err)
|
||||
}
|
||||
call, err := scanGroupCall(tx.QueryRow(ctx, `
|
||||
UPDATE group_calls SET participants_count = 0, version = version + 1
|
||||
WHERE call_id = $1 RETURNING `+groupCallColumns, callID))
|
||||
UPDATE group_calls
|
||||
SET participants_count = 0,
|
||||
version = version + 1,
|
||||
state = CASE
|
||||
WHEN kind = 'conference' AND state = 'active' THEN 'discarded'
|
||||
ELSE state
|
||||
END,
|
||||
discarded_at = CASE
|
||||
WHEN kind = 'conference' AND state = 'active' THEN $2
|
||||
ELSE discarded_at
|
||||
END,
|
||||
duration = CASE
|
||||
WHEN kind = 'conference' AND state = 'active' THEN GREATEST(0, $2 - created_at)
|
||||
ELSE duration
|
||||
END
|
||||
WHERE call_id = $1
|
||||
RETURNING `+groupCallColumns, callID, now))
|
||||
if err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return out, fmt.Errorf("reset group call version: %w", err)
|
||||
|
|
@ -613,6 +917,13 @@ func nullableJSON(b []byte) any {
|
|||
return b
|
||||
}
|
||||
|
||||
func nullableGroupCallBytes(b []byte) any {
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func parseGroupCallOffset(offset string) (joinDate int, userID int64, ok bool) {
|
||||
if offset == "" {
|
||||
return 0, 0, false
|
||||
|
|
@ -673,3 +984,216 @@ func (s *GroupCallStore) GetParticipantOverride(ctx context.Context, callID, set
|
|||
}
|
||||
return ov, true, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) CreateConferenceInvite(ctx context.Context, invite domain.GroupCallInvite) (domain.GroupCallInvite, error) {
|
||||
if invite.CallID == 0 || invite.InviterUserID == 0 || invite.InviteeUserID == 0 || invite.MessageID == 0 {
|
||||
return domain.GroupCallInvite{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
if invite.Status == "" {
|
||||
invite.Status = domain.GroupCallInvitePending
|
||||
}
|
||||
var status string
|
||||
err := s.db.QueryRow(ctx, `
|
||||
INSERT INTO group_call_invites (call_id, inviter_user_id, invitee_user_id, message_id, status, video, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (call_id, invitee_user_id, message_id) DO UPDATE SET
|
||||
inviter_user_id = EXCLUDED.inviter_user_id,
|
||||
video = EXCLUDED.video
|
||||
RETURNING call_id, inviter_user_id, invitee_user_id, message_id, status, video, created_at, updated_at`,
|
||||
invite.CallID, invite.InviterUserID, invite.InviteeUserID, invite.MessageID, string(invite.Status), invite.Video, invite.CreatedAt, invite.UpdatedAt,
|
||||
).Scan(&invite.CallID, &invite.InviterUserID, &invite.InviteeUserID, &invite.MessageID, &status, &invite.Video, &invite.CreatedAt, &invite.UpdatedAt)
|
||||
if err != nil {
|
||||
return domain.GroupCallInvite{}, fmt.Errorf("create conference invite: %w", err)
|
||||
}
|
||||
invite.Status = domain.GroupCallInviteStatus(status)
|
||||
return invite, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) SetConferenceInviteStatus(ctx context.Context, callID int64, inviteeUserID int64, msgID int, status domain.GroupCallInviteStatus, now int) (domain.GroupCallInvite, bool, error) {
|
||||
var inv domain.GroupCallInvite
|
||||
var newStatus string
|
||||
err := s.db.QueryRow(ctx, `
|
||||
UPDATE group_call_invites
|
||||
SET status = $4, updated_at = $5
|
||||
WHERE call_id = $1 AND invitee_user_id = $2 AND message_id = $3
|
||||
RETURNING call_id, inviter_user_id, invitee_user_id, message_id, status, video, created_at, updated_at`,
|
||||
callID, inviteeUserID, msgID, string(status), now,
|
||||
).Scan(&inv.CallID, &inv.InviterUserID, &inv.InviteeUserID, &inv.MessageID, &newStatus, &inv.Video, &inv.CreatedAt, &inv.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.GroupCallInvite{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.GroupCallInvite{}, false, fmt.Errorf("set conference invite status: %w", err)
|
||||
}
|
||||
inv.Status = domain.GroupCallInviteStatus(newStatus)
|
||||
return inv, true, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) ListConferenceRecipientUserIDs(ctx context.Context, callID int64) ([]int64, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
WITH c AS (
|
||||
SELECT creator_user_id, state FROM group_calls WHERE call_id = $1
|
||||
)
|
||||
SELECT creator_user_id FROM c
|
||||
UNION
|
||||
SELECT p.user_id
|
||||
FROM group_call_participants p CROSS JOIN c
|
||||
WHERE p.call_id = $1 AND (c.state <> 'active' OR NOT p.left_call)
|
||||
UNION
|
||||
SELECT i.inviter_user_id
|
||||
FROM group_call_invites i CROSS JOIN c
|
||||
WHERE i.call_id = $1 AND (c.state <> 'active' OR i.status IN ('pending', 'accepted'))
|
||||
UNION
|
||||
SELECT i.invitee_user_id
|
||||
FROM group_call_invites i CROSS JOIN c
|
||||
WHERE i.call_id = $1 AND (c.state <> 'active' OR i.status IN ('pending', 'accepted'))
|
||||
ORDER BY 1`, callID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list conference recipients: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id != 0 {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) AppendGroupCallChainBlock(ctx context.Context, block domain.GroupCallChainBlock) (domain.GroupCallChainBlock, error) {
|
||||
if block.CallID == 0 || len(block.Block) == 0 {
|
||||
return domain.GroupCallChainBlock{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
tx, err := s.begin(ctx, "append group call chain block")
|
||||
if err != nil {
|
||||
return domain.GroupCallChainBlock{}, err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
call, err := lockGroupCallTx(ctx, tx, block.CallID)
|
||||
if err != nil {
|
||||
return domain.GroupCallChainBlock{}, err
|
||||
}
|
||||
if !call.Conference() {
|
||||
return domain.GroupCallChainBlock{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
block, err = appendGroupCallChainBlockTx(ctx, tx, block)
|
||||
if err != nil {
|
||||
return domain.GroupCallChainBlock{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.GroupCallChainBlock{}, fmt.Errorf("commit append group call chain block: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return block, nil
|
||||
}
|
||||
|
||||
func appendGroupCallChainBlockTx(ctx context.Context, tx pgx.Tx, block domain.GroupCallChainBlock) (domain.GroupCallChainBlock, error) {
|
||||
if block.CallID == 0 || len(block.Block) == 0 {
|
||||
return domain.GroupCallChainBlock{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
var existing domain.GroupCallChainBlock
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT call_id, sub_chain_id, block_offset, author_user_id, block, created_at
|
||||
FROM group_call_chain_blocks
|
||||
WHERE call_id = $1 AND sub_chain_id = $2 AND block = $3
|
||||
ORDER BY block_offset ASC
|
||||
LIMIT 1`, block.CallID, block.SubChainID, block.Block).Scan(
|
||||
&existing.CallID, &existing.SubChainID, &existing.Offset, &existing.AuthorUserID, &existing.Block, &existing.CreatedAt,
|
||||
)
|
||||
if err == nil {
|
||||
return domain.GroupCallChainBlock{}, domain.ErrConferenceChainInvalid
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.GroupCallChainBlock{}, fmt.Errorf("get existing group call chain block: %w", err)
|
||||
}
|
||||
var nextOffset int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(MAX(block_offset) + 1, 0)
|
||||
FROM group_call_chain_blocks
|
||||
WHERE call_id = $1 AND sub_chain_id = $2`, block.CallID, block.SubChainID).Scan(&nextOffset); err != nil {
|
||||
return domain.GroupCallChainBlock{}, fmt.Errorf("next chain block offset: %w", err)
|
||||
}
|
||||
if block.Offset < 0 {
|
||||
block.Offset = nextOffset
|
||||
}
|
||||
if block.Offset != nextOffset {
|
||||
return domain.GroupCallChainBlock{}, domain.ErrConferenceChainInvalid
|
||||
}
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO group_call_chain_blocks (call_id, sub_chain_id, block_offset, author_user_id, block, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING call_id, sub_chain_id, block_offset, author_user_id, block, created_at`,
|
||||
block.CallID, block.SubChainID, block.Offset, block.AuthorUserID, block.Block, block.CreatedAt,
|
||||
).Scan(&block.CallID, &block.SubChainID, &block.Offset, &block.AuthorUserID, &block.Block, &block.CreatedAt)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return domain.GroupCallChainBlock{}, domain.ErrConferenceChainInvalid
|
||||
}
|
||||
return domain.GroupCallChainBlock{}, fmt.Errorf("append group call chain block: %w", err)
|
||||
}
|
||||
return block, nil
|
||||
}
|
||||
|
||||
func (s *GroupCallStore) ListGroupCallChainBlocks(ctx context.Context, callID int64, subChainID, offset, limit int) (domain.GroupCallChainBlockPage, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
if offset == domain.GroupCallChainBlockLatestOffset {
|
||||
var block domain.GroupCallChainBlock
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT call_id, sub_chain_id, block_offset, author_user_id, block, created_at
|
||||
FROM group_call_chain_blocks
|
||||
WHERE call_id = $1 AND sub_chain_id = $2
|
||||
ORDER BY block_offset DESC
|
||||
LIMIT 1`, callID, subChainID).Scan(&block.CallID, &block.SubChainID, &block.Offset, &block.AuthorUserID, &block.Block, &block.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.GroupCallChainBlockPage{NextOffset: 0}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.GroupCallChainBlockPage{}, fmt.Errorf("get latest group call chain block: %w", err)
|
||||
}
|
||||
return domain.GroupCallChainBlockPage{
|
||||
Blocks: []domain.GroupCallChainBlock{block},
|
||||
NextOffset: block.Offset + 1,
|
||||
}, nil
|
||||
}
|
||||
if offset < 0 {
|
||||
return domain.GroupCallChainBlockPage{}, domain.ErrGroupCallInvalid
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT call_id, sub_chain_id, block_offset, author_user_id, block, created_at
|
||||
FROM group_call_chain_blocks
|
||||
WHERE call_id = $1 AND sub_chain_id = $2 AND block_offset >= $3
|
||||
ORDER BY block_offset ASC
|
||||
LIMIT $4`, callID, subChainID, offset, limit)
|
||||
if err != nil {
|
||||
return domain.GroupCallChainBlockPage{}, fmt.Errorf("list group call chain blocks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
page := domain.GroupCallChainBlockPage{NextOffset: offset}
|
||||
for rows.Next() {
|
||||
var block domain.GroupCallChainBlock
|
||||
if err := rows.Scan(&block.CallID, &block.SubChainID, &block.Offset, &block.AuthorUserID, &block.Block, &block.CreatedAt); err != nil {
|
||||
return domain.GroupCallChainBlockPage{}, err
|
||||
}
|
||||
page.Blocks = append(page.Blocks, block)
|
||||
page.NextOffset = block.Offset + 1
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return domain.GroupCallChainBlockPage{}, err
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ package storetest
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -36,6 +38,9 @@ func RunGroupCallStoreContract(t *testing.T, factory GroupCallStoreFactory) {
|
|||
t.Run("UpdateParticipant", func(t *testing.T) { contractUpdateParticipant(t, factory) })
|
||||
t.Run("ResetAllParticipants", func(t *testing.T) { contractReset(t, factory) })
|
||||
t.Run("JoinVideoStateLifecycle", func(t *testing.T) { contractJoinVideoState(t, factory) })
|
||||
t.Run("ConferenceChainBlocks", func(t *testing.T) { contractConferenceChainBlocks(t, factory) })
|
||||
t.Run("ConferenceRecipientsTerminalAccess", func(t *testing.T) { contractConferenceRecipientsTerminalAccess(t, factory) })
|
||||
t.Run("ConferenceEmptyDiscards", func(t *testing.T) { contractConferenceEmptyDiscards(t, factory) })
|
||||
}
|
||||
|
||||
func newContractCall(t *testing.T, st store.GroupCallStore, channelID, id int64) domain.GroupCall {
|
||||
|
|
@ -134,7 +139,7 @@ func contractSSRC(t *testing.T, factory GroupCallStoreFactory) {
|
|||
}
|
||||
|
||||
// contractJoinVideoState:join 携带 VideoJSON 整体替换、rejoin 清空 presentation
|
||||
//(主连接 rejoin 后客户端会重发 joinGroupCallPresentation,旧屏幕登记必须作废)。
|
||||
// (主连接 rejoin 后客户端会重发 joinGroupCallPresentation,旧屏幕登记必须作废)。
|
||||
func contractJoinVideoState(t *testing.T, factory GroupCallStoreFactory) {
|
||||
st, channelID := factory(t)
|
||||
ctx := context.Background()
|
||||
|
|
@ -328,6 +333,157 @@ func contractReset(t *testing.T, factory GroupCallStoreFactory) {
|
|||
}
|
||||
}
|
||||
|
||||
func contractConferenceChainBlocks(t *testing.T, factory GroupCallStoreFactory) {
|
||||
st, channelID := factory(t)
|
||||
ctx := context.Background()
|
||||
now := baseNow()
|
||||
slug := fmt.Sprintf("contract-chain-%d", channelID)
|
||||
call, err := st.CreateConferenceCall(ctx, domain.GroupCall{
|
||||
ID: channelID*100 + 51, AccessHash: channelID*100 + 58, CreatorUserID: 1,
|
||||
InviteSlug: slug, InviteLink: "https://telesrv.net/call/" + slug + "?slug=" + slug,
|
||||
RandomID: channelID*100 + 51, CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference call: %v", err)
|
||||
}
|
||||
firstBlock := []byte("same-chain-block")
|
||||
first, err := st.AppendGroupCallChainBlock(ctx, domain.GroupCallChainBlock{
|
||||
CallID: call.ID, SubChainID: 0, Offset: -1, Block: firstBlock, CreatedAt: now,
|
||||
})
|
||||
if err != nil || first.Offset != 0 {
|
||||
t.Fatalf("append first chain block = %+v err=%v", first, err)
|
||||
}
|
||||
dup, err := st.AppendGroupCallChainBlock(ctx, domain.GroupCallChainBlock{
|
||||
CallID: call.ID, SubChainID: 0, Offset: -1, Block: append([]byte(nil), firstBlock...), CreatedAt: now + 1,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrConferenceChainInvalid) {
|
||||
t.Fatalf("append duplicate chain block = %+v err=%v, want ErrConferenceChainInvalid", dup, err)
|
||||
}
|
||||
secondBlock := []byte("next-chain-block")
|
||||
second, err := st.AppendGroupCallChainBlock(ctx, domain.GroupCallChainBlock{
|
||||
CallID: call.ID, SubChainID: 0, Offset: -1, Block: secondBlock, CreatedAt: now + 2,
|
||||
})
|
||||
if err != nil || second.Offset != 1 {
|
||||
t.Fatalf("append second chain block = %+v err=%v", second, err)
|
||||
}
|
||||
if _, err := st.AppendGroupCallChainBlock(ctx, domain.GroupCallChainBlock{
|
||||
CallID: call.ID, SubChainID: 0, Offset: 0, Block: []byte("stale-offset-block"), CreatedAt: now + 3,
|
||||
}); !errors.Is(err, domain.ErrConferenceChainInvalid) {
|
||||
t.Fatalf("append stale offset chain block err=%v, want ErrConferenceChainInvalid", err)
|
||||
}
|
||||
page, err := st.ListGroupCallChainBlocks(ctx, call.ID, 0, 0, 10)
|
||||
if err != nil || page.NextOffset != 2 || len(page.Blocks) != 2 {
|
||||
t.Fatalf("list chain blocks = %+v err=%v", page, err)
|
||||
}
|
||||
latest, err := st.ListGroupCallChainBlocks(ctx, call.ID, 0, domain.GroupCallChainBlockLatestOffset, 1)
|
||||
if err != nil || latest.NextOffset != 2 || len(latest.Blocks) != 1 || string(latest.Blocks[0].Block) != string(secondBlock) {
|
||||
t.Fatalf("latest chain block = %+v err=%v", latest, err)
|
||||
}
|
||||
}
|
||||
|
||||
func contractConferenceRecipientsTerminalAccess(t *testing.T, factory GroupCallStoreFactory) {
|
||||
st, channelID := factory(t)
|
||||
ctx := context.Background()
|
||||
now := baseNow()
|
||||
slug := fmt.Sprintf("contract-recipient-%d", channelID)
|
||||
call, err := st.CreateConferenceCall(ctx, domain.GroupCall{
|
||||
ID: channelID*100 + 61, AccessHash: channelID*100 + 68, CreatorUserID: 1,
|
||||
InviteSlug: slug, InviteLink: "https://telesrv.net/call/" + slug + "?slug=" + slug,
|
||||
RandomID: channelID*100 + 61, CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference call: %v", err)
|
||||
}
|
||||
join(t, st, call.ID, 2, 7102, now+1)
|
||||
join(t, st, call.ID, 3, 7103, now+2)
|
||||
if _, err := st.LeaveGroupCall(ctx, call.ID, 3, now+3); err != nil {
|
||||
t.Fatalf("leave historical participant: %v", err)
|
||||
}
|
||||
if _, err := st.CreateConferenceInvite(ctx, domain.GroupCallInvite{
|
||||
CallID: call.ID, InviterUserID: 1, InviteeUserID: 4, MessageID: 401,
|
||||
Status: domain.GroupCallInvitePending, CreatedAt: now + 4,
|
||||
}); err != nil {
|
||||
t.Fatalf("create pending invite: %v", err)
|
||||
}
|
||||
if _, err := st.CreateConferenceInvite(ctx, domain.GroupCallInvite{
|
||||
CallID: call.ID, InviterUserID: 1, InviteeUserID: 5, MessageID: 501,
|
||||
Status: domain.GroupCallInviteDeclined, CreatedAt: now + 5, UpdatedAt: now + 5,
|
||||
}); err != nil {
|
||||
t.Fatalf("create declined invite: %v", err)
|
||||
}
|
||||
activeRecipients, err := st.ListConferenceRecipientUserIDs(ctx, call.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("active recipients: %v", err)
|
||||
}
|
||||
if want := []int64{1, 2, 4}; !reflect.DeepEqual(activeRecipients, want) {
|
||||
t.Fatalf("active recipients = %v, want %v", activeRecipients, want)
|
||||
}
|
||||
if _, _, err := st.DiscardGroupCall(ctx, call.ID, now+10); err != nil {
|
||||
t.Fatalf("discard conference: %v", err)
|
||||
}
|
||||
discardedRecipients, err := st.ListConferenceRecipientUserIDs(ctx, call.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("discarded recipients: %v", err)
|
||||
}
|
||||
if want := []int64{1, 2, 3, 4, 5}; !reflect.DeepEqual(discardedRecipients, want) {
|
||||
t.Fatalf("discarded recipients = %v, want %v", discardedRecipients, want)
|
||||
}
|
||||
}
|
||||
|
||||
func contractConferenceEmptyDiscards(t *testing.T, factory GroupCallStoreFactory) {
|
||||
st, channelID := factory(t)
|
||||
ctx := context.Background()
|
||||
now := baseNow()
|
||||
call, err := st.CreateConferenceCall(ctx, domain.GroupCall{
|
||||
ID: channelID*100 + 71, AccessHash: channelID*100 + 78, CreatorUserID: 1,
|
||||
InviteSlug: fmt.Sprintf("contract-empty-%d", channelID),
|
||||
InviteLink: fmt.Sprintf("https://telesrv.net/call/contract-empty-%d?slug=contract-empty-%d", channelID, channelID),
|
||||
RandomID: channelID*100 + 71,
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create conference call: %v", err)
|
||||
}
|
||||
join(t, st, call.ID, 1, 7201, now+1)
|
||||
join(t, st, call.ID, 2, 7202, now+2)
|
||||
firstLeave, err := st.LeaveGroupCall(ctx, call.ID, 2, now+3)
|
||||
if err != nil {
|
||||
t.Fatalf("first leave conference: %v", err)
|
||||
}
|
||||
if !firstLeave.Call.Active() || firstLeave.Call.ParticipantsCount != 1 {
|
||||
t.Fatalf("first leave call = %+v, want still active with one participant", firstLeave.Call)
|
||||
}
|
||||
lastLeave, err := st.LeaveGroupCall(ctx, call.ID, 1, now+4)
|
||||
if err != nil {
|
||||
t.Fatalf("last leave conference: %v", err)
|
||||
}
|
||||
if lastLeave.Call.Active() || lastLeave.Call.ParticipantsCount != 0 || lastLeave.Call.DiscardedAt != now+4 {
|
||||
t.Fatalf("last leave call = %+v, want discarded empty conference", lastLeave.Call)
|
||||
}
|
||||
if _, err := st.JoinGroupCall(ctx, domain.JoinGroupCallRequest{CallID: call.ID, UserID: 3, SSRC: 7203, Now: now + 5}); !errors.Is(err, domain.ErrGroupCallDiscarded) {
|
||||
t.Fatalf("join empty discarded conference err = %v, want ErrGroupCallDiscarded", err)
|
||||
}
|
||||
|
||||
resetCall, err := st.CreateConferenceCall(ctx, domain.GroupCall{
|
||||
ID: channelID*100 + 81, AccessHash: channelID*100 + 88, CreatorUserID: 1,
|
||||
InviteSlug: fmt.Sprintf("contract-reset-empty-%d", channelID),
|
||||
InviteLink: fmt.Sprintf("https://telesrv.net/call/contract-reset-empty-%d?slug=contract-reset-empty-%d", channelID, channelID),
|
||||
RandomID: channelID*100 + 81,
|
||||
CreatedAt: now + 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create reset conference call: %v", err)
|
||||
}
|
||||
join(t, st, resetCall.ID, 1, 7301, now+11)
|
||||
reset, err := st.ResetAllParticipants(ctx, now+12)
|
||||
if err != nil || len(reset) != 1 {
|
||||
t.Fatalf("reset conferences = %+v err=%v, want one affected call", reset, err)
|
||||
}
|
||||
if reset[0].ID != resetCall.ID || reset[0].Active() || reset[0].ParticipantsCount != 0 {
|
||||
t.Fatalf("reset conference call = %+v, want discarded empty conference", reset[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 以下为 M2 契约:per-viewer overrides 与举手序号(追加进 RunGroupCallStoreContract
|
||||
// 之外单独可调,避免改既有签名——两实现测试各自调用 RunGroupCallStoreM2Contract)。
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue