feat: sync recent call and channel fixes

This commit is contained in:
A 2026-07-01 14:34:59 +08:00
parent e5e0080216
commit 866a87583e
65 changed files with 6680 additions and 229 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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