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

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