feat: sync recent call and channel fixes
This commit is contained in:
parent
e5e0080216
commit
866a87583e
65 changed files with 6680 additions and 229 deletions
24
deploy/migrations/0037_conference_calls.down.sql
Normal file
24
deploy/migrations/0037_conference_calls.down.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
DROP TABLE IF EXISTS public.group_call_chain_blocks;
|
||||||
|
DROP TABLE IF EXISTS public.group_call_invites;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS public.group_calls_conference_random_uniq;
|
||||||
|
DROP INDEX IF EXISTS public.group_calls_invite_slug_uniq;
|
||||||
|
DROP INDEX IF EXISTS public.group_calls_active_channel_uniq;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS group_calls_active_channel_uniq
|
||||||
|
ON public.group_calls USING btree (channel_id)
|
||||||
|
WHERE (state = 'active'::text);
|
||||||
|
|
||||||
|
ALTER TABLE public.group_call_participants
|
||||||
|
DROP COLUMN IF EXISTS join_block,
|
||||||
|
DROP COLUMN IF EXISTS public_key;
|
||||||
|
|
||||||
|
ALTER TABLE public.group_calls
|
||||||
|
DROP CONSTRAINT IF EXISTS group_calls_kind_check;
|
||||||
|
|
||||||
|
ALTER TABLE public.group_calls
|
||||||
|
DROP COLUMN IF EXISTS migrated_from_phone_call_id,
|
||||||
|
DROP COLUMN IF EXISTS random_id,
|
||||||
|
DROP COLUMN IF EXISTS invite_link,
|
||||||
|
DROP COLUMN IF EXISTS invite_slug,
|
||||||
|
DROP COLUMN IF EXISTS kind;
|
||||||
58
deploy/migrations/0037_conference_calls.up.sql
Normal file
58
deploy/migrations/0037_conference_calls.up.sql
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
ALTER TABLE public.group_calls
|
||||||
|
ADD COLUMN IF NOT EXISTS kind text DEFAULT 'channel'::text NOT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS invite_slug text DEFAULT ''::text NOT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS invite_link text DEFAULT ''::text NOT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS random_id bigint DEFAULT 0 NOT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS migrated_from_phone_call_id bigint DEFAULT 0 NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE public.group_calls
|
||||||
|
DROP CONSTRAINT IF EXISTS group_calls_kind_check;
|
||||||
|
|
||||||
|
ALTER TABLE public.group_calls
|
||||||
|
ADD CONSTRAINT group_calls_kind_check CHECK (kind = ANY (ARRAY['channel'::text, 'conference'::text]));
|
||||||
|
|
||||||
|
ALTER TABLE public.group_call_participants
|
||||||
|
ADD COLUMN IF NOT EXISTS public_key bytea,
|
||||||
|
ADD COLUMN IF NOT EXISTS join_block bytea;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS public.group_calls_active_channel_uniq;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS group_calls_active_channel_uniq
|
||||||
|
ON public.group_calls USING btree (channel_id)
|
||||||
|
WHERE ((state = 'active'::text) AND (channel_id <> 0));
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS group_calls_invite_slug_uniq
|
||||||
|
ON public.group_calls USING btree (invite_slug)
|
||||||
|
WHERE (invite_slug <> ''::text);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS group_calls_conference_random_uniq
|
||||||
|
ON public.group_calls USING btree (creator_user_id, random_id)
|
||||||
|
WHERE ((kind = 'conference'::text) AND (random_id <> 0));
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.group_call_invites (
|
||||||
|
call_id bigint NOT NULL REFERENCES public.group_calls(call_id) ON DELETE CASCADE,
|
||||||
|
inviter_user_id bigint NOT NULL,
|
||||||
|
invitee_user_id bigint NOT NULL,
|
||||||
|
message_id integer NOT NULL,
|
||||||
|
status text DEFAULT 'pending'::text NOT NULL,
|
||||||
|
video boolean DEFAULT false NOT NULL,
|
||||||
|
created_at integer NOT NULL,
|
||||||
|
updated_at integer DEFAULT 0 NOT NULL,
|
||||||
|
CONSTRAINT group_call_invites_status_check CHECK (status = ANY (ARRAY['pending'::text, 'accepted'::text, 'declined'::text, 'missed'::text, 'revoked'::text])),
|
||||||
|
CONSTRAINT group_call_invites_pkey PRIMARY KEY (call_id, invitee_user_id, message_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS group_call_invites_msg_uniq
|
||||||
|
ON public.group_call_invites USING btree (invitee_user_id, message_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS group_call_invites_call_idx
|
||||||
|
ON public.group_call_invites USING btree (call_id, invitee_user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.group_call_chain_blocks (
|
||||||
|
call_id bigint NOT NULL REFERENCES public.group_calls(call_id) ON DELETE CASCADE,
|
||||||
|
sub_chain_id integer DEFAULT 0 NOT NULL,
|
||||||
|
block_offset integer NOT NULL,
|
||||||
|
block bytea NOT NULL,
|
||||||
|
created_at integer NOT NULL,
|
||||||
|
CONSTRAINT group_call_chain_blocks_pkey PRIMARY KEY (call_id, sub_chain_id, block_offset)
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- Data repair only. Do not remove ManageRanks on rollback because later user edits
|
||||||
|
-- may have intentionally granted it.
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
UPDATE channel_members m
|
||||||
|
SET admin_rights = m.admin_rights || '{"ManageRanks": true}'::jsonb
|
||||||
|
FROM channels c
|
||||||
|
WHERE c.id = m.channel_id
|
||||||
|
AND NOT c.deleted
|
||||||
|
AND c.megagroup
|
||||||
|
AND NOT c.broadcast
|
||||||
|
AND m.status = 'active'
|
||||||
|
AND (
|
||||||
|
m.role = 'creator'
|
||||||
|
OR (
|
||||||
|
m.role = 'admin'
|
||||||
|
AND COALESCE((m.admin_rights ->> 'ChangeInfo')::boolean, false)
|
||||||
|
AND COALESCE((m.admin_rights ->> 'DeleteMessages')::boolean, false)
|
||||||
|
AND COALESCE((m.admin_rights ->> 'BanUsers')::boolean, false)
|
||||||
|
AND COALESCE((m.admin_rights ->> 'InviteUsers')::boolean, false)
|
||||||
|
AND COALESCE((m.admin_rights ->> 'PinMessages')::boolean, false)
|
||||||
|
AND COALESCE((m.admin_rights ->> 'AddAdmins')::boolean, false)
|
||||||
|
AND COALESCE((m.admin_rights ->> 'ManageCall')::boolean, false)
|
||||||
|
)
|
||||||
|
);
|
||||||
4
deploy/migrations/0039_group_call_chain_author.down.sql
Normal file
4
deploy/migrations/0039_group_call_chain_author.down.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
DROP INDEX IF EXISTS public.group_call_chain_blocks_author_idx;
|
||||||
|
|
||||||
|
ALTER TABLE public.group_call_chain_blocks
|
||||||
|
DROP COLUMN IF EXISTS author_user_id;
|
||||||
5
deploy/migrations/0039_group_call_chain_author.up.sql
Normal file
5
deploy/migrations/0039_group_call_chain_author.up.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
ALTER TABLE public.group_call_chain_blocks
|
||||||
|
ADD COLUMN IF NOT EXISTS author_user_id bigint DEFAULT 0 NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS group_call_chain_blocks_author_idx
|
||||||
|
ON public.group_call_chain_blocks USING btree (call_id, sub_chain_id, author_user_id, block_offset);
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
-- No-op: discarded empty conference calls are terminal cleanup records.
|
||||||
31
deploy/migrations/0040_discard_empty_conferences.up.sql
Normal file
31
deploy/migrations/0040_discard_empty_conferences.up.sql
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
WITH stale AS (
|
||||||
|
SELECT c.call_id
|
||||||
|
FROM public.group_calls c
|
||||||
|
WHERE c.kind = 'conference'::text
|
||||||
|
AND c.state = 'active'::text
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.group_call_participants p
|
||||||
|
WHERE p.call_id = c.call_id
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.group_call_participants p
|
||||||
|
WHERE p.call_id = c.call_id
|
||||||
|
AND NOT p.left_call
|
||||||
|
)
|
||||||
|
)
|
||||||
|
UPDATE public.group_calls c
|
||||||
|
SET state = 'discarded'::text,
|
||||||
|
discarded_at = CASE
|
||||||
|
WHEN c.discarded_at = 0 THEN EXTRACT(EPOCH FROM now())::integer
|
||||||
|
ELSE c.discarded_at
|
||||||
|
END,
|
||||||
|
duration = CASE
|
||||||
|
WHEN c.duration = 0 THEN GREATEST(0, EXTRACT(EPOCH FROM now())::integer - c.created_at)
|
||||||
|
ELSE c.duration
|
||||||
|
END,
|
||||||
|
participants_count = 0,
|
||||||
|
version = c.version + 1
|
||||||
|
FROM stale
|
||||||
|
WHERE c.call_id = stale.call_id;
|
||||||
|
|
@ -53,6 +53,15 @@ func (c *participantsReadModelCache) getOrLoad(ctx context.Context, key particip
|
||||||
return c.cache.GetOrLoadVersioned(ctx, key, hash, load)
|
return c.cache.GetOrLoadVersioned(ctx, key, hash, load)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *participantsReadModelCache) invalidateChannel(channelID int64) {
|
||||||
|
if c == nil || channelID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.cache.InvalidateWhere(func(key participantsCacheKey) bool {
|
||||||
|
return key.channelID == channelID
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) cachedParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
|
func (s *Service) cachedParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
|
||||||
filter, offset, limit = normalizeParticipantsRequest(filter, offset, limit)
|
filter, offset, limit = normalizeParticipantsRequest(filter, offset, limit)
|
||||||
if s.participantCache == nil || s.versions == nil {
|
if s.participantCache == nil || s.versions == nil {
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,7 @@ func (s *Service) InviteToChannel(ctx context.Context, userID, channelID int64,
|
||||||
res, err := s.channels.InviteToChannel(ctx, channelID, userID, userIDs, date)
|
res, err := s.channels.InviteToChannel(ctx, channelID, userID, userIDs, date)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s.invalidateActiveChannelIDs(activeMembershipUserIDsFromMembers(0, res.Members)...)
|
s.invalidateActiveChannelIDs(activeMembershipUserIDsFromMembers(0, res.Members)...)
|
||||||
|
s.participantCache.invalidateChannel(channelID)
|
||||||
}
|
}
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
|
|
@ -249,6 +250,7 @@ func (s *Service) JoinChannel(ctx context.Context, userID, channelID int64, date
|
||||||
res, err := s.channels.JoinChannel(ctx, channelID, userID, date)
|
res, err := s.channels.JoinChannel(ctx, channelID, userID, date)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s.invalidateActiveChannelIDs(userID)
|
s.invalidateActiveChannelIDs(userID)
|
||||||
|
s.participantCache.invalidateChannel(channelID)
|
||||||
}
|
}
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
|
|
@ -261,6 +263,7 @@ func (s *Service) LeaveChannel(ctx context.Context, userID, channelID int64, dat
|
||||||
res, err := s.channels.LeaveChannel(ctx, channelID, userID, date)
|
res, err := s.channels.LeaveChannel(ctx, channelID, userID, date)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s.invalidateActiveChannelIDs(userID)
|
s.invalidateActiveChannelIDs(userID)
|
||||||
|
s.participantCache.invalidateChannel(channelID)
|
||||||
}
|
}
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
|
|
@ -318,7 +321,31 @@ func (s *Service) EditAdmin(ctx context.Context, userID int64, req domain.EditCh
|
||||||
if req.UserID != userID || req.ChannelID == 0 || req.MemberID == 0 || len(req.Rank) > domain.MaxChannelAdminRankLength {
|
if req.UserID != userID || req.ChannelID == 0 || req.MemberID == 0 || len(req.Rank) > domain.MaxChannelAdminRankLength {
|
||||||
return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
|
return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
|
||||||
}
|
}
|
||||||
return s.channels.EditChannelAdmin(ctx, req)
|
res, err := s.channels.EditChannelAdmin(ctx, req)
|
||||||
|
if err == nil {
|
||||||
|
s.invalidateActiveChannelIDs(req.MemberID)
|
||||||
|
s.participantCache.invalidateChannel(req.ChannelID)
|
||||||
|
}
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransferOwnership transfers a channel/supergroup to another active member.
|
||||||
|
func (s *Service) TransferOwnership(ctx context.Context, userID int64, req domain.TransferChannelOwnershipRequest) (domain.TransferChannelOwnershipResult, error) {
|
||||||
|
if s == nil || s.channels == nil || userID == 0 {
|
||||||
|
return domain.TransferChannelOwnershipResult{}, domain.ErrChannelInvalid
|
||||||
|
}
|
||||||
|
if req.UserID == 0 {
|
||||||
|
req.UserID = userID
|
||||||
|
}
|
||||||
|
if req.UserID != userID || req.ChannelID == 0 || req.NewOwnerID == 0 || req.NewOwnerID == userID {
|
||||||
|
return domain.TransferChannelOwnershipResult{}, domain.ErrChannelInvalid
|
||||||
|
}
|
||||||
|
res, err := s.channels.TransferChannelOwnership(ctx, req)
|
||||||
|
if err == nil {
|
||||||
|
s.invalidateActiveChannelIDs(req.UserID, req.NewOwnerID)
|
||||||
|
s.participantCache.invalidateChannel(req.ChannelID)
|
||||||
|
}
|
||||||
|
return res, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditMemberRank sets or clears a participant's member tag without touching
|
// EditMemberRank sets or clears a participant's member tag without touching
|
||||||
|
|
@ -333,7 +360,11 @@ func (s *Service) EditMemberRank(ctx context.Context, userID int64, req domain.E
|
||||||
if req.UserID != userID || req.ChannelID == 0 || req.MemberID == 0 || len(req.Rank) > domain.MaxChannelAdminRankLength {
|
if req.UserID != userID || req.ChannelID == 0 || req.MemberID == 0 || len(req.Rank) > domain.MaxChannelAdminRankLength {
|
||||||
return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
|
return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
|
||||||
}
|
}
|
||||||
return s.channels.EditChannelMemberRank(ctx, req)
|
res, err := s.channels.EditChannelMemberRank(ctx, req)
|
||||||
|
if err == nil {
|
||||||
|
s.participantCache.invalidateChannel(req.ChannelID)
|
||||||
|
}
|
||||||
|
return res, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditBanned edits a participant's banned rights.
|
// EditBanned edits a participant's banned rights.
|
||||||
|
|
@ -350,6 +381,7 @@ func (s *Service) EditBanned(ctx context.Context, userID int64, req domain.EditC
|
||||||
res, err := s.channels.EditChannelBanned(ctx, req)
|
res, err := s.channels.EditChannelBanned(ctx, req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s.invalidateActiveChannelIDs(req.Participant.ID)
|
s.invalidateActiveChannelIDs(req.Participant.ID)
|
||||||
|
s.participantCache.invalidateChannel(req.ChannelID)
|
||||||
}
|
}
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -568,6 +568,106 @@ func TestGetParticipantsCachesPageByCompositeReadModelHash(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetParticipantsCacheInvalidatesAfterAdminMutation(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
const ownerID int64 = 1001
|
||||||
|
base := &countingChannelStore{ChannelStore: memory.NewChannelStore()}
|
||||||
|
service := NewService(base)
|
||||||
|
created, err := service.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{
|
||||||
|
Title: "Admin Cache",
|
||||||
|
Megagroup: true,
|
||||||
|
MemberUserIDs: []int64{1002},
|
||||||
|
Date: 1700004103,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateChannel: %v", err)
|
||||||
|
}
|
||||||
|
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}
|
||||||
|
versions := &fakeReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||||
|
{Model: readmodel.ModelChannelBase, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}: 201,
|
||||||
|
{Model: readmodel.ModelChannelParticipants, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}: 202,
|
||||||
|
{Model: readmodel.ModelChannelMember, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 203,
|
||||||
|
{Model: readmodel.ModelContactAccount, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}: 204,
|
||||||
|
}}
|
||||||
|
service = NewService(base, WithReadModelVersions(versions))
|
||||||
|
|
||||||
|
filter := domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsAdmins}
|
||||||
|
before, err := service.GetParticipants(ctx, ownerID, created.Channel.ID, filter, 0, 20)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first admins: %v", err)
|
||||||
|
}
|
||||||
|
if len(before.Participants) != 1 || before.Participants[0].UserID != ownerID {
|
||||||
|
t.Fatalf("first admins = %+v, want only creator", before.Participants)
|
||||||
|
}
|
||||||
|
if _, err := service.GetParticipants(ctx, ownerID, created.Channel.ID, filter, 0, 20); err != nil {
|
||||||
|
t.Fatalf("cached admins: %v", err)
|
||||||
|
}
|
||||||
|
if base.getParticipantCalls != 1 {
|
||||||
|
t.Fatalf("GetParticipants calls before mutation = %d, want 1", base.getParticipantCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := service.EditAdmin(ctx, ownerID, domain.EditChannelAdminRequest{
|
||||||
|
ChannelID: created.Channel.ID,
|
||||||
|
MemberID: 1002,
|
||||||
|
AdminRights: domain.ChannelAdminRights{InviteUsers: true},
|
||||||
|
Date: 1700004104,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("EditAdmin: %v", err)
|
||||||
|
}
|
||||||
|
after, err := service.GetParticipants(ctx, ownerID, created.Channel.ID, filter, 0, 20)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admins after mutation: %v", err)
|
||||||
|
}
|
||||||
|
if base.getParticipantCalls != 2 {
|
||||||
|
t.Fatalf("GetParticipants calls after mutation = %d, want 2", base.getParticipantCalls)
|
||||||
|
}
|
||||||
|
if len(after.Participants) != 2 || after.Participants[1].UserID != 1002 || after.Participants[1].Role != domain.ChannelRoleAdmin {
|
||||||
|
t.Fatalf("admins after mutation = %+v, want fresh promoted admin", after.Participants)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFullMegagroupAdminGrantFillsManageRanks(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
service := NewService(memory.NewChannelStore())
|
||||||
|
created, err := service.CreateChannel(ctx, 1001, domain.CreateChannelRequest{
|
||||||
|
Title: "Full Admin",
|
||||||
|
Megagroup: true,
|
||||||
|
MemberUserIDs: []int64{1002},
|
||||||
|
Date: 1700004200,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateChannel: %v", err)
|
||||||
|
}
|
||||||
|
rights := domain.ChannelAdminRights{
|
||||||
|
ChangeInfo: true,
|
||||||
|
DeleteMessages: true,
|
||||||
|
BanUsers: true,
|
||||||
|
InviteUsers: true,
|
||||||
|
PinMessages: true,
|
||||||
|
AddAdmins: true,
|
||||||
|
ManageCall: true,
|
||||||
|
}
|
||||||
|
edited, err := service.EditAdmin(ctx, 1001, domain.EditChannelAdminRequest{
|
||||||
|
ChannelID: created.Channel.ID,
|
||||||
|
MemberID: 1002,
|
||||||
|
AdminRights: rights,
|
||||||
|
Date: 1700004201,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EditAdmin full rights: %v", err)
|
||||||
|
}
|
||||||
|
if !edited.Participant.AdminRights.ManageRanks {
|
||||||
|
t.Fatalf("edited admin rights = %+v, want ManageRanks for full megagroup admin", edited.Participant.AdminRights)
|
||||||
|
}
|
||||||
|
member, err := service.GetParticipant(ctx, 1001, created.Channel.ID, 1002)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetParticipant: %v", err)
|
||||||
|
}
|
||||||
|
if !member.AdminRights.ManageRanks {
|
||||||
|
t.Fatalf("stored admin rights = %+v, want ManageRanks", member.AdminRights)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateChatCreatesMegagroupWithChannelPts(t *testing.T) {
|
func TestCreateChatCreatesMegagroupWithChannelPts(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
store := memory.NewChannelStore()
|
store := memory.NewChannelStore()
|
||||||
|
|
@ -1647,6 +1747,60 @@ func TestDeleteParticipantHistoryDeletesOneBoundedSenderPage(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTransferOwnershipDoesNotAdvanceChannelPts(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
service := NewService(memory.NewChannelStore())
|
||||||
|
created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
|
||||||
|
Title: "Transfer",
|
||||||
|
MemberUserIDs: []int64{1002},
|
||||||
|
Date: 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateMegagroupFromCreateChat: %v", err)
|
||||||
|
}
|
||||||
|
ptsBeforeTransfer := created.Channel.Pts
|
||||||
|
transfer, err := service.TransferOwnership(ctx, 1001, domain.TransferChannelOwnershipRequest{
|
||||||
|
ChannelID: created.Channel.ID,
|
||||||
|
NewOwnerID: 1002,
|
||||||
|
Date: 11,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TransferOwnership: %v", err)
|
||||||
|
}
|
||||||
|
if transfer.Channel.CreatorUserID != 1002 || transfer.NewOwner.Role != domain.ChannelRoleCreator || transfer.OldOwner.Role != domain.ChannelRoleAdmin {
|
||||||
|
t.Fatalf("transfer result = %+v, want owner moved to 1002 and old owner admin", transfer)
|
||||||
|
}
|
||||||
|
if transfer.Channel.Pts != ptsBeforeTransfer {
|
||||||
|
t.Fatalf("transfer channel pts = %d, want unchanged %d", transfer.Channel.Pts, ptsBeforeTransfer)
|
||||||
|
}
|
||||||
|
if len(transfer.Events) != 2 {
|
||||||
|
t.Fatalf("transfer events = %+v, want two participant transitions", transfer.Events)
|
||||||
|
}
|
||||||
|
for _, event := range transfer.Events {
|
||||||
|
if event.Type != domain.ChannelUpdateParticipant || event.Pts != 0 || event.PtsCount != 0 {
|
||||||
|
t.Fatalf("transfer event = %+v, want transient participant event", event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diffAfterTransfer, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: ptsBeforeTransfer, Limit: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDifference after transfer: %v", err)
|
||||||
|
}
|
||||||
|
if len(diffAfterTransfer.OtherUpdates) != 0 || diffAfterTransfer.Pts != ptsBeforeTransfer {
|
||||||
|
t.Fatalf("diff after transfer = %+v, want no durable participant update", diffAfterTransfer)
|
||||||
|
}
|
||||||
|
oldOwner, err := service.GetParticipant(ctx, 1002, created.Channel.ID, 1001)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetParticipant old owner: %v", err)
|
||||||
|
}
|
||||||
|
newOwner, err := service.GetParticipant(ctx, 1002, created.Channel.ID, 1002)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetParticipant new owner: %v", err)
|
||||||
|
}
|
||||||
|
if oldOwner.Role != domain.ChannelRoleAdmin || newOwner.Role != domain.ChannelRoleCreator {
|
||||||
|
t.Fatalf("participants after transfer old=%+v new=%+v, want admin/creator", oldOwner, newOwner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChannelAdminTitlePinAndInvite(t *testing.T) {
|
func TestChannelAdminTitlePinAndInvite(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
service := NewService(memory.NewChannelStore())
|
service := NewService(memory.NewChannelStore())
|
||||||
|
|
@ -2292,8 +2446,8 @@ func TestPublicChannelSearchAndResolveUsername(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SearchPublicChannels joined: %v", err)
|
t.Fatalf("SearchPublicChannels joined: %v", err)
|
||||||
}
|
}
|
||||||
if len(joined.MyResults) != 1 || joined.MyResults[0].ID != public.ID || len(joined.Results) != 0 {
|
if len(joined.MyResults) != 0 || len(joined.Results) != 0 {
|
||||||
t.Fatalf("joined public search = %+v, want my public channel only", joined)
|
t.Fatalf("joined public search = %+v, want no discovery result for active member", joined)
|
||||||
}
|
}
|
||||||
global, err := service.SearchPublicChannels(ctx, 1003, "public", 10)
|
global, err := service.SearchPublicChannels(ctx, 1003, "public", 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ package groupcalls
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
|
@ -44,10 +45,55 @@ func (s *Service) Create(ctx context.Context, channelID, creatorUserID int64, ti
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateConference 分配 id/access_hash/slug 并创建 ad-hoc conference call。
|
||||||
|
func (s *Service) CreateConference(ctx context.Context, creatorUserID, randomID, migratedFromPhoneCallID int64, now int) (domain.GroupCall, error) {
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
id, err := randomPositiveInt64()
|
||||||
|
if err != nil {
|
||||||
|
return domain.GroupCall{}, err
|
||||||
|
}
|
||||||
|
accessHash, err := randomPositiveInt64()
|
||||||
|
if err != nil {
|
||||||
|
return domain.GroupCall{}, err
|
||||||
|
}
|
||||||
|
slug, err := randomSlug()
|
||||||
|
if err != nil {
|
||||||
|
return domain.GroupCall{}, err
|
||||||
|
}
|
||||||
|
call, err := s.store.CreateConferenceCall(ctx, domain.GroupCall{
|
||||||
|
ID: id,
|
||||||
|
AccessHash: accessHash,
|
||||||
|
CreatorUserID: creatorUserID,
|
||||||
|
Kind: domain.GroupCallKindConference,
|
||||||
|
Version: 1,
|
||||||
|
CreatedAt: now,
|
||||||
|
InviteSlug: slug,
|
||||||
|
InviteLink: conferenceInviteLink(slug),
|
||||||
|
RandomID: randomID,
|
||||||
|
MigratedFromPhoneCallID: migratedFromPhoneCallID,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
return call, nil
|
||||||
|
}
|
||||||
|
if err != domain.ErrGroupCallInvalid {
|
||||||
|
return domain.GroupCall{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return domain.GroupCall{}, fmt.Errorf("groupcalls: exhausted conference slug attempts")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Get(ctx context.Context, callID int64) (domain.GroupCall, bool, error) {
|
func (s *Service) Get(ctx context.Context, callID int64) (domain.GroupCall, bool, error) {
|
||||||
return s.store.GetGroupCall(ctx, callID)
|
return s.store.GetGroupCall(ctx, callID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetBySlug(ctx context.Context, slug string) (domain.GroupCall, bool, error) {
|
||||||
|
return s.store.GetGroupCallBySlug(ctx, slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetByInviteMessage(ctx context.Context, userID int64, msgID int) (domain.GroupCall, domain.GroupCallInvite, bool, error) {
|
||||||
|
return s.store.GetGroupCallByInviteMessage(ctx, userID, msgID)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Join(ctx context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error) {
|
func (s *Service) Join(ctx context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error) {
|
||||||
return s.store.JoinGroupCall(ctx, req)
|
return s.store.JoinGroupCall(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
@ -56,6 +102,10 @@ func (s *Service) Leave(ctx context.Context, callID, userID int64, now int) (dom
|
||||||
return s.store.LeaveGroupCall(ctx, callID, userID, now)
|
return s.store.LeaveGroupCall(ctx, callID, userID, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) RemoveConferenceParticipants(ctx context.Context, req domain.RemoveConferenceCallParticipantsRequest) (domain.RemoveConferenceCallParticipantsResult, error) {
|
||||||
|
return s.store.RemoveConferenceCallParticipants(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Discard(ctx context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error) {
|
func (s *Service) Discard(ctx context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error) {
|
||||||
return s.store.DiscardGroupCall(ctx, callID, now)
|
return s.store.DiscardGroupCall(ctx, callID, now)
|
||||||
}
|
}
|
||||||
|
|
@ -108,6 +158,26 @@ func (s *Service) ParticipantOverride(ctx context.Context, callID, setterUserID,
|
||||||
return s.store.GetParticipantOverride(ctx, callID, setterUserID, targetUserID)
|
return s.store.GetParticipantOverride(ctx, callID, setterUserID, targetUserID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateConferenceInvite(ctx context.Context, invite domain.GroupCallInvite) (domain.GroupCallInvite, error) {
|
||||||
|
return s.store.CreateConferenceInvite(ctx, invite)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetConferenceInviteStatus(ctx context.Context, callID, inviteeUserID int64, msgID int, status domain.GroupCallInviteStatus, now int) (domain.GroupCallInvite, bool, error) {
|
||||||
|
return s.store.SetConferenceInviteStatus(ctx, callID, inviteeUserID, msgID, status, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ConferenceRecipients(ctx context.Context, callID int64) ([]int64, error) {
|
||||||
|
return s.store.ListConferenceRecipientUserIDs(ctx, callID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) AppendChainBlock(ctx context.Context, block domain.GroupCallChainBlock) (domain.GroupCallChainBlock, error) {
|
||||||
|
return s.store.AppendGroupCallChainBlock(ctx, block)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ChainBlocks(ctx context.Context, callID int64, subChainID, offset, limit int) (domain.GroupCallChainBlockPage, error) {
|
||||||
|
return s.store.ListGroupCallChainBlocks(ctx, callID, subChainID, offset, limit)
|
||||||
|
}
|
||||||
|
|
||||||
func randomPositiveInt64() (int64, error) {
|
func randomPositiveInt64() (int64, error) {
|
||||||
var buf [8]byte
|
var buf [8]byte
|
||||||
if _, err := rand.Read(buf[:]); err != nil {
|
if _, err := rand.Read(buf[:]); err != nil {
|
||||||
|
|
@ -119,3 +189,15 @@ func randomPositiveInt64() (int64, error) {
|
||||||
}
|
}
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func randomSlug() (string, error) {
|
||||||
|
var buf [18]byte
|
||||||
|
if _, err := rand.Read(buf[:]); err != nil {
|
||||||
|
return "", fmt.Errorf("groupcalls: random slug: %w", err)
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(buf[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func conferenceInviteLink(slug string) string {
|
||||||
|
return "https://telesrv.net/call/" + slug + "?slug=" + slug
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,11 +83,16 @@ func (r *registry) decActiveLocked(userID int64) {
|
||||||
|
|
||||||
// markDiscardedLocked 把非终态 entry 迁入终态并更新并发计数。
|
// markDiscardedLocked 把非终态 entry 迁入终态并更新并发计数。
|
||||||
func (r *registry) markDiscardedLocked(e *entry, reason domain.PhoneCallDiscardReason, duration, nowUnix int) {
|
func (r *registry) markDiscardedLocked(e *entry, reason domain.PhoneCallDiscardReason, duration, nowUnix int) {
|
||||||
|
r.markDiscardedWithSlugLocked(e, reason, "", duration, nowUnix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *registry) markDiscardedWithSlugLocked(e *entry, reason domain.PhoneCallDiscardReason, reasonSlug string, duration, nowUnix int) {
|
||||||
if e.call.Terminal() {
|
if e.call.Terminal() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
e.call.State = domain.PhoneCallStateDiscarded
|
e.call.State = domain.PhoneCallStateDiscarded
|
||||||
e.call.DiscardReason = reason
|
e.call.DiscardReason = reason
|
||||||
|
e.call.DiscardReasonSlug = reasonSlug
|
||||||
e.call.Duration = duration
|
e.call.Duration = duration
|
||||||
e.call.DiscardedAt = nowUnix
|
e.call.DiscardedAt = nowUnix
|
||||||
r.decActiveLocked(e.call.AdminID)
|
r.decActiveLocked(e.call.AdminID)
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,10 @@ func (s *Service) ConfirmCall(ctx context.Context, userID, callID, accessHash in
|
||||||
// DiscardCall 挂断:任意非终态可达,幂等。already=true 表示通话此前已是终态
|
// DiscardCall 挂断:任意非终态可达,幂等。already=true 表示通话此前已是终态
|
||||||
// (双方同时挂断:先到者定 reason,后到者拿快照)。
|
// (双方同时挂断:先到者定 reason,后到者拿快照)。
|
||||||
func (s *Service) DiscardCall(ctx context.Context, userID, callID, accessHash int64, reason domain.PhoneCallDiscardReason, duration int) (domain.PhoneCall, bool, error) {
|
func (s *Service) DiscardCall(ctx context.Context, userID, callID, accessHash int64, reason domain.PhoneCallDiscardReason, duration int) (domain.PhoneCall, bool, error) {
|
||||||
|
return s.DiscardCallWithSlug(ctx, userID, callID, accessHash, reason, "", duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DiscardCallWithSlug(ctx context.Context, userID, callID, accessHash int64, reason domain.PhoneCallDiscardReason, reasonSlug string, duration int) (domain.PhoneCall, bool, error) {
|
||||||
s.reg.mu.Lock()
|
s.reg.mu.Lock()
|
||||||
defer s.reg.mu.Unlock()
|
defer s.reg.mu.Unlock()
|
||||||
e, err := s.lookupLocked(callID, accessHash)
|
e, err := s.lookupLocked(callID, accessHash)
|
||||||
|
|
@ -267,11 +271,14 @@ func (s *Service) DiscardCall(ctx context.Context, userID, callID, accessHash in
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
reason = domain.PhoneCallDiscardReasonHangup
|
reason = domain.PhoneCallDiscardReasonHangup
|
||||||
}
|
}
|
||||||
|
if reason != domain.PhoneCallDiscardReasonMigrateConference {
|
||||||
|
reasonSlug = ""
|
||||||
|
}
|
||||||
// duration 只在通话真正建立(Confirmed)后才认,防止客户端把振铃时长报成通话时长。
|
// duration 只在通话真正建立(Confirmed)后才认,防止客户端把振铃时长报成通话时长。
|
||||||
if e.call.StartDate == 0 || duration < 0 {
|
if e.call.StartDate == 0 || duration < 0 {
|
||||||
duration = 0
|
duration = 0
|
||||||
}
|
}
|
||||||
s.reg.markDiscardedLocked(e, reason, duration, int(s.clk.Now().Unix()))
|
s.reg.markDiscardedWithSlugLocked(e, reason, reasonSlug, duration, int(s.clk.Now().Unix()))
|
||||||
return e.call, false, nil
|
return e.call, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,15 @@ import (
|
||||||
|
|
||||||
// Canonical ids used to synthesize converted/defaulted values.
|
// Canonical ids used to synthesize converted/defaulted values.
|
||||||
const (
|
const (
|
||||||
inputUserID = 0xf21158c6 // inputUser user_id:long access_hash:long
|
inputUserID = 0xf21158c6 // inputUser user_id:long access_hash:long
|
||||||
inputMessageID = 0xa676a322 // inputMessageID id:int
|
inputMessageID = 0xa676a322 // inputMessageID id:int
|
||||||
boolFalseID = 0xbc799737 // boolFalse
|
inputChannelEmptyID = 0xee8c1e86 // inputChannelEmpty
|
||||||
|
inputChannelID = 0xf35aec28 // inputChannel channel_id:long access_hash:long
|
||||||
|
inputChannelFromMessageID = 0x5b934f9d // inputChannelFromMessage peer:InputPeer msg_id:int channel_id:long
|
||||||
|
inputPeerEmptyID = 0x7f3b18ea // inputPeerEmpty
|
||||||
|
inputPeerChannelID = 0x27bcbbfc // inputPeerChannel channel_id:long access_hash:long
|
||||||
|
inputPeerChannelFromMessageID = 0xbd2a0840 // inputPeerChannelFromMessage peer:InputPeer msg_id:int channel_id:long
|
||||||
|
boolFalseID = 0xbc799737 // boolFalse
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed schema/client-drift.tl
|
//go:embed schema/client-drift.tl
|
||||||
|
|
@ -34,7 +40,8 @@ func mustLoadDrift() *schemaModel {
|
||||||
// Pure schema diff cannot recover a rename, so it is declared here (data, not a
|
// Pure schema diff cannot recover a rename, so it is declared here (data, not a
|
||||||
// transform). It is the only thing a structural rename needs.
|
// transform). It is the only thing a structural rename needs.
|
||||||
var driftFieldRenames = map[string]string{
|
var driftFieldRenames = map[string]string{
|
||||||
"bots.exportBotToken\x00bot": "bot_id",
|
"bots.exportBotToken\x00bot": "bot_id",
|
||||||
|
"messages.editChatCreator\x00peer": "channel",
|
||||||
}
|
}
|
||||||
|
|
||||||
// fieldConverter rewrites one field whose wire type changed between the old and
|
// fieldConverter rewrites one field whose wire type changed between the old and
|
||||||
|
|
@ -73,6 +80,28 @@ var fieldConverters = map[string]fieldConverter{
|
||||||
out.PutLong(0)
|
out.PutLong(0)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
// channel:InputChannel -> peer:InputPeer for the old channels.editCreator
|
||||||
|
// Android constructor. Concrete layouts are otherwise byte-compatible.
|
||||||
|
"InputChannel->InputPeer": func(raw []byte, out *bin.Buffer) error {
|
||||||
|
in := &bin.Buffer{Buf: raw}
|
||||||
|
id, err := in.ID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch id {
|
||||||
|
case inputChannelEmptyID:
|
||||||
|
out.PutID(inputPeerEmptyID)
|
||||||
|
case inputChannelID:
|
||||||
|
out.PutID(inputPeerChannelID)
|
||||||
|
out.Put(in.Buf)
|
||||||
|
case inputChannelFromMessageID:
|
||||||
|
out.PutID(inputPeerChannelFromMessageID)
|
||||||
|
out.Put(in.Buf)
|
||||||
|
default:
|
||||||
|
return bin.NewUnexpectedID(id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpgradeInbound converts an old client's inbound request to canonical (227)
|
// UpgradeInbound converts an old client's inbound request to canonical (227)
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,34 @@ func TestInboundBodyTransforms(t *testing.T) {
|
||||||
}
|
}
|
||||||
validateMethodRequest(t, out, 0x42c6978f, "langpackGetLanguages")
|
validateMethodRequest(t, out, 0x42c6978f, "langpackGetLanguages")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("channelsEditCreatorToMessagesEditChatCreator", func(t *testing.T) {
|
||||||
|
var in bin.Buffer
|
||||||
|
in.PutID(0x8f38cd1f)
|
||||||
|
_ = (&tg.InputChannel{ChannelID: 132, AccessHash: 8956724956393200600}).Encode(&in)
|
||||||
|
_ = (&tg.InputUser{UserID: 1780243211, AccessHash: 42}).Encode(&in)
|
||||||
|
_ = (&tg.InputCheckPasswordEmpty{}).Encode(&in)
|
||||||
|
out, ok, err := UpgradeInbound(0x8f38cd1f, &in)
|
||||||
|
if !ok || err != nil {
|
||||||
|
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
validateMethodRequest(t, out, 0xf743b857, "messagesEditChatCreator")
|
||||||
|
var req tg.MessagesEditChatCreatorRequest
|
||||||
|
if err := req.Decode(&bin.Buffer{Buf: append([]byte(nil), out.Buf...)}); err != nil {
|
||||||
|
t.Fatalf("decode upgraded editChatCreator: %v", err)
|
||||||
|
}
|
||||||
|
peer, ok := req.Peer.(*tg.InputPeerChannel)
|
||||||
|
if !ok || peer.ChannelID != 132 || peer.AccessHash != 8956724956393200600 {
|
||||||
|
t.Fatalf("upgraded peer = %T %+v, want inputPeerChannel", req.Peer, req.Peer)
|
||||||
|
}
|
||||||
|
user, ok := req.UserID.(*tg.InputUser)
|
||||||
|
if !ok || user.UserID != 1780243211 || user.AccessHash != 42 {
|
||||||
|
t.Fatalf("upgraded user = %T %+v, want inputUser", req.UserID, req.UserID)
|
||||||
|
}
|
||||||
|
if _, ok := req.Password.(*tg.InputCheckPasswordEmpty); !ok {
|
||||||
|
t.Fatalf("upgraded password = %T, want inputCheckPasswordEmpty", req.Password)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestInboundCRCSwaps covers the body-compatible client-drift methods that only
|
// TestInboundCRCSwaps covers the body-compatible client-drift methods that only
|
||||||
|
|
|
||||||
|
|
@ -26,3 +26,7 @@ contacts.search#11f812d8 q:string limit:int = contacts.Found;
|
||||||
langpack.getLangPack#9ab5c58e lang_code:string = LangPackDifference;
|
langpack.getLangPack#9ab5c58e lang_code:string = LangPackDifference;
|
||||||
langpack.getStrings#2e1ee318 lang_code:string keys:Vector<string> = Vector<LangPackString>;
|
langpack.getStrings#2e1ee318 lang_code:string keys:Vector<string> = Vector<LangPackString>;
|
||||||
langpack.getLanguages#800fd57d = Vector<LangPackLanguage>;
|
langpack.getLanguages#800fd57d = Vector<LangPackLanguage>;
|
||||||
|
// DrKLO still emits old channels.editCreator#8f38cd1f; canonical 227 replaced
|
||||||
|
// that flow with messages.editChatCreator(peer:InputPeer,...). Keep the old
|
||||||
|
// constructor id here but target the canonical method name for generic upgrade.
|
||||||
|
messages.editChatCreator#8f38cd1f channel:InputChannel user_id:InputUser password:InputCheckPasswordSRP = Updates;
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,40 @@ type ChannelAdminRights struct {
|
||||||
ManageDirectMessages bool
|
ManageDirectMessages bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreatorChannelAdminRights returns the full rights set clients expect on creator projections.
|
||||||
|
func CreatorChannelAdminRights() ChannelAdminRights {
|
||||||
|
return 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,
|
||||||
|
ManageRanks: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeFullMegagroupAdminRights fills implicit full-admin bits for megagroups.
|
||||||
|
func NormalizeFullMegagroupAdminRights(ch Channel, rights ChannelAdminRights) ChannelAdminRights {
|
||||||
|
if ch.Megagroup && !ch.Broadcast &&
|
||||||
|
rights.ChangeInfo &&
|
||||||
|
rights.DeleteMessages &&
|
||||||
|
rights.BanUsers &&
|
||||||
|
rights.InviteUsers &&
|
||||||
|
rights.PinMessages &&
|
||||||
|
rights.AddAdmins &&
|
||||||
|
rights.ManageCall {
|
||||||
|
rights.ManageRanks = true
|
||||||
|
}
|
||||||
|
return rights
|
||||||
|
}
|
||||||
|
|
||||||
// ChannelBannedRights is a domain-only representation of Telegram banned rights.
|
// ChannelBannedRights is a domain-only representation of Telegram banned rights.
|
||||||
type ChannelBannedRights struct {
|
type ChannelBannedRights struct {
|
||||||
ViewMessages bool
|
ViewMessages bool
|
||||||
|
|
@ -1019,6 +1053,9 @@ type ChannelRecommendationsResult struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublicChannelSearchResult contains contacts.search public channel/supergroup matches.
|
// PublicChannelSearchResult contains contacts.search public channel/supergroup matches.
|
||||||
|
// Results are public peers the viewer is not an active member of; joined peers
|
||||||
|
// are intentionally left to dialogs/resolve paths so clients do not render
|
||||||
|
// invisible discovery rows for already joined channels.
|
||||||
type PublicChannelSearchResult struct {
|
type PublicChannelSearchResult struct {
|
||||||
MyResults []Channel
|
MyResults []Channel
|
||||||
Results []Channel
|
Results []Channel
|
||||||
|
|
@ -1203,6 +1240,26 @@ type EditChannelAdminResult struct {
|
||||||
Date int
|
Date int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TransferChannelOwnershipRequest transfers a channel/supergroup to another active member.
|
||||||
|
type TransferChannelOwnershipRequest struct {
|
||||||
|
UserID int64
|
||||||
|
ChannelID int64
|
||||||
|
NewOwnerID int64
|
||||||
|
Date int
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransferChannelOwnershipResult describes both participant transitions produced by an owner transfer.
|
||||||
|
type TransferChannelOwnershipResult struct {
|
||||||
|
Channel Channel
|
||||||
|
PreviousOwner ChannelMember
|
||||||
|
OldOwner ChannelMember
|
||||||
|
PreviousNewOwner ChannelMember
|
||||||
|
NewOwner ChannelMember
|
||||||
|
Events []ChannelUpdateEvent
|
||||||
|
Recipients []int64
|
||||||
|
Date int
|
||||||
|
}
|
||||||
|
|
||||||
// EditChannelMemberRankRequest sets or clears a participant's member tag (rank)
|
// EditChannelMemberRankRequest sets or clears a participant's member tag (rank)
|
||||||
// without touching their role or admin rights.
|
// without touching their role or admin rights.
|
||||||
type EditChannelMemberRankRequest struct {
|
type EditChannelMemberRankRequest struct {
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ type ImportContactsResult struct {
|
||||||
|
|
||||||
// UserSearchResult 是 contacts.search 的业务结果。
|
// UserSearchResult 是 contacts.search 的业务结果。
|
||||||
// MyResults 放当前账号通讯录内命中的用户;Results 放其他全局命中用户。
|
// MyResults 放当前账号通讯录内命中的用户;Results 放其他全局命中用户。
|
||||||
// MyChannelResults 放当前账号已加入的公开 channel/supergroup;ChannelResults 放其他公开命中。
|
// ChannelResults 放未加入的公开 channel/supergroup 命中;已加入频道由 dialogs/resolve 路径呈现。
|
||||||
type UserSearchResult struct {
|
type UserSearchResult struct {
|
||||||
MyResults []User
|
MyResults []User
|
||||||
Results []User
|
Results []User
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,27 @@ const (
|
||||||
GroupCallStateDiscarded GroupCallState = "discarded"
|
GroupCallStateDiscarded GroupCallState = "discarded"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GroupCallKind distinguishes regular channel group calls from ad-hoc
|
||||||
|
// conference calls. Conference calls have no channel membership scope; access is
|
||||||
|
// granted by creator/participant/invite/slug.
|
||||||
|
type GroupCallKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
GroupCallKindChannel GroupCallKind = "channel"
|
||||||
|
GroupCallKindConference GroupCallKind = "conference"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GroupCallInviteStatus is the durable state of a private conference invite.
|
||||||
|
type GroupCallInviteStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
GroupCallInvitePending GroupCallInviteStatus = "pending"
|
||||||
|
GroupCallInviteAccepted GroupCallInviteStatus = "accepted"
|
||||||
|
GroupCallInviteDeclined GroupCallInviteStatus = "declined"
|
||||||
|
GroupCallInviteMissed GroupCallInviteStatus = "missed"
|
||||||
|
GroupCallInviteRevoked GroupCallInviteStatus = "revoked"
|
||||||
|
)
|
||||||
|
|
||||||
// 群通话业务错误;rpc 层映射为 GROUPCALL_* RPC_ERROR。
|
// 群通话业务错误;rpc 层映射为 GROUPCALL_* RPC_ERROR。
|
||||||
var (
|
var (
|
||||||
ErrGroupCallInvalid = errors.New("group call invalid")
|
ErrGroupCallInvalid = errors.New("group call invalid")
|
||||||
|
|
@ -21,6 +42,7 @@ var (
|
||||||
ErrGroupCallAlreadyStarted = errors.New("group call already started")
|
ErrGroupCallAlreadyStarted = errors.New("group call already started")
|
||||||
ErrGroupCallSSRCDuplicate = errors.New("group call ssrc duplicate")
|
ErrGroupCallSSRCDuplicate = errors.New("group call ssrc duplicate")
|
||||||
ErrGroupCallNotJoined = errors.New("group call participant missing")
|
ErrGroupCallNotJoined = errors.New("group call participant missing")
|
||||||
|
ErrConferenceChainInvalid = errors.New("conference call chain invalid")
|
||||||
)
|
)
|
||||||
|
|
||||||
// GroupCall 是一场群通话的权威态。
|
// GroupCall 是一场群通话的权威态。
|
||||||
|
|
@ -29,6 +51,7 @@ type GroupCall struct {
|
||||||
AccessHash int64
|
AccessHash int64
|
||||||
ChannelID int64
|
ChannelID int64
|
||||||
CreatorUserID int64
|
CreatorUserID int64
|
||||||
|
Kind GroupCallKind
|
||||||
State GroupCallState
|
State GroupCallState
|
||||||
Title string
|
Title string
|
||||||
JoinMuted bool
|
JoinMuted bool
|
||||||
|
|
@ -41,6 +64,14 @@ type GroupCall struct {
|
||||||
// StartedMsgID 是 messageActionGroupCall(started) 的频道消息 id(discard 时
|
// StartedMsgID 是 messageActionGroupCall(started) 的频道消息 id(discard 时
|
||||||
// 客户端用它定位起始服务消息,当前仅记录)。
|
// 客户端用它定位起始服务消息,当前仅记录)。
|
||||||
StartedMsgID int
|
StartedMsgID int
|
||||||
|
// InviteSlug/InviteLink 仅 conference 使用。slug 必须在 migrate reason 与
|
||||||
|
// InputGroupCallSlug 中稳定可解析;link 是客户端 UI 复制/分享用完整 URL。
|
||||||
|
InviteSlug string
|
||||||
|
InviteLink string
|
||||||
|
// RandomID 是 conference create 的幂等键(creator_user_id + random_id)。
|
||||||
|
RandomID int64
|
||||||
|
// MigratedFromPhoneCallID 记录由哪通 P2P call 升级而来;link-only create 为 0。
|
||||||
|
MigratedFromPhoneCallID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active 报告通话是否仍在进行。
|
// Active 报告通话是否仍在进行。
|
||||||
|
|
@ -48,6 +79,10 @@ func (c GroupCall) Active() bool {
|
||||||
return c.State == GroupCallStateActive
|
return c.State == GroupCallStateActive
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c GroupCall) Conference() bool {
|
||||||
|
return c.Kind == GroupCallKindConference
|
||||||
|
}
|
||||||
|
|
||||||
// GroupCallParticipant 是房间内一名参与者。
|
// GroupCallParticipant 是房间内一名参与者。
|
||||||
type GroupCallParticipant struct {
|
type GroupCallParticipant struct {
|
||||||
CallID int64
|
CallID int64
|
||||||
|
|
@ -67,7 +102,11 @@ type GroupCallParticipant struct {
|
||||||
// 快照(M3/M4 启用;M0/M1 仅透明保存 self-edit,不转发)。
|
// 快照(M3/M4 启用;M0/M1 仅透明保存 self-edit,不转发)。
|
||||||
VideoJSON []byte
|
VideoJSON []byte
|
||||||
PresentationJSON []byte
|
PresentationJSON []byte
|
||||||
Left bool
|
// PublicKey/JoinBlock 仅 conference 使用。服务端不解析 E2E 内容,只持久化
|
||||||
|
// opaque bytes 并供 chain block 补拉/转发。
|
||||||
|
PublicKey []byte
|
||||||
|
JoinBlock []byte
|
||||||
|
Left bool
|
||||||
// LastCheckDate 是 checkGroupCall 保活水位。注意:客户端只在 Connecting 态
|
// LastCheckDate 是 checkGroupCall 保活水位。注意:客户端只在 Connecting 态
|
||||||
// 发 checkGroupCall(媒体连通后心跳停止),掉线判定必须与 SFU 媒体面活性
|
// 发 checkGroupCall(媒体连通后心跳停止),掉线判定必须与 SFU 媒体面活性
|
||||||
// 取双过期(见 sweeper),绝不能单凭此字段。
|
// 取双过期(见 sweeper),绝不能单凭此字段。
|
||||||
|
|
@ -85,11 +124,13 @@ type CreateGroupCallRequest struct {
|
||||||
|
|
||||||
// JoinGroupCallRequest 加入/重进群通话(rejoin 同主键换新 ssrc)。
|
// JoinGroupCallRequest 加入/重进群通话(rejoin 同主键换新 ssrc)。
|
||||||
type JoinGroupCallRequest struct {
|
type JoinGroupCallRequest struct {
|
||||||
CallID int64
|
CallID int64
|
||||||
UserID int64
|
UserID int64
|
||||||
SSRC int64
|
SSRC int64
|
||||||
Muted bool
|
Muted bool
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
|
PublicKey []byte
|
||||||
|
JoinBlock []byte
|
||||||
// VideoJSON 是本次 join 铸造的视频内部状态(endpoint+源组+active);rejoin
|
// VideoJSON 是本次 join 铸造的视频内部状态(endpoint+源组+active);rejoin
|
||||||
// 整体替换并**清空旧 PresentationJSON**(客户端主连接 rejoin 后会重发
|
// 整体替换并**清空旧 PresentationJSON**(客户端主连接 rejoin 后会重发
|
||||||
// joinGroupCallPresentation,旧屏幕登记必须作废)。
|
// joinGroupCallPresentation,旧屏幕登记必须作废)。
|
||||||
|
|
@ -104,6 +145,27 @@ type GroupCallMutation struct {
|
||||||
Participant GroupCallParticipant
|
Participant GroupCallParticipant
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveConferenceCallParticipantsRequest describes a conference participant
|
||||||
|
// removal from both the media participant set and the E2E member chain.
|
||||||
|
type RemoveConferenceCallParticipantsRequest struct {
|
||||||
|
CallID int64
|
||||||
|
AuthorUserID int64
|
||||||
|
TargetUserIDs []int64
|
||||||
|
OnlyLeft bool
|
||||||
|
Kick bool
|
||||||
|
Block []byte
|
||||||
|
Now int
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveConferenceCallParticipantsResult is the transactional result of
|
||||||
|
// accepting a conference E2E removal block and/or kicking active participants.
|
||||||
|
type RemoveConferenceCallParticipantsResult struct {
|
||||||
|
Call GroupCall
|
||||||
|
ParticipantsChanged []GroupCallParticipant
|
||||||
|
ChainBlock GroupCallChainBlock
|
||||||
|
ChainBlockAppended bool
|
||||||
|
}
|
||||||
|
|
||||||
// GroupCallParticipantUpdate 是 editGroupCallParticipant 的字段级更新(nil=不动)。
|
// GroupCallParticipantUpdate 是 editGroupCallParticipant 的字段级更新(nil=不动)。
|
||||||
type GroupCallParticipantUpdate struct {
|
type GroupCallParticipantUpdate struct {
|
||||||
Muted *bool
|
Muted *bool
|
||||||
|
|
@ -129,3 +191,33 @@ type GroupCallParticipantPage struct {
|
||||||
NextOffset string
|
NextOffset string
|
||||||
Version int
|
Version int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GroupCallInvite 是 conference call 在私聊中发出的邀请服务消息索引。
|
||||||
|
type GroupCallInvite struct {
|
||||||
|
CallID int64
|
||||||
|
InviterUserID int64
|
||||||
|
InviteeUserID int64
|
||||||
|
MessageID int
|
||||||
|
Status GroupCallInviteStatus
|
||||||
|
Video bool
|
||||||
|
CreatedAt int
|
||||||
|
UpdatedAt int
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupCallChainBlockLatestOffset 是客户端用来请求当前 sub-chain 最新 block 的哨兵值。
|
||||||
|
const GroupCallChainBlockLatestOffset = -1
|
||||||
|
|
||||||
|
// GroupCallChainBlock 是 conference E2E chain 的 opaque block。
|
||||||
|
type GroupCallChainBlock struct {
|
||||||
|
CallID int64
|
||||||
|
SubChainID int
|
||||||
|
Offset int
|
||||||
|
AuthorUserID int64
|
||||||
|
Block []byte
|
||||||
|
CreatedAt int
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupCallChainBlockPage struct {
|
||||||
|
Blocks []GroupCallChainBlock
|
||||||
|
NextOffset int
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -398,6 +398,9 @@ const (
|
||||||
// MessageServiceActionPhoneCall 映射 messageActionPhoneCall:私聊通话
|
// MessageServiceActionPhoneCall 映射 messageActionPhoneCall:私聊通话
|
||||||
// 结束(含 missed 超时)后落历史的通话条目,sender 恒为主叫。
|
// 结束(含 missed 超时)后落历史的通话条目,sender 恒为主叫。
|
||||||
MessageServiceActionPhoneCall MessageServiceActionKind = "phone_call"
|
MessageServiceActionPhoneCall MessageServiceActionKind = "phone_call"
|
||||||
|
// MessageServiceActionConferenceCall 映射 messageActionConferenceCall:
|
||||||
|
// ad-hoc conference call 的私聊邀请/状态服务消息。
|
||||||
|
MessageServiceActionConferenceCall MessageServiceActionKind = "conference_call"
|
||||||
// MessageServiceActionBotAllowed 映射 messageActionBotAllowed:用户授权
|
// MessageServiceActionBotAllowed 映射 messageActionBotAllowed:用户授权
|
||||||
// bot 后在 bot 私聊中留下的服务消息。
|
// bot 后在 bot 私聊中留下的服务消息。
|
||||||
MessageServiceActionBotAllowed MessageServiceActionKind = "bot_allowed"
|
MessageServiceActionBotAllowed MessageServiceActionKind = "bot_allowed"
|
||||||
|
|
@ -424,6 +427,16 @@ type MessagePhoneCallAction struct {
|
||||||
Video bool `json:"video,omitempty"`
|
Video bool `json:"video,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MessageConferenceCallAction 是 messageActionConferenceCall 的协议中立载荷。
|
||||||
|
type MessageConferenceCallAction struct {
|
||||||
|
CallID int64 `json:"call_id"`
|
||||||
|
Missed bool `json:"missed,omitempty"`
|
||||||
|
Active bool `json:"active,omitempty"`
|
||||||
|
Video bool `json:"video,omitempty"`
|
||||||
|
Duration int `json:"duration,omitempty"`
|
||||||
|
OtherParticipants []Peer `json:"other_participants,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// MessageBotAllowedAction 是 messageActionBotAllowed 的协议中立载荷。
|
// MessageBotAllowedAction 是 messageActionBotAllowed 的协议中立载荷。
|
||||||
type MessageBotAllowedAction struct {
|
type MessageBotAllowedAction struct {
|
||||||
AttachMenu bool `json:"attach_menu,omitempty"`
|
AttachMenu bool `json:"attach_menu,omitempty"`
|
||||||
|
|
@ -450,14 +463,15 @@ type MessageRequestedPeerAction struct {
|
||||||
|
|
||||||
// MessageServiceAction 是私聊服务消息动作的协议中立表示。
|
// MessageServiceAction 是私聊服务消息动作的协议中立表示。
|
||||||
type MessageServiceAction struct {
|
type MessageServiceAction struct {
|
||||||
Kind MessageServiceActionKind `json:"kind"`
|
Kind MessageServiceActionKind `json:"kind"`
|
||||||
Photo *Photo `json:"photo,omitempty"`
|
Photo *Photo `json:"photo,omitempty"`
|
||||||
Call *MessagePhoneCallAction `json:"call,omitempty"`
|
Call *MessagePhoneCallAction `json:"call,omitempty"`
|
||||||
BotAllowed *MessageBotAllowedAction `json:"bot_allowed,omitempty"`
|
ConferenceCall *MessageConferenceCallAction `json:"conference_call,omitempty"`
|
||||||
WebViewData *MessageWebViewDataAction `json:"web_view_data,omitempty"`
|
BotAllowed *MessageBotAllowedAction `json:"bot_allowed,omitempty"`
|
||||||
RequestedPeer *MessageRequestedPeerAction `json:"requested_peer,omitempty"`
|
WebViewData *MessageWebViewDataAction `json:"web_view_data,omitempty"`
|
||||||
ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"`
|
RequestedPeer *MessageRequestedPeerAction `json:"requested_peer,omitempty"`
|
||||||
StarGift *MessageStarGiftAction `json:"star_gift,omitempty"`
|
ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"`
|
||||||
|
StarGift *MessageStarGiftAction `json:"star_gift,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MessageStarGiftAction 是 messageActionStarGift 的协议中立载荷:内嵌礼物快照(贴纸/星价)
|
// MessageStarGiftAction 是 messageActionStarGift 的协议中立载荷:内嵌礼物快照(贴纸/星价)
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,10 @@ type PhoneCall struct {
|
||||||
|
|
||||||
P2PAllowed bool
|
P2PAllowed bool
|
||||||
DiscardReason PhoneCallDiscardReason
|
DiscardReason PhoneCallDiscardReason
|
||||||
Duration int
|
// DiscardReasonSlug is set for migrate_conference and must resolve through
|
||||||
|
// phone.getGroupCall(inputGroupCallSlug) before the discarded update is sent.
|
||||||
|
DiscardReasonSlug string
|
||||||
|
Duration int
|
||||||
|
|
||||||
// PrivacyP2P 与 Connections 自 PhoneCallRequest 原样保留(见其注释)。
|
// PrivacyP2P 与 Connections 自 PhoneCallRequest 原样保留(见其注释)。
|
||||||
PrivacyP2P bool
|
PrivacyP2P bool
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,17 @@ import (
|
||||||
"telesrv/internal/store/memory"
|
"telesrv/internal/store/memory"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type acceptPasswordAccountService struct {
|
||||||
|
AccountService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acceptPasswordAccountService) CheckPassword(_ context.Context, _ int64, check domain.PasswordCheck) error {
|
||||||
|
if check.Empty {
|
||||||
|
return domain.ErrPasswordHashInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestMessagesGetFutureChatCreatorAfterLeaveAndCreatorLeaveTransfers(t *testing.T) {
|
func TestMessagesGetFutureChatCreatorAfterLeaveAndCreatorLeaveTransfers(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
userStore := memory.NewUserStore()
|
userStore := memory.NewUserStore()
|
||||||
|
|
@ -98,6 +109,85 @@ func TestMessagesGetFutureChatCreatorAfterLeaveAndCreatorLeaveTransfers(t *testi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMessagesEditChatCreatorTransfersWithoutChannelPts(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
userStore := memory.NewUserStore()
|
||||||
|
owner, err := userStore.Create(ctx, domain.User{AccessHash: 9121, Phone: "15550009121", FirstName: "Owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create owner: %v", err)
|
||||||
|
}
|
||||||
|
member, err := userStore.Create(ctx, domain.User{AccessHash: 9122, Phone: "15550009122", FirstName: "Member"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create member: %v", err)
|
||||||
|
}
|
||||||
|
channelStore := memory.NewChannelStore()
|
||||||
|
channelService := appchannels.NewService(channelStore)
|
||||||
|
r := New(Config{}, Deps{
|
||||||
|
Account: acceptPasswordAccountService{},
|
||||||
|
Users: appusers.NewService(userStore),
|
||||||
|
Channels: channelService,
|
||||||
|
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700009130, 0)})
|
||||||
|
created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
|
||||||
|
CreatorUserID: owner.ID,
|
||||||
|
Title: "explicit transfer",
|
||||||
|
Megagroup: true,
|
||||||
|
MemberUserIDs: []int64{member.ID},
|
||||||
|
Date: 1700009130,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create channel: %v", err)
|
||||||
|
}
|
||||||
|
ownerCtx := WithUserID(ctx, owner.ID)
|
||||||
|
peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
|
||||||
|
if _, err := r.onMessagesEditChatCreator(ownerCtx, &tg.MessagesEditChatCreatorRequest{
|
||||||
|
Peer: peer,
|
||||||
|
UserID: &tg.InputUserEmpty{},
|
||||||
|
Password: &tg.InputCheckPasswordEmpty{},
|
||||||
|
}); err == nil || !tgerr.Is(err, "PASSWORD_HASH_INVALID") {
|
||||||
|
t.Fatalf("editChatCreator probe err = %v, want PASSWORD_HASH_INVALID", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updatesClass, err := r.onMessagesEditChatCreator(ownerCtx, &tg.MessagesEditChatCreatorRequest{
|
||||||
|
Peer: peer,
|
||||||
|
UserID: &tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash},
|
||||||
|
Password: &tg.InputCheckPasswordSRP{SRPID: 1, A: []byte{1}, M1: []byte{2}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("editChatCreator transfer: %v", err)
|
||||||
|
}
|
||||||
|
updates := updatesClass.(*tg.Updates)
|
||||||
|
participantUpdates := 0
|
||||||
|
hasChannel := false
|
||||||
|
for _, update := range updates.Updates {
|
||||||
|
switch update.(type) {
|
||||||
|
case *tg.UpdateChannelParticipant:
|
||||||
|
participantUpdates++
|
||||||
|
case *tg.UpdateChannel:
|
||||||
|
hasChannel = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if participantUpdates != 2 || !hasChannel {
|
||||||
|
t.Fatalf("transfer updates = %+v, want two participant updates and updateChannel", updates.Updates)
|
||||||
|
}
|
||||||
|
if chat, ok := updates.Chats[0].(*tg.Channel); !ok || chat.Creator || !chat.AdminRights.AddAdmins {
|
||||||
|
t.Fatalf("owner response chat = %T %+v, want old owner as non-creator admin", updates.Chats[0], updates.Chats[0])
|
||||||
|
}
|
||||||
|
view, err := channelService.GetChannel(ctx, member.ID, created.Channel.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("member get channel after transfer: %v", err)
|
||||||
|
}
|
||||||
|
if view.Channel.CreatorUserID != member.ID || view.Self.Role != domain.ChannelRoleCreator || view.Channel.Pts != created.Channel.Pts {
|
||||||
|
t.Fatalf("channel after transfer = %+v self=%+v, want member creator and pts unchanged %d", view.Channel, view.Self, created.Channel.Pts)
|
||||||
|
}
|
||||||
|
oldOwner, err := channelService.GetParticipant(ctx, member.ID, created.Channel.ID, owner.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("old owner participant after transfer: %v", err)
|
||||||
|
}
|
||||||
|
if oldOwner.Role != domain.ChannelRoleAdmin {
|
||||||
|
t.Fatalf("old owner after transfer = %+v, want admin", oldOwner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMessagesGetFutureChatCreatorAfterLeaveNoCandidate(t *testing.T) {
|
func TestMessagesGetFutureChatCreatorAfterLeaveNoCandidate(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
userStore := memory.NewUserStore()
|
userStore := memory.NewUserStore()
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/gotd/td/tg"
|
"github.com/gotd/td/tg"
|
||||||
"github.com/gotd/td/tgerr"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"telesrv/internal/compat/tdesktop"
|
"telesrv/internal/compat/tdesktop"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
|
|
@ -283,6 +282,9 @@ func (r *Router) onMessagesEditChatDefaultBannedRights(ctx context.Context, req
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) onMessagesEditChatCreator(ctx context.Context, req *tg.MessagesEditChatCreatorRequest) (tg.UpdatesClass, error) {
|
func (r *Router) onMessagesEditChatCreator(ctx context.Context, req *tg.MessagesEditChatCreatorRequest) (tg.UpdatesClass, error) {
|
||||||
|
if r.deps.Channels == nil {
|
||||||
|
return nil, notImplementedErr()
|
||||||
|
}
|
||||||
if req.UserID == nil {
|
if req.UserID == nil {
|
||||||
return nil, peerIDInvalidErr()
|
return nil, peerIDInvalidErr()
|
||||||
}
|
}
|
||||||
|
|
@ -290,15 +292,52 @@ func (r *Router) onMessagesEditChatCreator(ctx context.Context, req *tg.Messages
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, internalErr()
|
return nil, internalErr()
|
||||||
}
|
}
|
||||||
if _, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer); err != nil {
|
channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if _, found, err := r.userFromInput(ctx, userID, req.UserID); err != nil {
|
if req.Password == nil {
|
||||||
|
return nil, passwordHashInvalidErr()
|
||||||
|
}
|
||||||
|
if _, ok := req.UserID.(*tg.InputUserEmpty); ok {
|
||||||
|
return nil, passwordHashInvalidErr()
|
||||||
|
}
|
||||||
|
if _, ok := req.Password.(*tg.InputCheckPasswordEmpty); ok {
|
||||||
|
return nil, passwordHashInvalidErr()
|
||||||
|
}
|
||||||
|
target, found, err := r.userFromInput(ctx, userID, req.UserID)
|
||||||
|
if err != nil {
|
||||||
return nil, internalErr()
|
return nil, internalErr()
|
||||||
} else if !found {
|
}
|
||||||
|
if !found || target.ID == 0 {
|
||||||
return nil, peerIDInvalidErr()
|
return nil, peerIDInvalidErr()
|
||||||
}
|
}
|
||||||
return nil, tgerr.New(400, "PASSWORD_HASH_INVALID")
|
if target.Bot {
|
||||||
|
return nil, userIDInvalidErr()
|
||||||
|
}
|
||||||
|
if r.deps.Account == nil {
|
||||||
|
return nil, passwordHashInvalidErr()
|
||||||
|
}
|
||||||
|
if err := r.deps.Account.CheckPassword(ctx, userID, domainPasswordCheck(req.Password)); err != nil {
|
||||||
|
return nil, passwordErr(err)
|
||||||
|
}
|
||||||
|
res, err := r.deps.Channels.TransferOwnership(ctx, userID, domain.TransferChannelOwnershipRequest{
|
||||||
|
UserID: userID,
|
||||||
|
ChannelID: channelID,
|
||||||
|
NewOwnerID: target.ID,
|
||||||
|
Date: int(r.clock.Now().Unix()),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, channelTransferErr(err)
|
||||||
|
}
|
||||||
|
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
||||||
|
r.addOnlineChannelMemberships(res.Channel.ID, res.OldOwner.UserID, res.NewOwner.UserID)
|
||||||
|
cache := newViewerPeerCache(r)
|
||||||
|
updates := r.channelOwnershipTransferUpdatesWithPeerCache(ctx, userID, userID, res, cache)
|
||||||
|
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
|
||||||
|
return r.channelOwnershipTransferUpdatesWithPeerCache(ctx, viewerUserID, userID, res, cache)
|
||||||
|
})
|
||||||
|
return updates, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) onMessagesGetFutureChatCreatorAfterLeave(ctx context.Context, peer tg.InputPeerClass) (tg.UserClass, error) {
|
func (r *Router) onMessagesGetFutureChatCreatorAfterLeave(ctx context.Context, peer tg.InputPeerClass) (tg.UserClass, error) {
|
||||||
|
|
|
||||||
|
|
@ -716,6 +716,47 @@ func (r *Router) channelParticipantUpdatesWithPeerCache(ctx context.Context, vie
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Router) channelOwnershipTransferUpdatesWithPeerCache(ctx context.Context, viewerUserID, actorUserID int64, res domain.TransferChannelOwnershipResult, cache *viewerPeerCache) *tg.Updates {
|
||||||
|
if cache == nil {
|
||||||
|
cache = newViewerPeerCache(r)
|
||||||
|
}
|
||||||
|
date := res.Date
|
||||||
|
if date == 0 {
|
||||||
|
date = int(r.clock.Now().Unix())
|
||||||
|
}
|
||||||
|
updates := make([]tg.UpdateClass, 0, len(res.Events)+1)
|
||||||
|
userIDs := []int64{actorUserID, res.PreviousOwner.UserID, res.OldOwner.UserID, res.OldOwner.InviterUserID, res.PreviousNewOwner.UserID, res.NewOwner.UserID, res.NewOwner.InviterUserID}
|
||||||
|
for _, event := range res.Events {
|
||||||
|
update := tgChannelUpdate(viewerUserID, event)
|
||||||
|
if update == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
updates = append(updates, update)
|
||||||
|
userIDs = append(userIDs, event.SenderUserID, event.Previous.UserID, event.Previous.InviterUserID, event.Participant.UserID, event.Participant.InviterUserID)
|
||||||
|
}
|
||||||
|
updates = append(updates, &tg.UpdateChannel{ChannelID: res.Channel.ID})
|
||||||
|
var self *domain.ChannelMember
|
||||||
|
switch viewerUserID {
|
||||||
|
case res.OldOwner.UserID:
|
||||||
|
member := res.OldOwner
|
||||||
|
self = &member
|
||||||
|
case res.NewOwner.UserID:
|
||||||
|
member := res.NewOwner
|
||||||
|
self = &member
|
||||||
|
}
|
||||||
|
chat := tgChannelChatMin(viewerUserID, res.Channel)
|
||||||
|
if self != nil {
|
||||||
|
chat = tgChannelChat(viewerUserID, res.Channel, self)
|
||||||
|
}
|
||||||
|
return &tg.Updates{
|
||||||
|
Updates: updates,
|
||||||
|
Users: tgUsersForViewer(viewerUserID, cache.usersForIDs(ctx, viewerUserID, uniqueRecipientIDs(userIDs))),
|
||||||
|
Chats: []tg.ChatClass{chat},
|
||||||
|
Date: date,
|
||||||
|
Seq: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func domainChannelAdminLogFilter(req *tg.ChannelsGetAdminLogRequest) domain.ChannelAdminLogFilter {
|
func domainChannelAdminLogFilter(req *tg.ChannelsGetAdminLogRequest) domain.ChannelAdminLogFilter {
|
||||||
filter, ok := req.GetEventsFilter()
|
filter, ok := req.GetEventsFilter()
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -828,3 +869,14 @@ func channelAdminErr(err error) error {
|
||||||
return channelInvalidErr(err)
|
return channelInvalidErr(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func channelTransferErr(err error) error {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, domain.ErrChannelAdminRequired):
|
||||||
|
return tgerr400("CHAT_CREATOR_REQUIRED")
|
||||||
|
case errors.Is(err, domain.ErrUserNotParticipant):
|
||||||
|
return tgerr400("PARTICIPANT_MISSING")
|
||||||
|
default:
|
||||||
|
return channelAdminErr(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -196,4 +196,27 @@ func TestPublicChannelPreviewRPCsAllowNonMember(t *testing.T) {
|
||||||
if !ok || !peerDialogChat.Left || peerDialogChat.ID != public.Channel.ID {
|
if !ok || !peerDialogChat.Left || peerDialogChat.ID != public.Channel.ID {
|
||||||
t.Fatalf("peer dialog chat = %T %+v, want left public channel", peerDialogs.Chats[0], peerDialogs.Chats[0])
|
t.Fatalf("peer dialog chat = %T %+v, want left public channel", peerDialogs.Chats[0], peerDialogs.Chats[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, err := channelService.JoinChannel(ctx, viewer.ID, public.Channel.ID, 1700010120); err != nil {
|
||||||
|
t.Fatalf("join public channel after preview: %v", err)
|
||||||
|
}
|
||||||
|
var joinedPeerDialogsIn bin.Buffer
|
||||||
|
if err := peerDialogsReq.Encode(&joinedPeerDialogsIn); err != nil {
|
||||||
|
t.Fatalf("encode joined getPeerDialogs: %v", err)
|
||||||
|
}
|
||||||
|
joinedPeerDialogsEnc, err := r.Dispatch(WithUserID(ctx, viewer.ID), [8]byte{}, 0, &joinedPeerDialogsIn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dispatch getPeerDialogs after join: %v", err)
|
||||||
|
}
|
||||||
|
joinedPeerDialogs, ok := joinedPeerDialogsEnc.(*tg.MessagesPeerDialogs)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("joined getPeerDialogs response = %T, want peer dialogs", joinedPeerDialogsEnc)
|
||||||
|
}
|
||||||
|
if len(joinedPeerDialogs.Chats) != 1 {
|
||||||
|
t.Fatalf("joined peer dialog chats = %d, want one channel", len(joinedPeerDialogs.Chats))
|
||||||
|
}
|
||||||
|
joinedChat, ok := joinedPeerDialogs.Chats[0].(*tg.Channel)
|
||||||
|
if !ok || joinedChat.Left || joinedChat.ID != public.Channel.ID {
|
||||||
|
t.Fatalf("joined peer dialog chat = %T %+v, want active channel with left=false", joinedPeerDialogs.Chats[0], joinedPeerDialogs.Chats[0])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -848,7 +848,6 @@ func (r *Router) onContactsSearch(ctx context.Context, req *tg.ContactsSearchReq
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, channelInvalidErr(err)
|
return nil, channelInvalidErr(err)
|
||||||
}
|
}
|
||||||
res.MyChannelResults = channelRes.MyResults
|
|
||||||
res.ChannelResults = channelRes.Results
|
res.ChannelResults = channelRes.Results
|
||||||
}
|
}
|
||||||
return r.tgContactsFound(ctx, userID, r.withUserSearchPresence(res)), nil
|
return r.tgContactsFound(ctx, userID, r.withUserSearchPresence(res)), nil
|
||||||
|
|
|
||||||
|
|
@ -591,19 +591,8 @@ func TestContactsSearchFindsPublicChannels(t *testing.T) {
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("result type = %T, want *tg.ContactsFound", enc)
|
t.Fatalf("result type = %T, want *tg.ContactsFound", enc)
|
||||||
}
|
}
|
||||||
if len(box.MyResults) != 1 || len(box.Chats) != 1 {
|
if len(box.MyResults) != 0 || len(box.Results) != 0 || len(box.Chats) != 0 {
|
||||||
t.Fatalf("search result sizes = my %d chats %d, want 1/1", len(box.MyResults), len(box.Chats))
|
t.Fatalf("member public search = my %d results %d chats %d, want no discovery channel for active member", len(box.MyResults), len(box.Results), len(box.Chats))
|
||||||
}
|
|
||||||
peer, ok := box.MyResults[0].(*tg.PeerChannel)
|
|
||||||
if !ok || peer.ChannelID != public.ID {
|
|
||||||
t.Fatalf("peer = %T %+v, want public channel", box.MyResults[0], box.MyResults[0])
|
|
||||||
}
|
|
||||||
chat, ok := box.Chats[0].(*tg.Channel)
|
|
||||||
if !ok || chat.ID != public.ID || chat.Username != "cu_public_rpc" {
|
|
||||||
t.Fatalf("chat = %T %+v, want public channel chat", box.Chats[0], box.Chats[0])
|
|
||||||
}
|
|
||||||
if chat.Left {
|
|
||||||
t.Fatalf("member search chat left = true, want active member channel")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var strangerIn bin.Buffer
|
var strangerIn bin.Buffer
|
||||||
|
|
|
||||||
|
|
@ -436,7 +436,7 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
|
||||||
switch self.Role {
|
switch self.Role {
|
||||||
case domain.ChannelRoleCreator:
|
case domain.ChannelRoleCreator:
|
||||||
out.Creator = true
|
out.Creator = true
|
||||||
out.SetAdminRights(tgChatAdminRights(self.AdminRights))
|
out.SetAdminRights(tgChatAdminRights(creatorProjectionAdminRights(self.AdminRights)))
|
||||||
case domain.ChannelRoleAdmin:
|
case domain.ChannelRoleAdmin:
|
||||||
out.SetAdminRights(tgChatAdminRights(self.AdminRights))
|
out.SetAdminRights(tgChatAdminRights(self.AdminRights))
|
||||||
}
|
}
|
||||||
|
|
@ -611,7 +611,7 @@ func tgChannelParticipant(selfUserID int64, member domain.ChannelMember) tg.Chan
|
||||||
case domain.ChannelRoleCreator:
|
case domain.ChannelRoleCreator:
|
||||||
out := &tg.ChannelParticipantCreator{
|
out := &tg.ChannelParticipantCreator{
|
||||||
UserID: member.UserID,
|
UserID: member.UserID,
|
||||||
AdminRights: tgChatAdminRights(member.AdminRights),
|
AdminRights: tgChatAdminRights(creatorProjectionAdminRights(member.AdminRights)),
|
||||||
}
|
}
|
||||||
if member.Rank != "" {
|
if member.Rank != "" {
|
||||||
out.SetRank(member.Rank)
|
out.SetRank(member.Rank)
|
||||||
|
|
@ -779,6 +779,13 @@ func tgChatAdminRights(rights domain.ChannelAdminRights) tg.ChatAdminRights {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func creatorProjectionAdminRights(rights domain.ChannelAdminRights) domain.ChannelAdminRights {
|
||||||
|
creatorRights := domain.CreatorChannelAdminRights()
|
||||||
|
creatorRights.Anonymous = rights.Anonymous
|
||||||
|
creatorRights.ManageDirectMessages = rights.ManageDirectMessages
|
||||||
|
return creatorRights
|
||||||
|
}
|
||||||
|
|
||||||
func domainChannelAdminRights(rights tg.ChatAdminRights) domain.ChannelAdminRights {
|
func domainChannelAdminRights(rights tg.ChatAdminRights) domain.ChannelAdminRights {
|
||||||
return domain.ChannelAdminRights{
|
return domain.ChannelAdminRights{
|
||||||
ChangeInfo: rights.ChangeInfo,
|
ChangeInfo: rights.ChangeInfo,
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,12 @@ func tgChannelsForDialogs(viewerUserID int64, channels []domain.Channel, dialogs
|
||||||
UserID: viewerUserID,
|
UserID: viewerUserID,
|
||||||
Status: domain.ChannelMemberLeft,
|
Status: domain.ChannelMemberLeft,
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
self = &domain.ChannelMember{
|
||||||
|
ChannelID: ch.ID,
|
||||||
|
UserID: viewerUserID,
|
||||||
|
Status: domain.ChannelMemberActive,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out = append(out, tgChannelChat(viewerUserID, ch, self))
|
out = append(out, tgChannelChat(viewerUserID, ch, self))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,27 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
|
||||||
action.SetDuration(m.ServiceAction.Call.Duration)
|
action.SetDuration(m.ServiceAction.Call.Duration)
|
||||||
}
|
}
|
||||||
return action
|
return action
|
||||||
|
case domain.MessageServiceActionConferenceCall:
|
||||||
|
c := m.ServiceAction.ConferenceCall
|
||||||
|
if c == nil {
|
||||||
|
return &tg.MessageActionEmpty{}
|
||||||
|
}
|
||||||
|
action := &tg.MessageActionConferenceCall{
|
||||||
|
Missed: c.Missed,
|
||||||
|
Active: c.Active,
|
||||||
|
Video: c.Video,
|
||||||
|
CallID: c.CallID,
|
||||||
|
}
|
||||||
|
if c.Duration > 0 {
|
||||||
|
action.SetDuration(c.Duration)
|
||||||
|
}
|
||||||
|
if len(c.OtherParticipants) > 0 {
|
||||||
|
peers := tgPeerList(c.OtherParticipants)
|
||||||
|
if len(peers) > 0 {
|
||||||
|
action.SetOtherParticipants(peers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return action
|
||||||
case domain.MessageServiceActionBotAllowed:
|
case domain.MessageServiceActionBotAllowed:
|
||||||
allowed := m.ServiceAction.BotAllowed
|
allowed := m.ServiceAction.BotAllowed
|
||||||
if allowed == nil {
|
if allowed == nil {
|
||||||
|
|
|
||||||
|
|
@ -66,18 +66,19 @@ func tgPhoneCallForViewer(call domain.PhoneCall, viewerID int64) tg.PhoneCallCla
|
||||||
gaOrB = call.GB // 主叫视角:拿被叫的 g_b
|
gaOrB = call.GB // 主叫视角:拿被叫的 g_b
|
||||||
}
|
}
|
||||||
out := &tg.PhoneCall{
|
out := &tg.PhoneCall{
|
||||||
P2PAllowed: call.P2PAllowed,
|
P2PAllowed: call.P2PAllowed,
|
||||||
Video: call.Video,
|
Video: call.Video,
|
||||||
ID: call.ID,
|
ConferenceSupported: true,
|
||||||
AccessHash: call.AccessHash,
|
ID: call.ID,
|
||||||
Date: call.Date,
|
AccessHash: call.AccessHash,
|
||||||
AdminID: call.AdminID,
|
Date: call.Date,
|
||||||
ParticipantID: call.ParticipantID,
|
AdminID: call.AdminID,
|
||||||
GAOrB: gaOrB,
|
ParticipantID: call.ParticipantID,
|
||||||
KeyFingerprint: call.KeyFingerprint,
|
GAOrB: gaOrB,
|
||||||
Protocol: tgPhoneCallProtocol(call.Protocol),
|
KeyFingerprint: call.KeyFingerprint,
|
||||||
Connections: tgPhoneConnections(call.Connections),
|
Protocol: tgPhoneCallProtocol(call.Protocol),
|
||||||
StartDate: call.StartDate,
|
Connections: tgPhoneConnections(call.Connections),
|
||||||
|
StartDate: call.StartDate,
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
case domain.PhoneCallStateDiscarded:
|
case domain.PhoneCallStateDiscarded:
|
||||||
|
|
@ -132,7 +133,7 @@ func tgPhoneCallDiscarded(call domain.PhoneCall) *tg.PhoneCallDiscarded {
|
||||||
Video: call.Video,
|
Video: call.Video,
|
||||||
ID: call.ID,
|
ID: call.ID,
|
||||||
}
|
}
|
||||||
if reason := tgPhoneCallDiscardReason(call.DiscardReason); reason != nil {
|
if reason := tgPhoneCallDiscardReasonWithSlug(call.DiscardReason, call.DiscardReasonSlug); reason != nil {
|
||||||
out.SetReason(reason)
|
out.SetReason(reason)
|
||||||
}
|
}
|
||||||
if call.Duration > 0 {
|
if call.Duration > 0 {
|
||||||
|
|
@ -155,6 +156,10 @@ func tgPhoneCallStopRinging(call domain.PhoneCall) *tg.PhoneCallDiscarded {
|
||||||
}
|
}
|
||||||
|
|
||||||
func tgPhoneCallDiscardReason(r domain.PhoneCallDiscardReason) tg.PhoneCallDiscardReasonClass {
|
func tgPhoneCallDiscardReason(r domain.PhoneCallDiscardReason) tg.PhoneCallDiscardReasonClass {
|
||||||
|
return tgPhoneCallDiscardReasonWithSlug(r, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func tgPhoneCallDiscardReasonWithSlug(r domain.PhoneCallDiscardReason, slug string) tg.PhoneCallDiscardReasonClass {
|
||||||
switch r {
|
switch r {
|
||||||
case domain.PhoneCallDiscardReasonMissed:
|
case domain.PhoneCallDiscardReasonMissed:
|
||||||
return &tg.PhoneCallDiscardReasonMissed{}
|
return &tg.PhoneCallDiscardReasonMissed{}
|
||||||
|
|
@ -165,7 +170,7 @@ func tgPhoneCallDiscardReason(r domain.PhoneCallDiscardReason) tg.PhoneCallDisca
|
||||||
case domain.PhoneCallDiscardReasonBusy:
|
case domain.PhoneCallDiscardReasonBusy:
|
||||||
return &tg.PhoneCallDiscardReasonBusy{}
|
return &tg.PhoneCallDiscardReasonBusy{}
|
||||||
case domain.PhoneCallDiscardReasonMigrateConference:
|
case domain.PhoneCallDiscardReasonMigrateConference:
|
||||||
return &tg.PhoneCallDiscardReasonMigrateConferenceCall{}
|
return &tg.PhoneCallDiscardReasonMigrateConferenceCall{Slug: slug}
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -188,3 +193,10 @@ func phoneCallDiscardReasonFromTL(r tg.PhoneCallDiscardReasonClass) domain.Phone
|
||||||
return domain.PhoneCallDiscardReasonHangup
|
return domain.PhoneCallDiscardReasonHangup
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func phoneCallDiscardReasonSlugFromTL(r tg.PhoneCallDiscardReasonClass) string {
|
||||||
|
if migrate, ok := r.(*tg.PhoneCallDiscardReasonMigrateConferenceCall); ok {
|
||||||
|
return migrate.Slug
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ func tgGroupCall(call domain.GroupCall, viewerUserID int64, canManage bool) tg.G
|
||||||
JoinMuted: call.JoinMuted,
|
JoinMuted: call.JoinMuted,
|
||||||
CanChangeJoinMuted: canManage,
|
CanChangeJoinMuted: canManage,
|
||||||
Creator: call.CreatorUserID == viewerUserID && viewerUserID != 0,
|
Creator: call.CreatorUserID == viewerUserID && viewerUserID != 0,
|
||||||
|
Conference: call.Conference(),
|
||||||
// can_start_video:TDesktop 不读;DrKLO 用它喂入会前 dummy self 行的
|
// can_start_video:TDesktop 不读;DrKLO 用它喂入会前 dummy self 行的
|
||||||
// video_joined。RTC 通话一律放行。
|
// video_joined。RTC 通话一律放行。
|
||||||
CanStartVideo: true,
|
CanStartVideo: true,
|
||||||
|
|
@ -41,6 +42,13 @@ func tgGroupCall(call domain.GroupCall, viewerUserID int64, canManage bool) tg.G
|
||||||
if call.Title != "" {
|
if call.Title != "" {
|
||||||
out.SetTitle(call.Title)
|
out.SetTitle(call.Title)
|
||||||
}
|
}
|
||||||
|
if call.Conference() {
|
||||||
|
if link := conferenceCanonicalInviteLink(call.InviteSlug); link != "" {
|
||||||
|
out.SetInviteLink(link)
|
||||||
|
} else if call.InviteLink != "" {
|
||||||
|
out.SetInviteLink(call.InviteLink)
|
||||||
|
}
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,7 @@ func tgContactsFound(viewerUserID int64, res domain.UserSearchResult) *tg.Contac
|
||||||
}
|
}
|
||||||
for _, ch := range res.MyChannelResults {
|
for _, ch := range res.MyChannelResults {
|
||||||
out.MyResults = append(out.MyResults, &tg.PeerChannel{ChannelID: ch.ID})
|
out.MyResults = append(out.MyResults, &tg.PeerChannel{ChannelID: ch.ID})
|
||||||
appendChannel(ch, nil)
|
appendChannel(ch, &domain.ChannelMember{ChannelID: ch.ID, UserID: viewerUserID, Status: domain.ChannelMemberActive})
|
||||||
}
|
}
|
||||||
for _, u := range res.Results {
|
for _, u := range res.Results {
|
||||||
out.Results = append(out.Results, &tg.PeerUser{UserID: u.ID})
|
out.Results = append(out.Results, &tg.PeerUser{UserID: u.ID})
|
||||||
|
|
|
||||||
|
|
@ -470,6 +470,7 @@ type ChannelsService interface {
|
||||||
SetWallpaper(ctx context.Context, userID int64, req domain.SetChannelWallpaperRequest) (domain.SetChannelWallpaperResult, error)
|
SetWallpaper(ctx context.Context, userID int64, req domain.SetChannelWallpaperRequest) (domain.SetChannelWallpaperResult, error)
|
||||||
EditAbout(ctx context.Context, userID int64, req domain.EditChannelAboutRequest) (domain.Channel, error)
|
EditAbout(ctx context.Context, userID int64, req domain.EditChannelAboutRequest) (domain.Channel, error)
|
||||||
EditAdmin(ctx context.Context, userID int64, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error)
|
EditAdmin(ctx context.Context, userID int64, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error)
|
||||||
|
TransferOwnership(ctx context.Context, userID int64, req domain.TransferChannelOwnershipRequest) (domain.TransferChannelOwnershipResult, error)
|
||||||
EditMemberRank(ctx context.Context, userID int64, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error)
|
EditMemberRank(ctx context.Context, userID int64, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error)
|
||||||
EditBanned(ctx context.Context, userID int64, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error)
|
EditBanned(ctx context.Context, userID int64, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error)
|
||||||
EditDefaultBannedRights(ctx context.Context, userID int64, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error)
|
EditDefaultBannedRights(ctx context.Context, userID int64, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error)
|
||||||
|
|
@ -760,6 +761,7 @@ type PhoneService interface {
|
||||||
AcceptCall(ctx context.Context, userID, callID, accessHash int64, gb []byte, proto domain.PhoneCallProtocol, device domain.SessionRef) (domain.PhoneCall, error)
|
AcceptCall(ctx context.Context, userID, callID, accessHash int64, gb []byte, proto domain.PhoneCallProtocol, device domain.SessionRef) (domain.PhoneCall, error)
|
||||||
ConfirmCall(ctx context.Context, userID, callID, accessHash int64, ga []byte, keyFingerprint int64, proto domain.PhoneCallProtocol) (call domain.PhoneCall, forcedDiscard bool, err error)
|
ConfirmCall(ctx context.Context, userID, callID, accessHash int64, ga []byte, keyFingerprint int64, proto domain.PhoneCallProtocol) (call domain.PhoneCall, forcedDiscard bool, err error)
|
||||||
DiscardCall(ctx context.Context, userID, callID, accessHash int64, reason domain.PhoneCallDiscardReason, duration int) (call domain.PhoneCall, already bool, err error)
|
DiscardCall(ctx context.Context, userID, callID, accessHash int64, reason domain.PhoneCallDiscardReason, duration int) (call domain.PhoneCall, already bool, err error)
|
||||||
|
DiscardCallWithSlug(ctx context.Context, userID, callID, accessHash int64, reason domain.PhoneCallDiscardReason, reasonSlug string, duration int) (call domain.PhoneCall, already bool, err error)
|
||||||
// Signal 在该通话的信令顺序锁内执行 forward;drop=true 表示按契约静默吞掉。
|
// Signal 在该通话的信令顺序锁内执行 forward;drop=true 表示按契约静默吞掉。
|
||||||
// peerDevice 是对端受理设备锚点(可零值/失效),定向推送失败须回退 user 扇出。
|
// peerDevice 是对端受理设备锚点(可零值/失效),定向推送失败须回退 user 扇出。
|
||||||
Signal(ctx context.Context, userID, callID, accessHash int64, forward func(peerUserID int64, peerDevice domain.SessionRef)) (drop bool, err error)
|
Signal(ctx context.Context, userID, callID, accessHash int64, forward func(peerUserID int64, peerDevice domain.SessionRef)) (drop bool, err error)
|
||||||
|
|
@ -772,9 +774,13 @@ type PhoneService interface {
|
||||||
// 错误集合见 domain.ErrGroupCall*(rpc 层映射为 GROUPCALL_* RPC_ERROR)。
|
// 错误集合见 domain.ErrGroupCall*(rpc 层映射为 GROUPCALL_* RPC_ERROR)。
|
||||||
type GroupCallsService interface {
|
type GroupCallsService interface {
|
||||||
Create(ctx context.Context, channelID, creatorUserID int64, title string, now int) (domain.GroupCall, error)
|
Create(ctx context.Context, channelID, creatorUserID int64, title string, now int) (domain.GroupCall, error)
|
||||||
|
CreateConference(ctx context.Context, creatorUserID, randomID, migratedFromPhoneCallID int64, now int) (domain.GroupCall, error)
|
||||||
Get(ctx context.Context, callID int64) (domain.GroupCall, bool, error)
|
Get(ctx context.Context, callID int64) (domain.GroupCall, bool, error)
|
||||||
|
GetBySlug(ctx context.Context, slug string) (domain.GroupCall, bool, error)
|
||||||
|
GetByInviteMessage(ctx context.Context, userID int64, msgID int) (domain.GroupCall, domain.GroupCallInvite, bool, error)
|
||||||
Join(ctx context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error)
|
Join(ctx context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error)
|
||||||
Leave(ctx context.Context, callID, userID int64, now int) (domain.GroupCallMutation, error)
|
Leave(ctx context.Context, callID, userID int64, now int) (domain.GroupCallMutation, error)
|
||||||
|
RemoveConferenceParticipants(ctx context.Context, req domain.RemoveConferenceCallParticipantsRequest) (domain.RemoveConferenceCallParticipantsResult, error)
|
||||||
Discard(ctx context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error)
|
Discard(ctx context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error)
|
||||||
Touch(ctx context.Context, callID, userID int64, now int) (activeSSRCs []int64, joined bool, err error)
|
Touch(ctx context.Context, callID, userID int64, now int) (activeSSRCs []int64, joined bool, err error)
|
||||||
Participant(ctx context.Context, callID, userID int64) (domain.GroupCallParticipant, bool, error)
|
Participant(ctx context.Context, callID, userID int64) (domain.GroupCallParticipant, bool, error)
|
||||||
|
|
@ -788,6 +794,11 @@ type GroupCallsService interface {
|
||||||
NextRaiseHandRating(ctx context.Context, callID int64) (int64, error)
|
NextRaiseHandRating(ctx context.Context, callID int64) (int64, error)
|
||||||
SetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64, override domain.GroupCallParticipantOverride, clear bool) error
|
SetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64, override domain.GroupCallParticipantOverride, clear bool) error
|
||||||
ParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64) (domain.GroupCallParticipantOverride, bool, error)
|
ParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64) (domain.GroupCallParticipantOverride, bool, error)
|
||||||
|
CreateConferenceInvite(ctx context.Context, invite domain.GroupCallInvite) (domain.GroupCallInvite, error)
|
||||||
|
SetConferenceInviteStatus(ctx context.Context, callID, inviteeUserID int64, msgID int, status domain.GroupCallInviteStatus, now int) (domain.GroupCallInvite, bool, error)
|
||||||
|
ConferenceRecipients(ctx context.Context, callID int64) ([]int64, error)
|
||||||
|
AppendChainBlock(ctx context.Context, block domain.GroupCallChainBlock) (domain.GroupCallChainBlock, error)
|
||||||
|
ChainBlocks(ctx context.Context, callID int64, subChainID, offset, limit int) (domain.GroupCallChainBlockPage, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PollsService 抽象 poll 权威态的发送时创建与投票人列表(messages.getPollVotes)。
|
// PollsService 抽象 poll 权威态的发送时创建与投票人列表(messages.getPollVotes)。
|
||||||
|
|
|
||||||
|
|
@ -307,11 +307,15 @@ func groupCallInvalidErr() error { return tgerr.New(400, "GROUPCALL_INV
|
||||||
func groupCallAlreadyDiscardedErr() error { return tgerr.New(400, "GROUPCALL_ALREADY_DISCARDED") }
|
func groupCallAlreadyDiscardedErr() error { return tgerr.New(400, "GROUPCALL_ALREADY_DISCARDED") }
|
||||||
func groupCallAlreadyStartedErr() error { return tgerr.New(400, "GROUPCALL_ALREADY_STARTED") }
|
func groupCallAlreadyStartedErr() error { return tgerr.New(400, "GROUPCALL_ALREADY_STARTED") }
|
||||||
func groupCallForbiddenErr() error { return tgerr.New(403, "GROUPCALL_FORBIDDEN") }
|
func groupCallForbiddenErr() error { return tgerr.New(403, "GROUPCALL_FORBIDDEN") }
|
||||||
|
func publicChannelMissingErr() error { return tgerr.New(403, "PUBLIC_CHANNEL_MISSING") }
|
||||||
func groupCallSSRCDuplicateErr() error {
|
func groupCallSSRCDuplicateErr() error {
|
||||||
return tgerr.New(400, "GROUPCALL_SSRC_DUPLICATE_MUCH")
|
return tgerr.New(400, "GROUPCALL_SSRC_DUPLICATE_MUCH")
|
||||||
}
|
}
|
||||||
func groupCallJoinMissingErr() error { return tgerr.New(400, "GROUPCALL_JOIN_MISSING") }
|
func groupCallJoinMissingErr() error { return tgerr.New(400, "GROUPCALL_JOIN_MISSING") }
|
||||||
func groupCallNotModifiedErr() error { return tgerr.New(400, "GROUPCALL_NOT_MODIFIED") }
|
func groupCallNotModifiedErr() error { return tgerr.New(400, "GROUPCALL_NOT_MODIFIED") }
|
||||||
|
func confWriteChainInvalidErr() error {
|
||||||
|
return tgerr.New(400, "CONF_WRITE_CHAIN_INVALID")
|
||||||
|
}
|
||||||
|
|
||||||
// 私聊端对端加密(Secret Chat / encrypted chat)错误;触发点见
|
// 私聊端对端加密(Secret Chat / encrypted chat)错误;触发点见
|
||||||
// internal/rpc/encrypted_chats.go 与 app/secretchat、domain 错误映射。
|
// internal/rpc/encrypted_chats.go 与 app/secretchat、domain 错误映射。
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,14 @@ func (d *GroupCallSweepDispatcher) DispatchOnce(ctx context.Context) {
|
||||||
if d.router.deps.SFU != nil {
|
if d.router.deps.SFU != nil {
|
||||||
_ = d.router.deps.SFU.Leave(ctx, mut.Call.ID, mut.Participant.UserID, sfu.EndpointMain)
|
_ = d.router.deps.SFU.Leave(ctx, mut.Call.ID, mut.Participant.UserID, sfu.EndpointMain)
|
||||||
}
|
}
|
||||||
|
if mut.Call.Conference() {
|
||||||
|
d.router.groupCallMutationFanout(ctx, domain.Channel{}, mut)
|
||||||
|
d.log.Info("conference call participant swept",
|
||||||
|
zap.Int64("call_id", mut.Call.ID),
|
||||||
|
zap.Int64("user_id", mut.Participant.UserID),
|
||||||
|
zap.String("state", string(mut.Call.State)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
channel, err := d.router.channelForGroupCall(ctx, mut.Call)
|
channel, err := d.router.channelForGroupCall(ctx, mut.Call)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,12 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.MessagesForwardMessagesRequest) (tg.UpdatesClass, error) {
|
func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.MessagesForwardMessagesRequest) (tg.UpdatesClass, error) {
|
||||||
if len(req.ID) == 0 || len(req.ID) != len(req.RandomID) {
|
ids, randomIDs, ok := normalizeForwardMessageVectors(req.ID, req.RandomID)
|
||||||
|
if !ok {
|
||||||
return nil, inputRequestInvalidErr()
|
return nil, inputRequestInvalidErr()
|
||||||
}
|
}
|
||||||
|
req.ID = ids
|
||||||
|
req.RandomID = randomIDs
|
||||||
if len(req.ID) > domain.MaxForwardMessageIDs {
|
if len(req.ID) > domain.MaxForwardMessageIDs {
|
||||||
return nil, limitInvalidErr()
|
return nil, limitInvalidErr()
|
||||||
}
|
}
|
||||||
|
|
@ -37,7 +40,7 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
|
||||||
if userID == 0 {
|
if userID == 0 {
|
||||||
return nil, peerIDInvalidErr()
|
return nil, peerIDInvalidErr()
|
||||||
}
|
}
|
||||||
fromPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.FromPeer)
|
fromPeer, preloadedSources, err := r.forwardFromPeerAndSources(ctx, userID, req.FromPeer, req.ID, req.RandomID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -69,22 +72,20 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for i, id := range req.ID {
|
if !forwardMessageIDsValid(req.ID, req.RandomID) {
|
||||||
if id <= 0 || id > domain.MaxMessageBoxID || req.RandomID[i] == 0 {
|
return nil, messageIDInvalidErr()
|
||||||
return nil, messageIDInvalidErr()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if err := r.checkSendRateLimit(ctx, userID, len(req.ID)); err != nil {
|
if err := r.checkSendRateLimit(ctx, userID, len(req.ID)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) {
|
if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) {
|
||||||
return r.scheduleForwardMessages(ctx, userID, fromPeer, toPeer, req, replyTo, sendAs)
|
return r.scheduleForwardMessages(ctx, userID, fromPeer, toPeer, req, replyTo, sendAs, preloadedSources)
|
||||||
}
|
}
|
||||||
if toPeer.Type == domain.PeerTypeChannel {
|
if toPeer.Type == domain.PeerTypeChannel {
|
||||||
if r.deps.Channels == nil {
|
if r.deps.Channels == nil {
|
||||||
return nil, peerIDInvalidErr()
|
return nil, peerIDInvalidErr()
|
||||||
}
|
}
|
||||||
sources, err := r.forwardSources(ctx, userID, fromPeer, req.ID)
|
sources, err := r.forwardSourcesForRequest(ctx, userID, fromPeer, req.ID, preloadedSources)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, messageForwardErr(err)
|
return nil, messageForwardErr(err)
|
||||||
}
|
}
|
||||||
|
|
@ -152,7 +153,7 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
|
||||||
// 私聊源与频道源统一经 forwardSources 取源:首次生成的 forward header 在
|
// 私聊源与频道源统一经 forwardSources 取源:首次生成的 forward header 在
|
||||||
// forwardSources 内已按原作者 PrivacyKeyForwards 降级(不允许链接回账号时仅保
|
// forwardSources 内已按原作者 PrivacyKeyForwards 降级(不允许链接回账号时仅保
|
||||||
// 留 from_name),避免私聊→私聊路径泄漏原作者可点击账号;media 也随 source 透传。
|
// 留 from_name),避免私聊→私聊路径泄漏原作者可点击账号;media 也随 source 透传。
|
||||||
sources, err := r.forwardSources(ctx, userID, fromPeer, req.ID)
|
sources, err := r.forwardSourcesForRequest(ctx, userID, fromPeer, req.ID, preloadedSources)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, messageForwardErr(err)
|
return nil, messageForwardErr(err)
|
||||||
}
|
}
|
||||||
|
|
@ -200,6 +201,110 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
|
||||||
return nil, peerIDInvalidErr()
|
return nil, peerIDInvalidErr()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeForwardMessageVectors(ids []int, randomIDs []int64) ([]int, []int64, bool) {
|
||||||
|
if len(ids) == 0 || len(randomIDs) == 0 {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
if len(ids) == len(randomIDs) {
|
||||||
|
return ids, randomIDs, true
|
||||||
|
}
|
||||||
|
if len(ids) < len(randomIDs) {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
compact := make([]int, 0, len(randomIDs))
|
||||||
|
runLength := 0
|
||||||
|
for i, id := range ids {
|
||||||
|
if i == 0 || id != ids[i-1] {
|
||||||
|
compact = append(compact, id)
|
||||||
|
runLength = 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
runLength++
|
||||||
|
if runLength > 2 {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(compact) != len(randomIDs) {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
return compact, randomIDs, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) forwardFromPeerAndSources(ctx context.Context, userID int64, input tg.InputPeerClass, ids []int, randomIDs []int64) (domain.Peer, []forwardSource, error) {
|
||||||
|
if forwardFromPeerIsEmpty(input) {
|
||||||
|
if !forwardMessageIDsValid(ids, randomIDs) {
|
||||||
|
return domain.Peer{}, nil, messageIDInvalidErr()
|
||||||
|
}
|
||||||
|
fromPeer, sources, err := r.forwardSourcesFromEmptyPeer(ctx, userID, ids)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Peer{}, nil, messageForwardErr(err)
|
||||||
|
}
|
||||||
|
return fromPeer, sources, nil
|
||||||
|
}
|
||||||
|
fromPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input)
|
||||||
|
return fromPeer, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func forwardMessageIDsValid(ids []int, randomIDs []int64) bool {
|
||||||
|
if len(ids) == 0 || len(ids) != len(randomIDs) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, id := range ids {
|
||||||
|
if id <= 0 || id > domain.MaxMessageBoxID || randomIDs[i] == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func forwardFromPeerIsEmpty(peer tg.InputPeerClass) bool {
|
||||||
|
if inputPeerClassNil(peer) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := peer.(*tg.InputPeerEmpty)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) forwardSourcesFromEmptyPeer(ctx context.Context, userID int64, ids []int) (domain.Peer, []forwardSource, error) {
|
||||||
|
if r.deps.Messages == nil {
|
||||||
|
return domain.Peer{}, nil, domain.ErrMessageIDInvalid
|
||||||
|
}
|
||||||
|
list, err := r.deps.Messages.GetMessages(ctx, userID, ids)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Peer{}, nil, domain.ErrMessageIDInvalid
|
||||||
|
}
|
||||||
|
var fromPeer domain.Peer
|
||||||
|
byID := make(map[int]domain.Message, len(list.Messages))
|
||||||
|
for _, msg := range list.Messages {
|
||||||
|
byID[msg.ID] = msg
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
msg, ok := byID[id]
|
||||||
|
if !ok || msg.Peer.Type != domain.PeerTypeUser || msg.Peer.ID == 0 {
|
||||||
|
return domain.Peer{}, nil, domain.ErrMessageIDInvalid
|
||||||
|
}
|
||||||
|
if fromPeer.ID == 0 {
|
||||||
|
fromPeer = msg.Peer
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if msg.Peer != fromPeer {
|
||||||
|
return domain.Peer{}, nil, domain.ErrMessageIDInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sources, err := r.forwardSourcesFromPrivateMessages(ctx, userID, fromPeer, ids, list.Messages)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Peer{}, nil, err
|
||||||
|
}
|
||||||
|
return fromPeer, sources, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) forwardSourcesForRequest(ctx context.Context, userID int64, fromPeer domain.Peer, ids []int, preloaded []forwardSource) ([]forwardSource, error) {
|
||||||
|
if preloaded != nil {
|
||||||
|
return preloaded, nil
|
||||||
|
}
|
||||||
|
return r.forwardSources(ctx, userID, fromPeer, ids)
|
||||||
|
}
|
||||||
|
|
||||||
func mergeForwardTopMsgID(toPeer domain.Peer, replyTo *domain.MessageReply, topMsgID int, topMsgIDSet bool) (*domain.MessageReply, error) {
|
func mergeForwardTopMsgID(toPeer domain.Peer, replyTo *domain.MessageReply, topMsgID int, topMsgIDSet bool) (*domain.MessageReply, error) {
|
||||||
if !topMsgIDSet || topMsgID == 0 {
|
if !topMsgIDSet || topMsgID == 0 {
|
||||||
return replyTo, nil
|
return replyTo, nil
|
||||||
|
|
@ -241,36 +346,11 @@ func (r *Router) forwardSources(ctx context.Context, userID int64, fromPeer doma
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, domain.ErrMessageIDInvalid
|
return nil, domain.ErrMessageIDInvalid
|
||||||
}
|
}
|
||||||
byID := make(map[int]domain.Message, len(list.Messages))
|
sources, err := r.forwardSourcesFromPrivateMessages(ctx, userID, fromPeer, ids, list.Messages)
|
||||||
for _, msg := range list.Messages {
|
if err != nil {
|
||||||
byID[msg.ID] = msg
|
return nil, err
|
||||||
}
|
|
||||||
for _, id := range ids {
|
|
||||||
msg, ok := byID[id]
|
|
||||||
if !ok {
|
|
||||||
return nil, domain.ErrMessageIDInvalid
|
|
||||||
}
|
|
||||||
if msg.Peer != fromPeer {
|
|
||||||
return nil, domain.ErrMessageIDInvalid
|
|
||||||
}
|
|
||||||
if msg.NoForwards {
|
|
||||||
return nil, domain.ErrChatForwardsRestricted
|
|
||||||
}
|
|
||||||
forward := cloneDomainMessageForward(msg.Forward)
|
|
||||||
if forward == nil {
|
|
||||||
forward = &domain.MessageForward{From: msg.From, Date: msg.Date}
|
|
||||||
r.applyForwardAuthorPrivacy(ctx, userID, forward)
|
|
||||||
}
|
|
||||||
out = append(out, forwardSource{
|
|
||||||
body: msg.Body,
|
|
||||||
entities: append([]domain.MessageEntity(nil),
|
|
||||||
msg.Entities...),
|
|
||||||
media: msg.Media,
|
|
||||||
forward: forward,
|
|
||||||
from: msg.From,
|
|
||||||
date: msg.Date,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
out = append(out, sources...)
|
||||||
case domain.PeerTypeChannel:
|
case domain.PeerTypeChannel:
|
||||||
if r.deps.Channels == nil {
|
if r.deps.Channels == nil {
|
||||||
return nil, domain.ErrMessageIDInvalid
|
return nil, domain.ErrMessageIDInvalid
|
||||||
|
|
@ -325,6 +405,44 @@ func (r *Router) forwardSources(ctx context.Context, userID int64, fromPeer doma
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Router) forwardSourcesFromPrivateMessages(ctx context.Context, userID int64, fromPeer domain.Peer, ids []int, messages []domain.Message) ([]forwardSource, error) {
|
||||||
|
if fromPeer.Type != domain.PeerTypeUser || fromPeer.ID == 0 {
|
||||||
|
return nil, domain.ErrMessageIDInvalid
|
||||||
|
}
|
||||||
|
byID := make(map[int]domain.Message, len(messages))
|
||||||
|
for _, msg := range messages {
|
||||||
|
byID[msg.ID] = msg
|
||||||
|
}
|
||||||
|
out := make([]forwardSource, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
msg, ok := byID[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.ErrMessageIDInvalid
|
||||||
|
}
|
||||||
|
if msg.Peer != fromPeer {
|
||||||
|
return nil, domain.ErrMessageIDInvalid
|
||||||
|
}
|
||||||
|
if msg.NoForwards {
|
||||||
|
return nil, domain.ErrChatForwardsRestricted
|
||||||
|
}
|
||||||
|
forward := cloneDomainMessageForward(msg.Forward)
|
||||||
|
if forward == nil {
|
||||||
|
forward = &domain.MessageForward{From: msg.From, Date: msg.Date}
|
||||||
|
r.applyForwardAuthorPrivacy(ctx, userID, forward)
|
||||||
|
}
|
||||||
|
out = append(out, forwardSource{
|
||||||
|
body: msg.Body,
|
||||||
|
entities: append([]domain.MessageEntity(nil),
|
||||||
|
msg.Entities...),
|
||||||
|
media: msg.Media,
|
||||||
|
forward: forward,
|
||||||
|
from: msg.From,
|
||||||
|
date: msg.Date,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func cloneDomainMessageForward(in *domain.MessageForward) *domain.MessageForward {
|
func cloneDomainMessageForward(in *domain.MessageForward) *domain.MessageForward {
|
||||||
if in == nil {
|
if in == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,200 @@ func TestMessagesForwardMessagesLoadsPrivateSourcesInSingleBatch(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMessagesForwardMessagesInfersPrivateSourceFromInputPeerEmpty(t *testing.T) {
|
||||||
|
const (
|
||||||
|
ownerID = int64(1780243210)
|
||||||
|
fromID = int64(1780243211)
|
||||||
|
toID = int64(1780243212)
|
||||||
|
)
|
||||||
|
ctx := context.Background()
|
||||||
|
messages := &captureMessages{
|
||||||
|
getMessagesListed: true,
|
||||||
|
list: domain.MessageList{Messages: []domain.Message{
|
||||||
|
{
|
||||||
|
ID: 189,
|
||||||
|
OwnerUserID: ownerID,
|
||||||
|
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fromID},
|
||||||
|
From: domain.Peer{Type: domain.PeerTypeUser, ID: fromID},
|
||||||
|
Date: 1700002189,
|
||||||
|
Body: "android source",
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
r := New(Config{}, Deps{
|
||||||
|
Messages: messages,
|
||||||
|
Users: mapUsersService{users: map[int64]domain.User{
|
||||||
|
ownerID: {ID: ownerID, FirstName: "Owner"},
|
||||||
|
fromID: {ID: fromID, FirstName: "From"},
|
||||||
|
toID: {ID: toID, FirstName: "To"},
|
||||||
|
}},
|
||||||
|
}, zaptest.NewLogger(t), clock.System)
|
||||||
|
|
||||||
|
updatesClass, err := r.onMessagesForwardMessages(WithUserID(ctx, ownerID), &tg.MessagesForwardMessagesRequest{
|
||||||
|
FromPeer: &tg.InputPeerEmpty{},
|
||||||
|
ToPeer: &tg.InputPeerUser{UserID: toID},
|
||||||
|
ID: []int{189},
|
||||||
|
RandomID: []int64{5069400637215652584},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("forward with empty source peer: %v", err)
|
||||||
|
}
|
||||||
|
if messages.getMessagesCalls != 1 || len(messages.getMessagesIDs) != 1 || len(messages.getMessagesIDs[0]) != 1 || messages.getMessagesIDs[0][0] != 189 {
|
||||||
|
t.Fatalf("GetMessages calls=%d ids=%+v, want one source lookup for [189]", messages.getMessagesCalls, messages.getMessagesIDs)
|
||||||
|
}
|
||||||
|
if messages.sendReq.RecipientUserID != toID || messages.sendReq.Message != "android source" {
|
||||||
|
t.Fatalf("send request = %+v, want inferred source body to target", messages.sendReq)
|
||||||
|
}
|
||||||
|
if messages.sendReq.Forward == nil || messages.sendReq.Forward.From != (domain.Peer{Type: domain.PeerTypeUser, ID: fromID}) {
|
||||||
|
t.Fatalf("forward header = %+v, want original author %d", messages.sendReq.Forward, fromID)
|
||||||
|
}
|
||||||
|
updates, ok := updatesClass.(*tg.Updates)
|
||||||
|
if !ok || len(updates.Updates) != 2 {
|
||||||
|
t.Fatalf("updates = %T %+v, want updateMessageID + updateNewMessage", updatesClass, updatesClass)
|
||||||
|
}
|
||||||
|
if id, ok := updates.Updates[0].(*tg.UpdateMessageID); !ok || id.RandomID != 5069400637215652584 {
|
||||||
|
t.Fatalf("first update = %#v, want request random id", updates.Updates[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessagesForwardMessagesInputPeerEmptyRejectsMixedPrivateSources(t *testing.T) {
|
||||||
|
const (
|
||||||
|
ownerID = int64(1780243210)
|
||||||
|
fromA = int64(1780243211)
|
||||||
|
fromB = int64(1780243212)
|
||||||
|
toID = int64(1780243213)
|
||||||
|
)
|
||||||
|
ctx := context.Background()
|
||||||
|
messages := &captureMessages{
|
||||||
|
getMessagesListed: true,
|
||||||
|
list: domain.MessageList{Messages: []domain.Message{
|
||||||
|
{ID: 10, OwnerUserID: ownerID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fromA}, From: domain.Peer{Type: domain.PeerTypeUser, ID: fromA}, Date: 1700002210, Body: "first"},
|
||||||
|
{ID: 11, OwnerUserID: ownerID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fromB}, From: domain.Peer{Type: domain.PeerTypeUser, ID: fromB}, Date: 1700002211, Body: "second"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
r := New(Config{}, Deps{
|
||||||
|
Messages: messages,
|
||||||
|
Users: mapUsersService{users: map[int64]domain.User{
|
||||||
|
ownerID: {ID: ownerID, FirstName: "Owner"},
|
||||||
|
fromA: {ID: fromA, FirstName: "FromA"},
|
||||||
|
fromB: {ID: fromB, FirstName: "FromB"},
|
||||||
|
toID: {ID: toID, FirstName: "To"},
|
||||||
|
}},
|
||||||
|
}, zaptest.NewLogger(t), clock.System)
|
||||||
|
|
||||||
|
_, err := r.onMessagesForwardMessages(WithUserID(ctx, ownerID), &tg.MessagesForwardMessagesRequest{
|
||||||
|
FromPeer: &tg.InputPeerEmpty{},
|
||||||
|
ToPeer: &tg.InputPeerUser{UserID: toID},
|
||||||
|
ID: []int{10, 11},
|
||||||
|
RandomID: []int64{10010, 10011},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
|
||||||
|
t.Fatalf("forward mixed empty-source ids err = %v, want MESSAGE_ID_INVALID", err)
|
||||||
|
}
|
||||||
|
if messages.sendReq.RecipientUserID != 0 {
|
||||||
|
t.Fatalf("send request = %+v, want no send after mixed source rejection", messages.sendReq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessagesForwardMessagesInputPeerEmptyRejectsBadIDsBeforeLookup(t *testing.T) {
|
||||||
|
const ownerID = int64(1780243210)
|
||||||
|
ctx := context.Background()
|
||||||
|
messages := &captureMessages{}
|
||||||
|
r := New(Config{}, Deps{
|
||||||
|
Messages: messages,
|
||||||
|
}, zaptest.NewLogger(t), clock.System)
|
||||||
|
|
||||||
|
_, err := r.onMessagesForwardMessages(WithUserID(ctx, ownerID), &tg.MessagesForwardMessagesRequest{
|
||||||
|
FromPeer: &tg.InputPeerEmpty{},
|
||||||
|
ToPeer: &tg.InputPeerUser{UserID: 1780243211},
|
||||||
|
ID: []int{0},
|
||||||
|
RandomID: []int64{10001},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
|
||||||
|
t.Fatalf("forward bad empty-source id err = %v, want MESSAGE_ID_INVALID", err)
|
||||||
|
}
|
||||||
|
if messages.getMessagesCalls != 0 {
|
||||||
|
t.Fatalf("GetMessages calls = %d, want no source lookup for invalid id", messages.getMessagesCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessagesForwardMessagesNormalizesAndroidDuplicateIDRetry(t *testing.T) {
|
||||||
|
const (
|
||||||
|
ownerID = int64(1780243210)
|
||||||
|
fromID = int64(1780243211)
|
||||||
|
)
|
||||||
|
ctx := context.Background()
|
||||||
|
messages := &captureMessages{
|
||||||
|
getMessagesListed: true,
|
||||||
|
list: domain.MessageList{Messages: []domain.Message{
|
||||||
|
{
|
||||||
|
ID: 187,
|
||||||
|
OwnerUserID: ownerID,
|
||||||
|
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fromID},
|
||||||
|
From: domain.Peer{Type: domain.PeerTypeUser, ID: fromID},
|
||||||
|
Date: 1700002187,
|
||||||
|
Body: "retry source",
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
r := New(Config{}, Deps{
|
||||||
|
Messages: messages,
|
||||||
|
Users: mapUsersService{users: map[int64]domain.User{
|
||||||
|
ownerID: {ID: ownerID, FirstName: "Owner"},
|
||||||
|
fromID: {ID: fromID, FirstName: "From"},
|
||||||
|
}},
|
||||||
|
}, zaptest.NewLogger(t), clock.System)
|
||||||
|
|
||||||
|
updatesClass, err := r.onMessagesForwardMessages(WithUserID(ctx, ownerID), &tg.MessagesForwardMessagesRequest{
|
||||||
|
FromPeer: &tg.InputPeerEmpty{},
|
||||||
|
ToPeer: &tg.InputPeerUser{UserID: fromID},
|
||||||
|
ID: []int{187, 187},
|
||||||
|
RandomID: []int64{1993272996073519809},
|
||||||
|
DropAuthor: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("forward android duplicate-id retry: %v", err)
|
||||||
|
}
|
||||||
|
if messages.getMessagesCalls != 1 || len(messages.getMessagesIDs) != 1 || len(messages.getMessagesIDs[0]) != 1 || messages.getMessagesIDs[0][0] != 187 {
|
||||||
|
t.Fatalf("GetMessages calls=%d ids=%+v, want one normalized source lookup for [187]", messages.getMessagesCalls, messages.getMessagesIDs)
|
||||||
|
}
|
||||||
|
if messages.sendReq.RecipientUserID != fromID || messages.sendReq.Message != "retry source" {
|
||||||
|
t.Fatalf("send request = %+v, want one forwarded message to current peer", messages.sendReq)
|
||||||
|
}
|
||||||
|
if messages.sendReq.Forward != nil {
|
||||||
|
t.Fatalf("forward header = %+v, want dropped author", messages.sendReq.Forward)
|
||||||
|
}
|
||||||
|
updates, ok := updatesClass.(*tg.Updates)
|
||||||
|
if !ok || len(updates.Updates) != 2 {
|
||||||
|
t.Fatalf("updates = %T %+v, want one updateMessageID + one updateNewMessage", updatesClass, updatesClass)
|
||||||
|
}
|
||||||
|
if id, ok := updates.Updates[0].(*tg.UpdateMessageID); !ok || id.RandomID != 1993272996073519809 {
|
||||||
|
t.Fatalf("first update = %#v, want normalized random id", updates.Updates[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessagesForwardMessagesRejectsUnpairedIDRandomVectors(t *testing.T) {
|
||||||
|
const ownerID = int64(1780243210)
|
||||||
|
ctx := context.Background()
|
||||||
|
messages := &captureMessages{}
|
||||||
|
r := New(Config{}, Deps{
|
||||||
|
Messages: messages,
|
||||||
|
}, zaptest.NewLogger(t), clock.System)
|
||||||
|
|
||||||
|
_, err := r.onMessagesForwardMessages(WithUserID(ctx, ownerID), &tg.MessagesForwardMessagesRequest{
|
||||||
|
FromPeer: &tg.InputPeerEmpty{},
|
||||||
|
ToPeer: &tg.InputPeerUser{UserID: 1780243211},
|
||||||
|
ID: []int{187, 188},
|
||||||
|
RandomID: []int64{1993272996073519809},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "INPUT_REQUEST_INVALID") {
|
||||||
|
t.Fatalf("forward unpaired vectors err = %v, want INPUT_REQUEST_INVALID", err)
|
||||||
|
}
|
||||||
|
if messages.getMessagesCalls != 0 {
|
||||||
|
t.Fatalf("GetMessages calls = %d, want no source lookup for unpaired vectors", messages.getMessagesCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChatsForMessageUpdatesUsesBatchChannelProjection(t *testing.T) {
|
func TestChatsForMessageUpdatesUsesBatchChannelProjection(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
userStore := memory.NewUserStore()
|
userStore := memory.NewUserStore()
|
||||||
|
|
|
||||||
|
|
@ -365,12 +365,12 @@ func sentMessageIDFromUpdates(updates tg.UpdatesClass) int {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) scheduleForwardMessages(ctx context.Context, userID int64, fromPeer, toPeer domain.Peer, req *tg.MessagesForwardMessagesRequest, replyTo *domain.MessageReply, sendAs *domain.Peer) (tg.UpdatesClass, error) {
|
func (r *Router) scheduleForwardMessages(ctx context.Context, userID int64, fromPeer, toPeer domain.Peer, req *tg.MessagesForwardMessagesRequest, replyTo *domain.MessageReply, sendAs *domain.Peer, preloadedSources []forwardSource) (tg.UpdatesClass, error) {
|
||||||
scheduledSvc, ok := r.deps.Messages.(scheduledMessagesService)
|
scheduledSvc, ok := r.deps.Messages.(scheduledMessagesService)
|
||||||
if r.deps.Messages == nil || !ok {
|
if r.deps.Messages == nil || !ok {
|
||||||
return nil, peerIDInvalidErr()
|
return nil, peerIDInvalidErr()
|
||||||
}
|
}
|
||||||
sources, err := r.forwardSources(ctx, userID, fromPeer, req.ID)
|
sources, err := r.forwardSourcesForRequest(ctx, userID, fromPeer, req.ID, preloadedSources)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, messageForwardErr(err)
|
return nil, messageForwardErr(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -210,15 +210,28 @@ func (r *Router) onPhoneDiscardCall(ctx context.Context, req *tg.PhoneDiscardCal
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
reason := phoneCallDiscardReasonFromTL(req.Reason)
|
reason := phoneCallDiscardReasonFromTL(req.Reason)
|
||||||
call, already, err := r.deps.Phone.DiscardCall(ctx, userID, req.Peer.ID, req.Peer.AccessHash, reason, req.Duration)
|
reasonSlug := phoneCallDiscardReasonSlugFromTL(req.Reason)
|
||||||
|
if reason == domain.PhoneCallDiscardReasonMigrateConference {
|
||||||
|
if reasonSlug == "" || r.deps.GroupCalls == nil {
|
||||||
|
return nil, groupCallInvalidErr()
|
||||||
|
}
|
||||||
|
if call, found, err := r.deps.GroupCalls.GetBySlug(ctx, reasonSlug); err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
} else if !found || !call.Conference() || !call.Active() {
|
||||||
|
return nil, groupCallInvalidErr()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
call, already, err := r.deps.Phone.DiscardCallWithSlug(ctx, userID, req.Peer.ID, req.Peer.AccessHash, reason, reasonSlug, req.Duration)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, phoneCallErr(err)
|
return nil, phoneCallErr(err)
|
||||||
}
|
}
|
||||||
if !already {
|
if !already {
|
||||||
// 对端全部设备 + 发起者其它设备(ctx except 排除发起设备,其结果在 RPC 响应里)。
|
// 对端全部设备 + 发起者其它设备(ctx except 排除发起设备,其结果在 RPC 响应里)。
|
||||||
r.pushPhoneCallDiscardedBoth(ctx, call)
|
r.pushPhoneCallDiscardedBoth(ctx, call)
|
||||||
// 落 messageActionPhoneCall 历史(带 pts 走 outbox,双方全部设备可靠收到)。
|
if reason != domain.PhoneCallDiscardReasonMigrateConference {
|
||||||
r.sendPhoneCallServiceMessage(ctx, call)
|
// 落 messageActionPhoneCall 历史(带 pts 走 outbox,双方全部设备可靠收到)。
|
||||||
|
r.sendPhoneCallServiceMessage(ctx, call)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 双方同时挂断的竞态:先到者定 reason,后到者拿终态快照(幂等成功)。
|
// 双方同时挂断的竞态:先到者定 reason,后到者拿终态快照(幂等成功)。
|
||||||
return r.phoneCallUpdates(ctx, call, userID), nil
|
return r.phoneCallUpdates(ctx, call, userID), nil
|
||||||
|
|
|
||||||
468
internal/rpc/phone_conference.go
Normal file
468
internal/rpc/phone_conference.go
Normal file
|
|
@ -0,0 +1,468 @@
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/sfu"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxConferenceChainBlockBytes = 64 * 1024
|
||||||
|
maxConferenceChainBlocks = 100
|
||||||
|
|
||||||
|
conferenceChainBlockConstructor = 0x639a3db6
|
||||||
|
conferenceChainBlockServerConstructor = 0x639a3db7
|
||||||
|
conferenceBroadcastCommitConstructor = 0xd1512ae7
|
||||||
|
conferenceBroadcastCommitServerConstructor = 0xd1512ae8
|
||||||
|
conferenceBroadcastRevealConstructor = 0x83f4f9d8
|
||||||
|
conferenceBroadcastRevealServerConstructor = 0x83f4f9d9
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *Router) onPhoneCreateConferenceCall(ctx context.Context, req *tg.PhoneCreateConferenceCallRequest) (tg.UpdatesClass, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, inputRequestInvalidErr()
|
||||||
|
}
|
||||||
|
if r.deps.GroupCalls == nil {
|
||||||
|
return nil, notImplementedErr()
|
||||||
|
}
|
||||||
|
userID, err := r.phoneRequireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
now := int(r.clock.Now().Unix())
|
||||||
|
call, err := r.deps.GroupCalls.CreateConference(ctx, userID, int64(req.RandomID), 0, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, groupCallErr(err)
|
||||||
|
}
|
||||||
|
out := r.groupCallUpdateContainer(ctx, userID, domain.Channel{},
|
||||||
|
&tg.UpdateGroupCall{Call: tgGroupCall(call, userID, true)}, []int64{userID})
|
||||||
|
if !req.Join {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
params, ok := req.GetParams()
|
||||||
|
if !ok {
|
||||||
|
return nil, groupCallInvalidErr()
|
||||||
|
}
|
||||||
|
joinReq := &tg.PhoneJoinGroupCallRequest{
|
||||||
|
Muted: req.Muted,
|
||||||
|
VideoStopped: req.VideoStopped,
|
||||||
|
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
|
||||||
|
JoinAs: &tg.InputPeerSelf{},
|
||||||
|
Params: params,
|
||||||
|
}
|
||||||
|
if pk, ok := req.GetPublicKey(); ok {
|
||||||
|
joinReq.SetPublicKey(pk)
|
||||||
|
}
|
||||||
|
if block, ok := req.GetBlock(); ok {
|
||||||
|
joinReq.SetBlock(block)
|
||||||
|
}
|
||||||
|
joinUpdates, err := r.onPhoneJoinGroupCall(ctx, joinReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
appendUpdates(out, joinUpdates)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) onPhoneExportGroupCallInvite(ctx context.Context, req *tg.PhoneExportGroupCallInviteRequest) (*tg.PhoneExportedGroupCallInvite, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, inputRequestInvalidErr()
|
||||||
|
}
|
||||||
|
scope, err := r.groupCallScopeFrom(ctx, req.Call)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !scope.call.Active() {
|
||||||
|
return nil, groupCallInvalidErr()
|
||||||
|
}
|
||||||
|
if scope.call.Conference() {
|
||||||
|
link := conferenceExportInviteLink(scope.call)
|
||||||
|
if link == "" {
|
||||||
|
return nil, groupCallInvalidErr()
|
||||||
|
}
|
||||||
|
return &tg.PhoneExportedGroupCallInvite{Link: link}, nil
|
||||||
|
}
|
||||||
|
if scope.call.InviteLink != "" {
|
||||||
|
return &tg.PhoneExportedGroupCallInvite{Link: scope.call.InviteLink}, nil
|
||||||
|
}
|
||||||
|
if scope.channel.Username == "" {
|
||||||
|
return nil, publicChannelMissingErr()
|
||||||
|
}
|
||||||
|
return &tg.PhoneExportedGroupCallInvite{Link: "https://telesrv.net/" + scope.channel.Username}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func conferenceExportInviteLink(call domain.GroupCall) string {
|
||||||
|
if link := conferenceCanonicalInviteLink(call.InviteSlug); link != "" {
|
||||||
|
return link
|
||||||
|
}
|
||||||
|
return call.InviteLink
|
||||||
|
}
|
||||||
|
|
||||||
|
func conferenceCanonicalInviteLink(slug string) string {
|
||||||
|
if slug == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "https://telesrv.net/call/" + slug + "?slug=" + slug
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) onPhoneInviteConferenceCallParticipant(ctx context.Context, req *tg.PhoneInviteConferenceCallParticipantRequest) (tg.UpdatesClass, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, inputRequestInvalidErr()
|
||||||
|
}
|
||||||
|
if r.deps.GroupCalls == nil || r.deps.Messages == nil {
|
||||||
|
return nil, notImplementedErr()
|
||||||
|
}
|
||||||
|
scope, err := r.groupCallScopeFrom(ctx, req.Call)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !scope.call.Conference() || !scope.call.Active() {
|
||||||
|
return nil, groupCallInvalidErr()
|
||||||
|
}
|
||||||
|
target, found, err := r.userFromInput(ctx, scope.userID, req.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
if !found || target.ID == 0 || target.Bot || target.ID == scope.userID {
|
||||||
|
return nil, userIDInvalidErr()
|
||||||
|
}
|
||||||
|
if p, found, err := r.deps.GroupCalls.Participant(ctx, scope.call.ID, target.ID); err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
} else if found && !p.Left {
|
||||||
|
return nil, tgerr400("USER_ALREADY_PARTICIPANT")
|
||||||
|
}
|
||||||
|
recipientBlocked, err := r.peerBlocksUser(ctx, scope.userID, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
now := int(r.clock.Now().Unix())
|
||||||
|
res, err := r.deps.Messages.SendPrivateText(ctx, scope.userID, domain.SendPrivateTextRequest{
|
||||||
|
SenderUserID: scope.userID,
|
||||||
|
RecipientUserID: target.ID,
|
||||||
|
RandomID: conferenceInviteRandomID(scope.call.ID, target.ID, now),
|
||||||
|
Media: &domain.MessageMedia{
|
||||||
|
Kind: domain.MessageMediaKindService,
|
||||||
|
ServiceAction: &domain.MessageServiceAction{
|
||||||
|
Kind: domain.MessageServiceActionConferenceCall,
|
||||||
|
ConferenceCall: &domain.MessageConferenceCallAction{
|
||||||
|
CallID: scope.call.ID,
|
||||||
|
Video: req.Video,
|
||||||
|
OtherParticipants: []domain.Peer{
|
||||||
|
{Type: domain.PeerTypeUser, ID: scope.userID},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Date: now,
|
||||||
|
OriginAuthKeyID: authKeyIDFromCtx(ctx),
|
||||||
|
OriginSessionID: sessionIDFromCtx(ctx),
|
||||||
|
RecipientBlocked: recipientBlocked,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, messageSendErr(err)
|
||||||
|
}
|
||||||
|
invite, err := r.deps.GroupCalls.CreateConferenceInvite(ctx, domain.GroupCallInvite{
|
||||||
|
CallID: scope.call.ID,
|
||||||
|
InviterUserID: scope.userID,
|
||||||
|
InviteeUserID: target.ID,
|
||||||
|
MessageID: res.RecipientMessage.ID,
|
||||||
|
Status: domain.GroupCallInvitePending,
|
||||||
|
Video: req.Video,
|
||||||
|
CreatedAt: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, groupCallErr(err)
|
||||||
|
}
|
||||||
|
_ = invite
|
||||||
|
users := r.tgUsersForIDs(ctx, scope.userID, []int64{scope.userID, target.ID})
|
||||||
|
out := tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, 0, false, users, nil)
|
||||||
|
recipientUsers := r.tgUsersForIDs(ctx, target.ID, []int64{scope.userID, target.ID})
|
||||||
|
r.pushUserMessage(ctx, target.ID, "conference invite",
|
||||||
|
tgPrivateMessageUpdates(res.RecipientEvent, res.RecipientMessage, 0, false, recipientUsers, nil))
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) onPhoneDeclineConferenceCallInvite(ctx context.Context, msgID int) (tg.UpdatesClass, error) {
|
||||||
|
if r.deps.GroupCalls == nil {
|
||||||
|
return nil, notImplementedErr()
|
||||||
|
}
|
||||||
|
userID, err := r.phoneRequireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
call, inv, found, err := r.deps.GroupCalls.GetByInviteMessage(ctx, userID, msgID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
if !found || !call.Conference() {
|
||||||
|
return nil, msgIDInvalidErr()
|
||||||
|
}
|
||||||
|
now := int(r.clock.Now().Unix())
|
||||||
|
if _, _, err := r.deps.GroupCalls.SetConferenceInviteStatus(ctx, call.ID, userID, msgID, domain.GroupCallInviteDeclined, now); err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
r.pushConferenceGroupCallUpdate(ctx, call)
|
||||||
|
return r.groupCallUpdateContainer(ctx, userID, domain.Channel{},
|
||||||
|
&tg.UpdateGroupCall{Call: tgGroupCall(call, userID, userID == call.CreatorUserID)}, []int64{inv.InviterUserID, inv.InviteeUserID}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) onPhoneDeleteConferenceCallParticipants(ctx context.Context, req *tg.PhoneDeleteConferenceCallParticipantsRequest) (tg.UpdatesClass, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, inputRequestInvalidErr()
|
||||||
|
}
|
||||||
|
if len(req.IDs) == 0 || len(req.IDs) > 100 {
|
||||||
|
return nil, limitInvalidErr()
|
||||||
|
}
|
||||||
|
if len(req.Block) > maxConferenceChainBlockBytes {
|
||||||
|
return nil, limitInvalidErr()
|
||||||
|
}
|
||||||
|
scope, err := r.groupCallScopeFrom(ctx, req.Call)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !scope.call.Conference() {
|
||||||
|
return nil, groupCallInvalidErr()
|
||||||
|
}
|
||||||
|
if req.OnlyLeft == req.Kick {
|
||||||
|
return nil, inputRequestInvalidErr()
|
||||||
|
}
|
||||||
|
now := int(r.clock.Now().Unix())
|
||||||
|
if req.OnlyLeft && !scope.canManage() {
|
||||||
|
self, found, err := r.deps.GroupCalls.Participant(ctx, scope.call.ID, scope.userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
if !found || self.Left {
|
||||||
|
return nil, groupCallForbiddenErr()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, targetID := range req.IDs {
|
||||||
|
if targetID <= 0 {
|
||||||
|
return nil, userIDInvalidErr()
|
||||||
|
}
|
||||||
|
if req.Kick && targetID != scope.userID && !scope.canManage() {
|
||||||
|
return nil, groupCallForbiddenErr()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err := r.deps.GroupCalls.RemoveConferenceParticipants(ctx, domain.RemoveConferenceCallParticipantsRequest{
|
||||||
|
CallID: scope.call.ID,
|
||||||
|
AuthorUserID: scope.userID,
|
||||||
|
TargetUserIDs: req.IDs,
|
||||||
|
OnlyLeft: req.OnlyLeft,
|
||||||
|
Kick: req.Kick,
|
||||||
|
Block: req.Block,
|
||||||
|
Now: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, groupCallErr(err)
|
||||||
|
}
|
||||||
|
if result.ChainBlockAppended {
|
||||||
|
block := result.ChainBlock
|
||||||
|
r.pushConferenceChainBlocks(ctx, result.Call, block.SubChainID, [][]byte{block.Block}, block.Offset+1)
|
||||||
|
}
|
||||||
|
if len(result.ParticipantsChanged) > 0 {
|
||||||
|
r.pushConferenceGroupCallParticipantsUpdate(ctx, result.Call, result.ParticipantsChanged)
|
||||||
|
r.pushConferenceGroupCallUpdate(ctx, result.Call)
|
||||||
|
}
|
||||||
|
for _, p := range result.ParticipantsChanged {
|
||||||
|
if r.deps.SFU != nil {
|
||||||
|
_ = r.deps.SFU.Leave(ctx, scope.call.ID, p.UserID, sfu.EndpointMain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := r.groupCallUpdateContainer(ctx, scope.userID, domain.Channel{},
|
||||||
|
&tg.UpdateGroupCallParticipants{
|
||||||
|
Call: &tg.InputGroupCall{ID: result.Call.ID, AccessHash: result.Call.AccessHash},
|
||||||
|
Participants: tgGroupCallParticipants(result.ParticipantsChanged, scope.userID),
|
||||||
|
Version: result.Call.Version,
|
||||||
|
}, req.IDs)
|
||||||
|
if result.ChainBlockAppended {
|
||||||
|
block := result.ChainBlock
|
||||||
|
out.Updates = append(out.Updates, conferenceChainBlocksUpdate(result.Call, block.SubChainID, [][]byte{block.Block}, block.Offset+1))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) onPhoneSendConferenceCallBroadcast(ctx context.Context, req *tg.PhoneSendConferenceCallBroadcastRequest) (tg.UpdatesClass, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, inputRequestInvalidErr()
|
||||||
|
}
|
||||||
|
if len(req.Block) == 0 || len(req.Block) > maxConferenceChainBlockBytes {
|
||||||
|
return nil, limitInvalidErr()
|
||||||
|
}
|
||||||
|
scope, err := r.groupCallScopeFrom(ctx, req.Call)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !scope.call.Conference() {
|
||||||
|
return nil, groupCallInvalidErr()
|
||||||
|
}
|
||||||
|
if err := r.requireActiveConferenceParticipant(ctx, scope.call.ID, scope.userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
now := int(r.clock.Now().Unix())
|
||||||
|
block, err := r.deps.GroupCalls.AppendChainBlock(ctx, domain.GroupCallChainBlock{
|
||||||
|
CallID: scope.call.ID,
|
||||||
|
SubChainID: 1,
|
||||||
|
Offset: -1,
|
||||||
|
AuthorUserID: scope.userID,
|
||||||
|
Block: req.Block,
|
||||||
|
CreatedAt: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, groupCallErr(err)
|
||||||
|
}
|
||||||
|
nextOffset := block.Offset + 1
|
||||||
|
r.pushConferenceChainBlocks(ctx, scope.call, block.SubChainID, [][]byte{block.Block}, nextOffset)
|
||||||
|
return r.conferenceChainBlocksUpdates(ctx, scope.userID, scope.call, block.SubChainID, [][]byte{block.Block}, nextOffset), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) requireActiveConferenceParticipant(ctx context.Context, callID, userID int64) error {
|
||||||
|
if r.deps.GroupCalls == nil {
|
||||||
|
return notImplementedErr()
|
||||||
|
}
|
||||||
|
p, found, err := r.deps.GroupCalls.Participant(ctx, callID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return internalErr()
|
||||||
|
}
|
||||||
|
if !found || p.Left {
|
||||||
|
return groupCallJoinMissingErr()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) onPhoneGetGroupCallChainBlocks(ctx context.Context, req *tg.PhoneGetGroupCallChainBlocksRequest) (tg.UpdatesClass, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, inputRequestInvalidErr()
|
||||||
|
}
|
||||||
|
if req.Offset < domain.GroupCallChainBlockLatestOffset {
|
||||||
|
return nil, inputRequestInvalidErr()
|
||||||
|
}
|
||||||
|
limit := req.Limit
|
||||||
|
if limit <= 0 || limit > maxConferenceChainBlocks {
|
||||||
|
limit = maxConferenceChainBlocks
|
||||||
|
}
|
||||||
|
scope, err := r.groupCallScopeFrom(ctx, req.Call)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !scope.call.Conference() {
|
||||||
|
return nil, groupCallInvalidErr()
|
||||||
|
}
|
||||||
|
page, err := r.deps.GroupCalls.ChainBlocks(ctx, scope.call.ID, req.SubChainID, req.Offset, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, groupCallErr(err)
|
||||||
|
}
|
||||||
|
blocks := make([][]byte, 0, len(page.Blocks))
|
||||||
|
for _, row := range page.Blocks {
|
||||||
|
blocks = append(blocks, row.Block)
|
||||||
|
}
|
||||||
|
return r.conferenceChainBlocksUpdates(ctx, scope.userID, scope.call, req.SubChainID, blocks, page.NextOffset), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) pushConferenceChainBlocks(ctx context.Context, call domain.GroupCall, subChainID int, blocks [][]byte, nextOffset int) {
|
||||||
|
recipients := r.conferenceCallRecipients(ctx, call.ID)
|
||||||
|
for _, viewerID := range recipients {
|
||||||
|
r.pushUserMessage(ctx, viewerID, "conference chain blocks",
|
||||||
|
r.conferenceChainBlocksUpdates(ctx, viewerID, call, subChainID, blocks, nextOffset))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) conferenceChainBlocksUpdates(ctx context.Context, viewerID int64, call domain.GroupCall, subChainID int, blocks [][]byte, nextOffset int) *tg.Updates {
|
||||||
|
return &tg.Updates{
|
||||||
|
Updates: []tg.UpdateClass{conferenceChainBlocksUpdate(call, subChainID, blocks, nextOffset)},
|
||||||
|
Users: r.tgUsersForIDs(ctx, viewerID, []int64{viewerID}),
|
||||||
|
Date: int(r.clock.Now().Unix()),
|
||||||
|
Seq: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func conferenceChainBlocksUpdate(call domain.GroupCall, subChainID int, blocks [][]byte, nextOffset int) *tg.UpdateGroupCallChainBlocks {
|
||||||
|
return &tg.UpdateGroupCallChainBlocks{
|
||||||
|
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
|
||||||
|
SubChainID: subChainID,
|
||||||
|
Blocks: conferenceServerBlocks(subChainID, blocks),
|
||||||
|
NextOffset: nextOffset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func conferenceServerBlocks(subChainID int, blocks [][]byte) [][]byte {
|
||||||
|
out := make([][]byte, 0, len(blocks))
|
||||||
|
for _, block := range blocks {
|
||||||
|
out = append(out, conferenceServerBlock(subChainID, block))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func conferenceServerBlock(subChainID int, block []byte) []byte {
|
||||||
|
out := append([]byte(nil), block...)
|
||||||
|
if len(out) < 4 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
constructor := binary.LittleEndian.Uint32(out[:4])
|
||||||
|
switch subChainID {
|
||||||
|
case 0:
|
||||||
|
if constructor == conferenceChainBlockConstructor {
|
||||||
|
binary.LittleEndian.PutUint32(out[:4], conferenceChainBlockServerConstructor)
|
||||||
|
}
|
||||||
|
case 1:
|
||||||
|
switch constructor {
|
||||||
|
case conferenceBroadcastCommitConstructor:
|
||||||
|
binary.LittleEndian.PutUint32(out[:4], conferenceBroadcastCommitServerConstructor)
|
||||||
|
case conferenceBroadcastRevealConstructor:
|
||||||
|
binary.LittleEndian.PutUint32(out[:4], conferenceBroadcastRevealServerConstructor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendUpdates(dst *tg.Updates, src tg.UpdatesClass) {
|
||||||
|
if dst == nil || src == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch v := src.(type) {
|
||||||
|
case *tg.Updates:
|
||||||
|
dst.Updates = append(dst.Updates, v.Updates...)
|
||||||
|
dst.Users = append(dst.Users, v.Users...)
|
||||||
|
dst.Chats = append(dst.Chats, v.Chats...)
|
||||||
|
case *tg.UpdatesCombined:
|
||||||
|
dst.Updates = append(dst.Updates, v.Updates...)
|
||||||
|
dst.Users = append(dst.Users, v.Users...)
|
||||||
|
dst.Chats = append(dst.Chats, v.Chats...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func conferenceInviteRandomID(callID, targetID int64, date int) int64 {
|
||||||
|
id := int64(0x636f6e6663616c) // "confcal"
|
||||||
|
id ^= callID << 11
|
||||||
|
id ^= targetID << 3
|
||||||
|
id ^= int64(date) << 29
|
||||||
|
if id == 0 {
|
||||||
|
return 0x636f6e66
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func authKeyIDFromCtx(ctx context.Context) [8]byte {
|
||||||
|
if authKeyID, ok := RawAuthKeyIDFrom(ctx); ok {
|
||||||
|
return authKeyID
|
||||||
|
}
|
||||||
|
return [8]byte{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionIDFromCtx(ctx context.Context) int64 {
|
||||||
|
if sessionID, ok := SessionIDFrom(ctx); ok {
|
||||||
|
return sessionID
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) logConferenceMessageFailure(callID int64, err error) {
|
||||||
|
if err != nil {
|
||||||
|
r.log.Warn("conference message", zap.Int64("call_id", callID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
890
internal/rpc/phone_conference_rpc_test.go
Normal file
890
internal/rpc/phone_conference_rpc_test.go
Normal file
|
|
@ -0,0 +1,890 @@
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gotd/td/clock"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/gotd/td/tgerr"
|
||||||
|
"go.uber.org/zap/zaptest"
|
||||||
|
|
||||||
|
appgroupcalls "telesrv/internal/app/groupcalls"
|
||||||
|
appmessages "telesrv/internal/app/messages"
|
||||||
|
appphone "telesrv/internal/app/phone"
|
||||||
|
appusers "telesrv/internal/app/users"
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
type conferenceFixture struct {
|
||||||
|
ctx context.Context
|
||||||
|
router *Router
|
||||||
|
group *appgroupcalls.Service
|
||||||
|
messages *appmessages.Service
|
||||||
|
sessions *groupCallSessions
|
||||||
|
alice domain.User
|
||||||
|
bob domain.User
|
||||||
|
carol domain.User
|
||||||
|
clock *phoneTestClock
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConferenceFixture(t *testing.T) *conferenceFixture {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
users := memory.NewUserStore()
|
||||||
|
dialogs := memory.NewDialogStore()
|
||||||
|
messageStore := memory.NewMessageStore(dialogs)
|
||||||
|
groupStore := memory.NewGroupCallStore()
|
||||||
|
sessions := &groupCallSessions{}
|
||||||
|
clk := &phoneTestClock{now: time.Unix(1_700_000_000, 0)}
|
||||||
|
groupSvc := appgroupcalls.NewService(groupStore)
|
||||||
|
messageSvc := appmessages.NewService(messageStore, dialogs)
|
||||||
|
router := New(Config{GroupCallMaxParticipants: 8}, Deps{
|
||||||
|
Users: appusers.NewService(users),
|
||||||
|
Messages: messageSvc,
|
||||||
|
GroupCalls: groupSvc,
|
||||||
|
Phone: appphone.NewService(appphone.Config{}, appphone.WithClock(clk)),
|
||||||
|
Sessions: sessions,
|
||||||
|
}, zaptest.NewLogger(t), clk)
|
||||||
|
mk := func(hash int64, phone, name string) domain.User {
|
||||||
|
u, err := users.Create(ctx, domain.User{AccessHash: hash, Phone: phone, FirstName: name})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create user %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
f := &conferenceFixture{ctx: ctx, router: router, group: groupSvc, messages: messageSvc, sessions: sessions, clock: clk}
|
||||||
|
f.alice = mk(3001, "13700000001", "Alice")
|
||||||
|
f.bob = mk(3002, "13700000002", "Bob")
|
||||||
|
f.carol = mk(3003, "13700000003", "Carol")
|
||||||
|
f.sessions.online = []int64{f.alice.ID, f.bob.ID, f.carol.ID}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *conferenceFixture) userCtx(u domain.User, session int64) context.Context {
|
||||||
|
return WithSessionID(WithUserID(f.ctx, u.ID), session)
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinConferenceForTest(t *testing.T, f *conferenceFixture, ctx context.Context, call tg.InputGroupCallClass, blockSuffix string, ssrc int32) {
|
||||||
|
t.Helper()
|
||||||
|
block := conferenceTestBlock(conferenceChainBlockConstructor, blockSuffix)
|
||||||
|
req := &tg.PhoneJoinGroupCallRequest{
|
||||||
|
Call: call,
|
||||||
|
JoinAs: &tg.InputPeerSelf{},
|
||||||
|
Params: groupCallJoinParams(t, ssrc),
|
||||||
|
}
|
||||||
|
req.SetBlock(block)
|
||||||
|
if _, err := f.router.onPhoneJoinGroupCall(ctx, req); err != nil {
|
||||||
|
t.Fatalf("join conference %s: %v", blockSuffix, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionalUpdate[T tg.UpdateClass](updates tg.UpdatesClass) (T, bool) {
|
||||||
|
box, ok := updates.(*tg.Updates)
|
||||||
|
if !ok {
|
||||||
|
var zero T
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
|
for _, u := range box.Updates {
|
||||||
|
if v, ok := u.(T); ok {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var zero T
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushedDiscardedGroupCallUsers(records []phonePushRecord, callID int64) map[int64]bool {
|
||||||
|
seen := map[int64]bool{}
|
||||||
|
for _, rec := range records {
|
||||||
|
box, ok := rec.msg.(*tg.Updates)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, raw := range box.Updates {
|
||||||
|
update, ok := raw.(*tg.UpdateGroupCall)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
discarded, ok := update.Call.(*tg.GroupCallDiscarded)
|
||||||
|
if ok && discarded.ID == callID {
|
||||||
|
seen[rec.userID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seen
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConferenceCreateLinkAndGetBySlug(t *testing.T) {
|
||||||
|
f := newConferenceFixture(t)
|
||||||
|
ctx := f.userCtx(f.alice, 11)
|
||||||
|
res, err := f.router.onPhoneCreateConferenceCall(ctx, &tg.PhoneCreateConferenceCallRequest{RandomID: 42})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create conference: %v", err)
|
||||||
|
}
|
||||||
|
update := findUpdate[*tg.UpdateGroupCall](t, res)
|
||||||
|
call, ok := update.Call.(*tg.GroupCall)
|
||||||
|
if !ok || !call.Conference || call.InviteLink == "" || !strings.Contains(call.InviteLink, "slug=") || !strings.HasPrefix(call.InviteLink, "https://telesrv.net/call/") {
|
||||||
|
t.Fatalf("created call = %#v", update.Call)
|
||||||
|
}
|
||||||
|
slug := conferenceSlugFromLink(t, call.InviteLink)
|
||||||
|
got, err := f.router.onPhoneGetGroupCall(ctx, &tg.PhoneGetGroupCallRequest{
|
||||||
|
Call: &tg.InputGroupCallSlug{Slug: slug},
|
||||||
|
Limit: 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get by slug: %v", err)
|
||||||
|
}
|
||||||
|
if got.Call.(*tg.GroupCall).ID != call.ID {
|
||||||
|
t.Fatalf("get by slug id = %d, want %d", got.Call.(*tg.GroupCall).ID, call.ID)
|
||||||
|
}
|
||||||
|
again, err := f.router.onPhoneCreateConferenceCall(ctx, &tg.PhoneCreateConferenceCallRequest{RandomID: 42})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("repeat create conference: %v", err)
|
||||||
|
}
|
||||||
|
if findUpdate[*tg.UpdateGroupCall](t, again).Call.(*tg.GroupCall).ID != call.ID {
|
||||||
|
t.Fatalf("random_id must be idempotent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConferenceExportGroupCallInviteReturnsConferenceLink(t *testing.T) {
|
||||||
|
f := newConferenceFixture(t)
|
||||||
|
ctx := f.userCtx(f.alice, 11)
|
||||||
|
create, err := f.router.onPhoneCreateConferenceCall(ctx, &tg.PhoneCreateConferenceCallRequest{RandomID: 43})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create conference: %v", err)
|
||||||
|
}
|
||||||
|
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||||
|
exported, err := f.router.onPhoneExportGroupCallInvite(ctx, &tg.PhoneExportGroupCallInviteRequest{
|
||||||
|
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("export invite: %v", err)
|
||||||
|
}
|
||||||
|
if exported.Link != call.InviteLink || !strings.Contains(exported.Link, "slug=") {
|
||||||
|
t.Fatalf("exported link = %q, want conference invite link %q", exported.Link, call.InviteLink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConferenceExportGroupCallInviteReturnsPathSlug(t *testing.T) {
|
||||||
|
f := newConferenceFixture(t)
|
||||||
|
ctx := WithClientInfo(f.userCtx(f.alice, 11), ClientInfo{Type: ClientTypeAndroid, AppVersion: "12.8.1"})
|
||||||
|
create, err := f.router.onPhoneCreateConferenceCall(ctx, &tg.PhoneCreateConferenceCallRequest{RandomID: 44})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create conference: %v", err)
|
||||||
|
}
|
||||||
|
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||||
|
slug := conferenceSlugFromLink(t, call.InviteLink)
|
||||||
|
exported, err := f.router.onPhoneExportGroupCallInvite(ctx, &tg.PhoneExportGroupCallInviteRequest{
|
||||||
|
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("export invite: %v", err)
|
||||||
|
}
|
||||||
|
if exported.Link != call.InviteLink {
|
||||||
|
t.Fatalf("exported link = %q, want stored canonical invite link %q", exported.Link, call.InviteLink)
|
||||||
|
}
|
||||||
|
if got := lastPathSegmentFromLink(t, exported.Link); got != slug {
|
||||||
|
t.Fatalf("export path slug = %q, want %q (link %q)", got, slug, exported.Link)
|
||||||
|
}
|
||||||
|
if got := conferenceSlugFromLink(t, exported.Link); got != slug {
|
||||||
|
t.Fatalf("export query slug = %q, want %q (link %q)", got, slug, exported.Link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConferenceLinksNormalizeLegacyTMeLink(t *testing.T) {
|
||||||
|
const slug = "legacy_slug-1"
|
||||||
|
legacy := domain.GroupCall{
|
||||||
|
ID: 1,
|
||||||
|
AccessHash: 2,
|
||||||
|
Kind: domain.GroupCallKindConference,
|
||||||
|
State: domain.GroupCallStateActive,
|
||||||
|
Version: 1,
|
||||||
|
InviteSlug: slug,
|
||||||
|
InviteLink: "https://t.me/call?slug=" + slug,
|
||||||
|
}
|
||||||
|
want := "https://telesrv.net/call/" + slug + "?slug=" + slug
|
||||||
|
if got := conferenceExportInviteLink(legacy); got != want {
|
||||||
|
t.Fatalf("export link = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
call := tgGroupCall(legacy, 0, false).(*tg.GroupCall)
|
||||||
|
if call.InviteLink != want {
|
||||||
|
t.Fatalf("tg group call invite link = %q, want %q", call.InviteLink, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConferenceJoinBroadcastAndGetChainBlocks(t *testing.T) {
|
||||||
|
f := newConferenceFixture(t)
|
||||||
|
ctx := f.userCtx(f.alice, 11)
|
||||||
|
create, err := f.router.onPhoneCreateConferenceCall(ctx, &tg.PhoneCreateConferenceCallRequest{RandomID: 77})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create conference: %v", err)
|
||||||
|
}
|
||||||
|
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||||
|
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||||
|
join, err := f.router.onPhoneJoinGroupCall(ctx, &tg.PhoneJoinGroupCallRequest{
|
||||||
|
Call: input,
|
||||||
|
JoinAs: &tg.InputPeerSelf{},
|
||||||
|
Params: groupCallJoinParams(t, 9001),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("join conference: %v", err)
|
||||||
|
}
|
||||||
|
findUpdate[*tg.UpdateGroupCallConnection](t, join)
|
||||||
|
participants := findUpdate[*tg.UpdateGroupCallParticipants](t, join)
|
||||||
|
if len(participants.Participants) != 1 || !participants.Participants[0].Self {
|
||||||
|
t.Fatalf("participants update = %+v", participants.Participants)
|
||||||
|
}
|
||||||
|
block := conferenceTestBlock(conferenceBroadcastCommitConstructor, "opaque-chain-block")
|
||||||
|
broadcast, err := f.router.onPhoneSendConferenceCallBroadcast(ctx, &tg.PhoneSendConferenceCallBroadcastRequest{
|
||||||
|
Call: input,
|
||||||
|
Block: block,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("broadcast: %v", err)
|
||||||
|
}
|
||||||
|
chain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, broadcast)
|
||||||
|
if chain.SubChainID != 1 || chain.NextOffset != 1 || len(chain.Blocks) != 1 || conferenceTestConstructor(chain.Blocks[0]) != conferenceBroadcastCommitServerConstructor {
|
||||||
|
t.Fatalf("chain update = %+v", chain)
|
||||||
|
}
|
||||||
|
list, err := f.router.onPhoneGetGroupCallChainBlocks(ctx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||||
|
Call: input, SubChainID: 1, Offset: 0, Limit: 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get chain blocks: %v", err)
|
||||||
|
}
|
||||||
|
got := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
|
||||||
|
if got.SubChainID != 1 || len(got.Blocks) != 1 || conferenceTestConstructor(got.Blocks[0]) != conferenceBroadcastCommitServerConstructor {
|
||||||
|
t.Fatalf("get chain blocks = %+v", got)
|
||||||
|
}
|
||||||
|
nextBlock := conferenceTestBlock(conferenceBroadcastRevealConstructor, "opaque-chain-block-2")
|
||||||
|
nextBroadcast, err := f.router.onPhoneSendConferenceCallBroadcast(ctx, &tg.PhoneSendConferenceCallBroadcastRequest{
|
||||||
|
Call: input,
|
||||||
|
Block: nextBlock,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second broadcast: %v", err)
|
||||||
|
}
|
||||||
|
nextChain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, nextBroadcast)
|
||||||
|
if nextChain.SubChainID != 1 || nextChain.NextOffset != 2 || len(nextChain.Blocks) != 1 || conferenceTestConstructor(nextChain.Blocks[0]) != conferenceBroadcastRevealServerConstructor {
|
||||||
|
t.Fatalf("second chain update = %+v", nextChain)
|
||||||
|
}
|
||||||
|
latest, err := f.router.onPhoneGetGroupCallChainBlocks(ctx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||||
|
Call: input, SubChainID: 1, Offset: domain.GroupCallChainBlockLatestOffset, Limit: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get latest chain block: %v", err)
|
||||||
|
}
|
||||||
|
gotLatest := findUpdate[*tg.UpdateGroupCallChainBlocks](t, latest)
|
||||||
|
if gotLatest.SubChainID != 1 || gotLatest.NextOffset != 2 || len(gotLatest.Blocks) != 1 || conferenceTestConstructor(gotLatest.Blocks[0]) != conferenceBroadcastRevealServerConstructor {
|
||||||
|
t.Fatalf("latest chain block = %+v", gotLatest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConferenceBroadcastRequiresActiveParticipant(t *testing.T) {
|
||||||
|
f := newConferenceFixture(t)
|
||||||
|
aliceCtx := f.userCtx(f.alice, 11)
|
||||||
|
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 770})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create conference: %v", err)
|
||||||
|
}
|
||||||
|
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||||
|
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||||
|
|
||||||
|
f.sessions.reset()
|
||||||
|
block := conferenceTestBlock(conferenceBroadcastCommitConstructor, "creator-not-joined-broadcast")
|
||||||
|
res, err := f.router.onPhoneSendConferenceCallBroadcast(aliceCtx, &tg.PhoneSendConferenceCallBroadcastRequest{
|
||||||
|
Call: input,
|
||||||
|
Block: block,
|
||||||
|
})
|
||||||
|
if res != nil || !tgerr.Is(err, "GROUPCALL_JOIN_MISSING") {
|
||||||
|
t.Fatalf("broadcast before join = %+v err=%v, want GROUPCALL_JOIN_MISSING", res, err)
|
||||||
|
}
|
||||||
|
if got := f.sessions.records(); len(got) != 0 {
|
||||||
|
t.Fatalf("broadcast before join must not push updates, got %+v", got)
|
||||||
|
}
|
||||||
|
list, err := f.router.onPhoneGetGroupCallChainBlocks(aliceCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||||
|
Call: input, SubChainID: 1, Offset: 0, Limit: 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get chain blocks: %v", err)
|
||||||
|
}
|
||||||
|
chain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
|
||||||
|
if chain.NextOffset != 0 || len(chain.Blocks) != 0 {
|
||||||
|
t.Fatalf("chain after forbidden broadcast = %+v, want empty", chain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConferenceJoinBlockSeedsChainAndDuplicateJoinIsRejected(t *testing.T) {
|
||||||
|
f := newConferenceFixture(t)
|
||||||
|
aliceCtx := f.userCtx(f.alice, 11)
|
||||||
|
bobCtx := f.userCtx(f.bob, 22)
|
||||||
|
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 78})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create conference: %v", err)
|
||||||
|
}
|
||||||
|
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||||
|
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||||
|
joinBlock := conferenceTestBlock(conferenceChainBlockConstructor, "join-chain-block")
|
||||||
|
joinReq := &tg.PhoneJoinGroupCallRequest{
|
||||||
|
Call: input,
|
||||||
|
JoinAs: &tg.InputPeerSelf{},
|
||||||
|
Params: groupCallJoinParams(t, 9002),
|
||||||
|
}
|
||||||
|
joinReq.SetBlock(joinBlock)
|
||||||
|
join, err := f.router.onPhoneJoinGroupCall(aliceCtx, joinReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("join conference: %v", err)
|
||||||
|
}
|
||||||
|
findUpdate[*tg.UpdateGroupCallConnection](t, join)
|
||||||
|
joinChain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, join)
|
||||||
|
if joinChain.SubChainID != 0 || joinChain.NextOffset != 1 || len(joinChain.Blocks) != 1 || conferenceTestConstructor(joinChain.Blocks[0]) != conferenceChainBlockServerConstructor {
|
||||||
|
t.Fatalf("join chain update = %+v", joinChain)
|
||||||
|
}
|
||||||
|
|
||||||
|
dupJoinReq := &tg.PhoneJoinGroupCallRequest{
|
||||||
|
Call: &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)},
|
||||||
|
JoinAs: &tg.InputPeerSelf{},
|
||||||
|
Params: groupCallJoinParams(t, 9003),
|
||||||
|
}
|
||||||
|
dupJoinReq.SetBlock(append([]byte(nil), joinBlock...))
|
||||||
|
dupJoin, err := f.router.onPhoneJoinGroupCall(bobCtx, dupJoinReq)
|
||||||
|
if dupJoin != nil || !tgerr.Is(err, "CONF_WRITE_CHAIN_INVALID") {
|
||||||
|
t.Fatalf("duplicate join = %+v err=%v, want CONF_WRITE_CHAIN_INVALID", dupJoin, err)
|
||||||
|
}
|
||||||
|
list, err := f.router.onPhoneGetGroupCallChainBlocks(aliceCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||||
|
Call: input, Offset: 0, Limit: 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get chain blocks: %v", err)
|
||||||
|
}
|
||||||
|
got := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
|
||||||
|
if got.NextOffset != 1 || len(got.Blocks) != 1 || conferenceTestConstructor(got.Blocks[0]) != conferenceChainBlockServerConstructor {
|
||||||
|
t.Fatalf("get chain blocks after duplicate = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConferenceJoinReturnsSubmittedBlockOnly(t *testing.T) {
|
||||||
|
f := newConferenceFixture(t)
|
||||||
|
aliceCtx := f.userCtx(f.alice, 11)
|
||||||
|
bobCtx := f.userCtx(f.bob, 22)
|
||||||
|
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 790})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create conference: %v", err)
|
||||||
|
}
|
||||||
|
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||||
|
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||||
|
aliceBlock := conferenceTestBlock(conferenceChainBlockConstructor, "alice-join-chain-block")
|
||||||
|
aliceJoin := &tg.PhoneJoinGroupCallRequest{
|
||||||
|
Call: input,
|
||||||
|
JoinAs: &tg.InputPeerSelf{},
|
||||||
|
Params: groupCallJoinParams(t, 9031),
|
||||||
|
}
|
||||||
|
aliceJoin.SetBlock(aliceBlock)
|
||||||
|
if _, err := f.router.onPhoneJoinGroupCall(aliceCtx, aliceJoin); err != nil {
|
||||||
|
t.Fatalf("alice join: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bobBlock := conferenceTestBlock(conferenceChainBlockConstructor, "bob-join-chain-block")
|
||||||
|
bobJoin := &tg.PhoneJoinGroupCallRequest{
|
||||||
|
Call: &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)},
|
||||||
|
JoinAs: &tg.InputPeerSelf{},
|
||||||
|
Params: groupCallJoinParams(t, 9032),
|
||||||
|
}
|
||||||
|
bobJoin.SetBlock(bobBlock)
|
||||||
|
join, err := f.router.onPhoneJoinGroupCall(bobCtx, bobJoin)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bob join: %v", err)
|
||||||
|
}
|
||||||
|
joinChain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, join)
|
||||||
|
if joinChain.SubChainID != 0 || joinChain.NextOffset != 2 || len(joinChain.Blocks) != 1 {
|
||||||
|
t.Fatalf("bob join chain update = %+v, want only submitted block at next_offset=2", joinChain)
|
||||||
|
}
|
||||||
|
if conferenceTestConstructor(joinChain.Blocks[0]) != conferenceChainBlockServerConstructor || string(joinChain.Blocks[0][4:]) != string(bobBlock[4:]) {
|
||||||
|
t.Fatalf("bob join block = %x, want server-form submitted block %x", joinChain.Blocks[0], bobBlock)
|
||||||
|
}
|
||||||
|
list, err := f.router.onPhoneGetGroupCallChainBlocks(aliceCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||||
|
Call: input, Offset: 0, Limit: 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get chain blocks: %v", err)
|
||||||
|
}
|
||||||
|
got := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
|
||||||
|
if got.NextOffset != 2 || len(got.Blocks) != 2 {
|
||||||
|
t.Fatalf("persisted chain blocks = %+v, want full history through getGroupCallChainBlocks", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConferenceDuplicateJoinBlockDoesNotLeaveParticipantActive(t *testing.T) {
|
||||||
|
f := newConferenceFixture(t)
|
||||||
|
aliceCtx := f.userCtx(f.alice, 11)
|
||||||
|
bobCtx := f.userCtx(f.bob, 22)
|
||||||
|
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 79})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create conference: %v", err)
|
||||||
|
}
|
||||||
|
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||||
|
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||||
|
joinBlock := conferenceTestBlock(conferenceChainBlockConstructor, "alice-join-chain-block")
|
||||||
|
aliceJoin := &tg.PhoneJoinGroupCallRequest{
|
||||||
|
Call: input,
|
||||||
|
JoinAs: &tg.InputPeerSelf{},
|
||||||
|
Params: groupCallJoinParams(t, 9011),
|
||||||
|
}
|
||||||
|
aliceJoin.SetBlock(joinBlock)
|
||||||
|
if _, err := f.router.onPhoneJoinGroupCall(aliceCtx, aliceJoin); err != nil {
|
||||||
|
t.Fatalf("alice join: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bobJoin := &tg.PhoneJoinGroupCallRequest{
|
||||||
|
Call: &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)},
|
||||||
|
JoinAs: &tg.InputPeerSelf{},
|
||||||
|
Params: groupCallJoinParams(t, 9022),
|
||||||
|
}
|
||||||
|
bobJoin.SetBlock(append([]byte(nil), joinBlock...))
|
||||||
|
if res, err := f.router.onPhoneJoinGroupCall(bobCtx, bobJoin); res != nil || !tgerr.Is(err, "CONF_WRITE_CHAIN_INVALID") {
|
||||||
|
t.Fatalf("bob duplicate join = %+v err=%v, want CONF_WRITE_CHAIN_INVALID", res, err)
|
||||||
|
}
|
||||||
|
if p, found, err := f.group.Participant(f.ctx, call.ID, f.bob.ID); err != nil || (found && !p.Left) {
|
||||||
|
t.Fatalf("bob participant after duplicate join = %+v found=%v err=%v, want absent or left", p, found, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConferenceDeleteParticipantsReturnsSubmittedBlock(t *testing.T) {
|
||||||
|
f := newConferenceFixture(t)
|
||||||
|
aliceCtx := f.userCtx(f.alice, 11)
|
||||||
|
bobCtx := f.userCtx(f.bob, 22)
|
||||||
|
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 791})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create conference: %v", err)
|
||||||
|
}
|
||||||
|
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||||
|
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||||
|
aliceBlock := conferenceTestBlock(conferenceChainBlockConstructor, "alice-delete-seed-block")
|
||||||
|
aliceJoin := &tg.PhoneJoinGroupCallRequest{Call: input, JoinAs: &tg.InputPeerSelf{}, Params: groupCallJoinParams(t, 9041)}
|
||||||
|
aliceJoin.SetBlock(aliceBlock)
|
||||||
|
if _, err := f.router.onPhoneJoinGroupCall(aliceCtx, aliceJoin); err != nil {
|
||||||
|
t.Fatalf("alice join: %v", err)
|
||||||
|
}
|
||||||
|
bobBlock := conferenceTestBlock(conferenceChainBlockConstructor, "bob-delete-seed-block")
|
||||||
|
bobJoin := &tg.PhoneJoinGroupCallRequest{Call: &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, JoinAs: &tg.InputPeerSelf{}, Params: groupCallJoinParams(t, 9042)}
|
||||||
|
bobJoin.SetBlock(bobBlock)
|
||||||
|
if _, err := f.router.onPhoneJoinGroupCall(bobCtx, bobJoin); err != nil {
|
||||||
|
t.Fatalf("bob join: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeBlock := conferenceTestBlock(conferenceChainBlockConstructor, "alice-remove-bob-block")
|
||||||
|
res, err := f.router.onPhoneDeleteConferenceCallParticipants(aliceCtx, &tg.PhoneDeleteConferenceCallParticipantsRequest{
|
||||||
|
Call: input,
|
||||||
|
IDs: []int64{f.bob.ID},
|
||||||
|
Kick: true,
|
||||||
|
Block: removeBlock,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("delete conference participants: %v", err)
|
||||||
|
}
|
||||||
|
chain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, res)
|
||||||
|
if chain.SubChainID != 0 || chain.NextOffset != 3 || len(chain.Blocks) != 1 || conferenceTestConstructor(chain.Blocks[0]) != conferenceChainBlockServerConstructor {
|
||||||
|
t.Fatalf("delete participants chain update = %+v", chain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConferenceOnlyLeftRemoveIsIdempotentAndAllowedForParticipant(t *testing.T) {
|
||||||
|
f := newConferenceFixture(t)
|
||||||
|
aliceCtx := f.userCtx(f.alice, 11)
|
||||||
|
bobCtx := f.userCtx(f.bob, 22)
|
||||||
|
carolCtx := f.userCtx(f.carol, 33)
|
||||||
|
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 792})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create conference: %v", err)
|
||||||
|
}
|
||||||
|
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||||
|
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||||
|
joinConferenceForTest(t, f, aliceCtx, input, "alice-only-left-join", 9051)
|
||||||
|
joinConferenceForTest(t, f, bobCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "bob-only-left-join", 9052)
|
||||||
|
joinConferenceForTest(t, f, carolCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "carol-only-left-join", 9053)
|
||||||
|
if _, err := f.router.onPhoneLeaveGroupCall(carolCtx, &tg.PhoneLeaveGroupCallRequest{Call: input}); err != nil {
|
||||||
|
t.Fatalf("carol leave: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
aliceRemove := conferenceTestBlock(conferenceChainBlockConstructor, "alice-remove-left-carol")
|
||||||
|
first, err := f.router.onPhoneDeleteConferenceCallParticipants(aliceCtx, &tg.PhoneDeleteConferenceCallParticipantsRequest{
|
||||||
|
Call: input,
|
||||||
|
IDs: []int64{f.carol.ID},
|
||||||
|
OnlyLeft: true,
|
||||||
|
Block: aliceRemove,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("alice only_left remove: %v", err)
|
||||||
|
}
|
||||||
|
firstChain := findUpdate[*tg.UpdateGroupCallChainBlocks](t, first)
|
||||||
|
if firstChain.SubChainID != 0 || firstChain.NextOffset != 4 || len(firstChain.Blocks) != 1 {
|
||||||
|
t.Fatalf("first only_left chain update = %+v", firstChain)
|
||||||
|
}
|
||||||
|
|
||||||
|
bobRemove := conferenceTestBlock(conferenceChainBlockConstructor, "bob-stale-remove-left-carol")
|
||||||
|
second, err := f.router.onPhoneDeleteConferenceCallParticipants(bobCtx, &tg.PhoneDeleteConferenceCallParticipantsRequest{
|
||||||
|
Call: input,
|
||||||
|
IDs: []int64{f.carol.ID},
|
||||||
|
OnlyLeft: true,
|
||||||
|
Block: bobRemove,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bob duplicate only_left remove: %v", err)
|
||||||
|
}
|
||||||
|
if chain, ok := optionalUpdate[*tg.UpdateGroupCallChainBlocks](second); ok {
|
||||||
|
t.Fatalf("duplicate only_left must not append stale chain block, got %+v", chain)
|
||||||
|
}
|
||||||
|
list, err := f.router.onPhoneGetGroupCallChainBlocks(aliceCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||||
|
Call: input, Offset: 0, Limit: 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get chain blocks: %v", err)
|
||||||
|
}
|
||||||
|
got := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
|
||||||
|
if got.NextOffset != 4 || len(got.Blocks) != 4 {
|
||||||
|
t.Fatalf("chain after duplicate only_left = %+v, want exactly 3 joins + 1 remove", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConferenceForbiddenKickDoesNotAppendChainBlock(t *testing.T) {
|
||||||
|
f := newConferenceFixture(t)
|
||||||
|
aliceCtx := f.userCtx(f.alice, 11)
|
||||||
|
bobCtx := f.userCtx(f.bob, 22)
|
||||||
|
carolCtx := f.userCtx(f.carol, 33)
|
||||||
|
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 793})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create conference: %v", err)
|
||||||
|
}
|
||||||
|
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||||
|
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||||
|
joinConferenceForTest(t, f, aliceCtx, input, "alice-forbidden-kick-join", 9061)
|
||||||
|
joinConferenceForTest(t, f, bobCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "bob-forbidden-kick-join", 9062)
|
||||||
|
joinConferenceForTest(t, f, carolCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "carol-forbidden-kick-join", 9063)
|
||||||
|
|
||||||
|
staleBlock := conferenceTestBlock(conferenceChainBlockConstructor, "bob-forbidden-kick-carol")
|
||||||
|
res, err := f.router.onPhoneDeleteConferenceCallParticipants(bobCtx, &tg.PhoneDeleteConferenceCallParticipantsRequest{
|
||||||
|
Call: input,
|
||||||
|
IDs: []int64{f.carol.ID},
|
||||||
|
Kick: true,
|
||||||
|
Block: staleBlock,
|
||||||
|
})
|
||||||
|
if res != nil || !tgerr.Is(err, "GROUPCALL_FORBIDDEN") {
|
||||||
|
t.Fatalf("bob forbidden kick = %+v err=%v, want GROUPCALL_FORBIDDEN", res, err)
|
||||||
|
}
|
||||||
|
list, err := f.router.onPhoneGetGroupCallChainBlocks(aliceCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||||
|
Call: input, Offset: 0, Limit: 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get chain blocks: %v", err)
|
||||||
|
}
|
||||||
|
got := findUpdate[*tg.UpdateGroupCallChainBlocks](t, list)
|
||||||
|
if got.NextOffset != 3 || len(got.Blocks) != 3 {
|
||||||
|
t.Fatalf("chain after forbidden kick = %+v, want only join blocks", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConferenceLastLeaveDiscardsCall(t *testing.T) {
|
||||||
|
f := newConferenceFixture(t)
|
||||||
|
aliceCtx := f.userCtx(f.alice, 11)
|
||||||
|
bobCtx := f.userCtx(f.bob, 22)
|
||||||
|
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 794})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create conference: %v", err)
|
||||||
|
}
|
||||||
|
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||||
|
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||||
|
joinConferenceForTest(t, f, aliceCtx, input, "alice-last-leave-join", 9071)
|
||||||
|
joinConferenceForTest(t, f, bobCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "bob-last-leave-join", 9072)
|
||||||
|
|
||||||
|
bobLeave, err := f.router.onPhoneLeaveGroupCall(bobCtx, &tg.PhoneLeaveGroupCallRequest{Call: input})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bob leave: %v", err)
|
||||||
|
}
|
||||||
|
if update, ok := optionalUpdate[*tg.UpdateGroupCall](bobLeave); ok {
|
||||||
|
if _, discarded := update.Call.(*tg.GroupCallDiscarded); discarded {
|
||||||
|
t.Fatalf("first leave must keep conference active, got %+v", update.Call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aliceLeave, err := f.router.onPhoneLeaveGroupCall(aliceCtx, &tg.PhoneLeaveGroupCallRequest{Call: input})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("alice last leave: %v", err)
|
||||||
|
}
|
||||||
|
discardUpdate := findUpdate[*tg.UpdateGroupCall](t, aliceLeave)
|
||||||
|
if discarded, ok := discardUpdate.Call.(*tg.GroupCallDiscarded); !ok || discarded.ID != call.ID {
|
||||||
|
t.Fatalf("last leave update = %+v, want groupCallDiscarded %d", discardUpdate.Call, call.ID)
|
||||||
|
}
|
||||||
|
_, err = f.router.onPhoneJoinGroupCall(bobCtx, &tg.PhoneJoinGroupCallRequest{
|
||||||
|
Call: input,
|
||||||
|
JoinAs: &tg.InputPeerSelf{},
|
||||||
|
Params: groupCallJoinParams(t, 9073),
|
||||||
|
})
|
||||||
|
if !tgerr.Is(err, "GROUPCALL_ALREADY_DISCARDED") {
|
||||||
|
t.Fatalf("join after last leave err = %v, want GROUPCALL_ALREADY_DISCARDED", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConferenceDiscardRequiresCreator(t *testing.T) {
|
||||||
|
f := newConferenceFixture(t)
|
||||||
|
aliceCtx := f.userCtx(f.alice, 11)
|
||||||
|
bobCtx := f.userCtx(f.bob, 22)
|
||||||
|
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 794})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create conference: %v", err)
|
||||||
|
}
|
||||||
|
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||||
|
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||||
|
joinConferenceForTest(t, f, aliceCtx, input, "alice-discard-permission-join", 9071)
|
||||||
|
joinConferenceForTest(t, f, bobCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "bob-discard-permission-join", 9072)
|
||||||
|
|
||||||
|
f.sessions.reset()
|
||||||
|
res, err := f.router.onPhoneDiscardGroupCall(bobCtx, input)
|
||||||
|
if res != nil || !tgerr.Is(err, "CHAT_ADMIN_REQUIRED") {
|
||||||
|
t.Fatalf("bob discard = %+v err=%v, want CHAT_ADMIN_REQUIRED", res, err)
|
||||||
|
}
|
||||||
|
if got := f.sessions.records(); len(got) != 0 {
|
||||||
|
t.Fatalf("forbidden discard must not push updates, got %+v", got)
|
||||||
|
}
|
||||||
|
got, err := f.router.onPhoneGetGroupCall(aliceCtx, &tg.PhoneGetGroupCallRequest{Call: input, Limit: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get group call after forbidden discard: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := got.Call.(*tg.GroupCall); !ok {
|
||||||
|
t.Fatalf("call after forbidden discard = %T, want active GroupCall", got.Call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConferenceDiscardFanoutIncludesSlugJoinedParticipants(t *testing.T) {
|
||||||
|
f := newConferenceFixture(t)
|
||||||
|
aliceCtx := f.userCtx(f.alice, 11)
|
||||||
|
bobCtx := f.userCtx(f.bob, 22)
|
||||||
|
carolCtx := f.userCtx(f.carol, 33)
|
||||||
|
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 795})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create conference: %v", err)
|
||||||
|
}
|
||||||
|
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||||
|
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||||
|
joinConferenceForTest(t, f, aliceCtx, input, "alice-discard-fanout-join", 9081)
|
||||||
|
joinConferenceForTest(t, f, bobCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "bob-discard-fanout-join", 9082)
|
||||||
|
joinConferenceForTest(t, f, carolCtx, &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}, "carol-discard-fanout-join", 9083)
|
||||||
|
|
||||||
|
f.sessions.reset()
|
||||||
|
discard, err := f.router.onPhoneDiscardGroupCall(aliceCtx, input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("discard conference: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := findUpdate[*tg.UpdateGroupCall](t, discard).Call.(*tg.GroupCallDiscarded); !ok {
|
||||||
|
t.Fatalf("discard response missing groupCallDiscarded")
|
||||||
|
}
|
||||||
|
seen := pushedDiscardedGroupCallUsers(f.sessions.records(), call.ID)
|
||||||
|
for _, user := range []domain.User{f.alice, f.bob, f.carol} {
|
||||||
|
if !seen[user.ID] {
|
||||||
|
t.Fatalf("discard fanout missing user %d, seen=%v records=%+v", user.ID, seen, f.sessions.records())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConferenceDiscardAllowsHistoricalParticipantCleanupRPCs(t *testing.T) {
|
||||||
|
f := newConferenceFixture(t)
|
||||||
|
aliceCtx := f.userCtx(f.alice, 11)
|
||||||
|
bobCtx := f.userCtx(f.bob, 22)
|
||||||
|
carolCtx := f.userCtx(f.carol, 33)
|
||||||
|
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 796})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create conference: %v", err)
|
||||||
|
}
|
||||||
|
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||||
|
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||||
|
slugCall := &tg.InputGroupCallSlug{Slug: conferenceSlugFromLink(t, call.InviteLink)}
|
||||||
|
joinConferenceForTest(t, f, aliceCtx, input, "alice-discard-cleanup-join", 9091)
|
||||||
|
joinConferenceForTest(t, f, bobCtx, slugCall, "bob-discard-cleanup-join", 9092)
|
||||||
|
joinConferenceForTest(t, f, carolCtx, slugCall, "carol-discard-cleanup-join", 9093)
|
||||||
|
|
||||||
|
if _, err := f.router.onPhoneDiscardGroupCall(aliceCtx, input); err != nil {
|
||||||
|
t.Fatalf("discard conference: %v", err)
|
||||||
|
}
|
||||||
|
got, err := f.router.onPhoneGetGroupCall(bobCtx, &tg.PhoneGetGroupCallRequest{Call: input, Limit: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bob get discarded group call: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := got.Call.(*tg.GroupCallDiscarded); !ok {
|
||||||
|
t.Fatalf("bob call after discard = %T, want GroupCallDiscarded", got.Call)
|
||||||
|
}
|
||||||
|
ssrcs, err := f.router.onPhoneCheckGroupCall(bobCtx, &tg.PhoneCheckGroupCallRequest{
|
||||||
|
Call: input,
|
||||||
|
Sources: []int{9092},
|
||||||
|
})
|
||||||
|
if err != nil || len(ssrcs) != 0 {
|
||||||
|
t.Fatalf("bob check discarded group call = %v err=%v, want empty no error", ssrcs, err)
|
||||||
|
}
|
||||||
|
chain, err := f.router.onPhoneGetGroupCallChainBlocks(bobCtx, &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||||
|
Call: input,
|
||||||
|
SubChainID: 0,
|
||||||
|
Offset: 0,
|
||||||
|
Limit: 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bob get chain blocks after discard: %v", err)
|
||||||
|
}
|
||||||
|
if blocks := findUpdate[*tg.UpdateGroupCallChainBlocks](t, chain).Blocks; len(blocks) != 3 {
|
||||||
|
t.Fatalf("bob cleanup chain blocks len=%d, want 3", len(blocks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConferenceInviteMessageResolvesInputGroupCallInviteMessage(t *testing.T) {
|
||||||
|
f := newConferenceFixture(t)
|
||||||
|
aliceCtx := f.userCtx(f.alice, 11)
|
||||||
|
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 88})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create conference: %v", err)
|
||||||
|
}
|
||||||
|
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||||
|
input := &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
|
||||||
|
invite, err := f.router.onPhoneInviteConferenceCallParticipant(aliceCtx, &tg.PhoneInviteConferenceCallParticipantRequest{
|
||||||
|
Call: input,
|
||||||
|
UserID: &tg.InputUser{UserID: f.bob.ID, AccessHash: f.bob.AccessHash},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("invite conference: %v", err)
|
||||||
|
}
|
||||||
|
msg := findUpdate[*tg.UpdateNewMessage](t, invite).Message.(*tg.MessageService)
|
||||||
|
if _, ok := msg.Action.(*tg.MessageActionConferenceCall); !ok {
|
||||||
|
t.Fatalf("invite action = %T", msg.Action)
|
||||||
|
}
|
||||||
|
history, err := f.messages.GetHistory(f.ctx, f.bob.ID, domain.MessageFilter{
|
||||||
|
HasPeer: true,
|
||||||
|
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.alice.ID},
|
||||||
|
Limit: 10,
|
||||||
|
})
|
||||||
|
if err != nil || len(history.Messages) != 1 {
|
||||||
|
t.Fatalf("bob history len=%d err=%v", len(history.Messages), err)
|
||||||
|
}
|
||||||
|
bobMsgID := history.Messages[0].ID
|
||||||
|
joinConferenceForTest(t, f, aliceCtx, input, "alice-invite-message-broadcast-join", 9091)
|
||||||
|
got, err := f.router.onPhoneGetGroupCall(f.userCtx(f.bob, 22), &tg.PhoneGetGroupCallRequest{
|
||||||
|
Call: &tg.InputGroupCallInviteMessage{MsgID: bobMsgID},
|
||||||
|
Limit: 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get by invite message: %v", err)
|
||||||
|
}
|
||||||
|
if got.Call.(*tg.GroupCall).ID != call.ID {
|
||||||
|
t.Fatalf("invite message call id = %d, want %d", got.Call.(*tg.GroupCall).ID, call.ID)
|
||||||
|
}
|
||||||
|
block := conferenceTestBlock(conferenceBroadcastCommitConstructor, "invite-message-chain-block")
|
||||||
|
if _, err := f.router.onPhoneSendConferenceCallBroadcast(aliceCtx, &tg.PhoneSendConferenceCallBroadcastRequest{
|
||||||
|
Call: input,
|
||||||
|
Block: block,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("broadcast invite message chain block: %v", err)
|
||||||
|
}
|
||||||
|
chain, err := f.router.onPhoneGetGroupCallChainBlocks(f.userCtx(f.bob, 22), &tg.PhoneGetGroupCallChainBlocksRequest{
|
||||||
|
Call: &tg.InputGroupCallInviteMessage{MsgID: bobMsgID},
|
||||||
|
SubChainID: 1,
|
||||||
|
Offset: domain.GroupCallChainBlockLatestOffset,
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get latest by invite message: %v", err)
|
||||||
|
}
|
||||||
|
latest := findUpdate[*tg.UpdateGroupCallChainBlocks](t, chain)
|
||||||
|
if latest.SubChainID != 1 || latest.NextOffset != 1 || len(latest.Blocks) != 1 || conferenceTestConstructor(latest.Blocks[0]) != conferenceBroadcastCommitServerConstructor {
|
||||||
|
t.Fatalf("invite message latest block = %+v", latest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhoneDiscardMigrateConferenceCarriesSlug(t *testing.T) {
|
||||||
|
f := newConferenceFixture(t)
|
||||||
|
aliceCtx := f.userCtx(f.alice, 11)
|
||||||
|
bobCtx := f.userCtx(f.bob, 22)
|
||||||
|
ga, gaHash, gb := phoneTestKeys()
|
||||||
|
requested, err := f.router.onPhoneRequestCall(aliceCtx, &tg.PhoneRequestCallRequest{
|
||||||
|
UserID: &tg.InputUser{UserID: f.bob.ID, AccessHash: f.bob.AccessHash},
|
||||||
|
RandomID: 9,
|
||||||
|
GAHash: gaHash,
|
||||||
|
Protocol: phoneTestProtocol(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request call: %v", err)
|
||||||
|
}
|
||||||
|
peer := tg.InputPhoneCall{ID: requested.PhoneCall.(*tg.PhoneCallWaiting).ID, AccessHash: requested.PhoneCall.(*tg.PhoneCallWaiting).AccessHash}
|
||||||
|
if _, err := f.router.onPhoneAcceptCall(bobCtx, &tg.PhoneAcceptCallRequest{Peer: peer, GB: gb, Protocol: phoneTestProtocol()}); err != nil {
|
||||||
|
t.Fatalf("accept call: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := f.router.onPhoneConfirmCall(aliceCtx, &tg.PhoneConfirmCallRequest{Peer: peer, GA: ga, KeyFingerprint: 123, Protocol: phoneTestProtocol()}); err != nil {
|
||||||
|
t.Fatalf("confirm call: %v", err)
|
||||||
|
}
|
||||||
|
create, err := f.router.onPhoneCreateConferenceCall(aliceCtx, &tg.PhoneCreateConferenceCallRequest{RandomID: 99})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create conference: %v", err)
|
||||||
|
}
|
||||||
|
call := findUpdate[*tg.UpdateGroupCall](t, create).Call.(*tg.GroupCall)
|
||||||
|
slug := conferenceSlugFromLink(t, call.InviteLink)
|
||||||
|
updates, err := f.router.onPhoneDiscardCall(aliceCtx, &tg.PhoneDiscardCallRequest{
|
||||||
|
Peer: peer,
|
||||||
|
Duration: 1,
|
||||||
|
Reason: &tg.PhoneCallDiscardReasonMigrateConferenceCall{Slug: slug},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("discard migrate: %v", err)
|
||||||
|
}
|
||||||
|
discarded := findUpdate[*tg.UpdatePhoneCall](t, updates).PhoneCall.(*tg.PhoneCallDiscarded)
|
||||||
|
reason, ok := discarded.Reason.(*tg.PhoneCallDiscardReasonMigrateConferenceCall)
|
||||||
|
if !ok || reason.Slug != slug {
|
||||||
|
t.Fatalf("discard reason = %#v, want slug %q", discarded.Reason, slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ clock.Clock = (*phoneTestClock)(nil)
|
||||||
|
|
||||||
|
func conferenceSlugFromLink(t *testing.T, link string) string {
|
||||||
|
t.Helper()
|
||||||
|
u, err := url.Parse(link)
|
||||||
|
if err == nil {
|
||||||
|
if slug := u.Query().Get("slug"); slug != "" {
|
||||||
|
return slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx := strings.LastIndex(link, "slug=")
|
||||||
|
if idx < 0 {
|
||||||
|
t.Fatalf("link %q does not contain slug", link)
|
||||||
|
}
|
||||||
|
return link[idx+len("slug="):]
|
||||||
|
}
|
||||||
|
|
||||||
|
func conferenceTestBlock(constructor uint32, suffix string) []byte {
|
||||||
|
block := make([]byte, 4+len(suffix))
|
||||||
|
binary.LittleEndian.PutUint32(block[:4], constructor)
|
||||||
|
copy(block[4:], suffix)
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
func conferenceTestConstructor(block []byte) uint32 {
|
||||||
|
if len(block) < 4 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return binary.LittleEndian.Uint32(block[:4])
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastPathSegmentFromLink(t *testing.T, link string) string {
|
||||||
|
t.Helper()
|
||||||
|
u, err := url.Parse(link)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse link %q: %v", link, err)
|
||||||
|
}
|
||||||
|
segments := strings.Split(strings.Trim(u.EscapedPath(), "/"), "/")
|
||||||
|
if len(segments) == 0 || segments[len(segments)-1] == "" {
|
||||||
|
t.Fatalf("link %q has no path segment", link)
|
||||||
|
}
|
||||||
|
segment, err := url.PathUnescape(segments[len(segments)-1])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode path segment in %q: %v", link, err)
|
||||||
|
}
|
||||||
|
return segment
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,8 @@ func groupCallErr(err error) error {
|
||||||
return groupCallSSRCDuplicateErr()
|
return groupCallSSRCDuplicateErr()
|
||||||
case errors.Is(err, domain.ErrGroupCallNotJoined):
|
case errors.Is(err, domain.ErrGroupCallNotJoined):
|
||||||
return groupCallJoinMissingErr()
|
return groupCallJoinMissingErr()
|
||||||
|
case errors.Is(err, domain.ErrConferenceChainInvalid):
|
||||||
|
return confWriteChainInvalidErr()
|
||||||
default:
|
default:
|
||||||
return internalErr()
|
return internalErr()
|
||||||
}
|
}
|
||||||
|
|
@ -41,30 +43,69 @@ type groupCallScope struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *groupCallScope) canManage() bool {
|
func (s *groupCallScope) canManage() bool {
|
||||||
|
if s.call.Conference() {
|
||||||
|
return s.userID != 0 && s.userID == s.call.CreatorUserID
|
||||||
|
}
|
||||||
return channelMemberIsAdmin(s.member)
|
return channelMemberIsAdmin(s.member)
|
||||||
}
|
}
|
||||||
|
|
||||||
// groupCallScopeFrom 解析 InputGroupCallClass(仅 id+access_hash 变体;slug/
|
// groupCallScopeFrom 解析 InputGroupCallClass 并校验访问权。普通 group call 继续
|
||||||
// inviteMessage 属 conference 路径,返回 GROUPCALL_INVALID)并校验成员资格。
|
// 走频道成员资格;conference call 走 creator/participant/invite/slug 访问模型。
|
||||||
func (r *Router) groupCallScopeFrom(ctx context.Context, in tg.InputGroupCallClass) (*groupCallScope, error) {
|
func (r *Router) groupCallScopeFrom(ctx context.Context, in tg.InputGroupCallClass) (*groupCallScope, error) {
|
||||||
if r.deps.GroupCalls == nil || r.deps.Channels == nil {
|
if r.deps.GroupCalls == nil {
|
||||||
return nil, notImplementedErr()
|
return nil, notImplementedErr()
|
||||||
}
|
}
|
||||||
userID, err := r.phoneRequireUser(ctx)
|
userID, err := r.phoneRequireUser(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
callID, accessHash, err := inputGroupCallRef(in)
|
var call domain.GroupCall
|
||||||
if err != nil {
|
var found bool
|
||||||
return nil, err
|
allowBySlug := false
|
||||||
}
|
switch v := in.(type) {
|
||||||
call, found, err := r.deps.GroupCalls.Get(ctx, callID)
|
case *tg.InputGroupCall:
|
||||||
if err != nil {
|
call, found, err = r.deps.GroupCalls.Get(ctx, v.ID)
|
||||||
return nil, internalErr()
|
if err != nil {
|
||||||
}
|
return nil, internalErr()
|
||||||
if !found || call.AccessHash != accessHash {
|
}
|
||||||
|
if !found || call.AccessHash != v.AccessHash {
|
||||||
|
return nil, groupCallInvalidErr()
|
||||||
|
}
|
||||||
|
case *tg.InputGroupCallSlug:
|
||||||
|
call, found, err = r.deps.GroupCalls.GetBySlug(ctx, v.Slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
if !found || !call.Conference() {
|
||||||
|
return nil, groupCallInvalidErr()
|
||||||
|
}
|
||||||
|
allowBySlug = true
|
||||||
|
case *tg.InputGroupCallInviteMessage:
|
||||||
|
call, _, found, err = r.deps.GroupCalls.GetByInviteMessage(ctx, userID, v.MsgID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
if !found || !call.Conference() {
|
||||||
|
return nil, groupCallInvalidErr()
|
||||||
|
}
|
||||||
|
default:
|
||||||
return nil, groupCallInvalidErr()
|
return nil, groupCallInvalidErr()
|
||||||
}
|
}
|
||||||
|
if call.Conference() {
|
||||||
|
if !allowBySlug {
|
||||||
|
allowed, err := r.conferenceCallCanAccess(ctx, call.ID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
return nil, groupCallForbiddenErr()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &groupCallScope{userID: userID, call: call}, nil
|
||||||
|
}
|
||||||
|
if r.deps.Channels == nil {
|
||||||
|
return nil, notImplementedErr()
|
||||||
|
}
|
||||||
view, err := r.deps.Channels.GetChannel(ctx, userID, call.ChannelID)
|
view, err := r.deps.Channels.GetChannel(ctx, userID, call.ChannelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, groupCallForbiddenErr()
|
return nil, groupCallForbiddenErr()
|
||||||
|
|
@ -75,6 +116,19 @@ func (r *Router) groupCallScopeFrom(ctx context.Context, in tg.InputGroupCallCla
|
||||||
return &groupCallScope{userID: userID, call: call, channel: view.Channel, member: view.Self}, nil
|
return &groupCallScope{userID: userID, call: call, channel: view.Channel, member: view.Self}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Router) conferenceCallCanAccess(ctx context.Context, callID, userID int64) (bool, error) {
|
||||||
|
recipients, err := r.deps.GroupCalls.ConferenceRecipients(ctx, callID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, id := range recipients {
|
||||||
|
if id == userID {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Router) onPhoneCreateGroupCall(ctx context.Context, req *tg.PhoneCreateGroupCallRequest) (tg.UpdatesClass, error) {
|
func (r *Router) onPhoneCreateGroupCall(ctx context.Context, req *tg.PhoneCreateGroupCallRequest) (tg.UpdatesClass, error) {
|
||||||
if req == nil {
|
if req == nil {
|
||||||
return nil, inputRequestInvalidErr()
|
return nil, inputRequestInvalidErr()
|
||||||
|
|
@ -175,6 +229,14 @@ func (r *Router) onPhoneJoinGroupCall(ctx context.Context, req *tg.PhoneJoinGrou
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
now := int(r.clock.Now().Unix())
|
now := int(r.clock.Now().Unix())
|
||||||
|
var publicKey []byte
|
||||||
|
if pk, ok := req.GetPublicKey(); ok {
|
||||||
|
publicKey = append([]byte(nil), pk[:]...)
|
||||||
|
}
|
||||||
|
var joinBlock []byte
|
||||||
|
if block, ok := req.GetBlock(); ok {
|
||||||
|
joinBlock = append([]byte(nil), block...)
|
||||||
|
}
|
||||||
// 视频内部状态:endpoint 服务端铸造(join 响应 video.endpoint 与日后
|
// 视频内部状态:endpoint 服务端铸造(join 响应 video.endpoint 与日后
|
||||||
// participant.video.endpoint 必须逐字节一致);ssrc-groups 无论摄像头开关都
|
// participant.video.endpoint 必须逐字节一致);ssrc-groups 无论摄像头开关都
|
||||||
// 存档——video_stopped=false(join flag 或后续 self-edit)时原样回放。
|
// 存档——video_stopped=false(join flag 或后续 self-edit)时原样回放。
|
||||||
|
|
@ -190,6 +252,8 @@ func (r *Router) onPhoneJoinGroupCall(ctx context.Context, req *tg.PhoneJoinGrou
|
||||||
SSRC: ssrc,
|
SSRC: ssrc,
|
||||||
Muted: req.Muted,
|
Muted: req.Muted,
|
||||||
IsAdmin: scope.canManage(),
|
IsAdmin: scope.canManage(),
|
||||||
|
PublicKey: publicKey,
|
||||||
|
JoinBlock: joinBlock,
|
||||||
VideoJSON: encodeVideoState(videoState),
|
VideoJSON: encodeVideoState(videoState),
|
||||||
Now: now,
|
Now: now,
|
||||||
})
|
})
|
||||||
|
|
@ -215,6 +279,25 @@ func (r *Router) onPhoneJoinGroupCall(ctx context.Context, req *tg.PhoneJoinGrou
|
||||||
_, _ = r.deps.GroupCalls.Leave(ctx, scope.call.ID, scope.userID, now)
|
_, _ = r.deps.GroupCalls.Leave(ctx, scope.call.ID, scope.userID, now)
|
||||||
return nil, internalErr()
|
return nil, internalErr()
|
||||||
}
|
}
|
||||||
|
var conferenceJoinBlock domain.GroupCallChainBlock
|
||||||
|
var hasConferenceJoinBlock bool
|
||||||
|
if scope.call.Conference() && len(joinBlock) > 0 {
|
||||||
|
block, err := r.deps.GroupCalls.AppendChainBlock(ctx, domain.GroupCallChainBlock{
|
||||||
|
CallID: scope.call.ID,
|
||||||
|
SubChainID: 0,
|
||||||
|
Offset: -1,
|
||||||
|
AuthorUserID: scope.userID,
|
||||||
|
Block: joinBlock,
|
||||||
|
CreatedAt: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
_ = sfuService.Leave(ctx, scope.call.ID, scope.userID, sfu.EndpointMain)
|
||||||
|
_, _ = r.deps.GroupCalls.Leave(ctx, scope.call.ID, scope.userID, now)
|
||||||
|
return nil, groupCallErr(err)
|
||||||
|
}
|
||||||
|
conferenceJoinBlock = block
|
||||||
|
hasConferenceJoinBlock = true
|
||||||
|
}
|
||||||
// 扇出给房间/在线群成员(操作者其它设备含其中;本设备从 RPC 返回拿)。
|
// 扇出给房间/在线群成员(操作者其它设备含其中;本设备从 RPC 返回拿)。
|
||||||
channel := r.groupCallMutationFanout(ctx, scope.channel, mut)
|
channel := r.groupCallMutationFanout(ctx, scope.channel, mut)
|
||||||
// 响应(TDesktop 从本 RPC 返回的 Updates 摘取 updateGroupCallConnection,不会等推送):
|
// 响应(TDesktop 从本 RPC 返回的 Updates 摘取 updateGroupCallConnection,不会等推送):
|
||||||
|
|
@ -226,8 +309,16 @@ func (r *Router) onPhoneJoinGroupCall(ctx context.Context, req *tg.PhoneJoinGrou
|
||||||
}, []int64{scope.userID})
|
}, []int64{scope.userID})
|
||||||
out.Updates = append(out.Updates, &tg.UpdateGroupCallConnection{Params: tg.DataJSON{Data: params}})
|
out.Updates = append(out.Updates, &tg.UpdateGroupCallConnection{Params: tg.DataJSON{Data: params}})
|
||||||
callUpdate := &tg.UpdateGroupCall{Call: tgGroupCall(mut.Call, scope.userID, scope.canManage())}
|
callUpdate := &tg.UpdateGroupCall{Call: tgGroupCall(mut.Call, scope.userID, scope.canManage())}
|
||||||
callUpdate.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
if channel.ID != 0 {
|
||||||
|
callUpdate.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
||||||
|
}
|
||||||
out.Updates = append(out.Updates, callUpdate)
|
out.Updates = append(out.Updates, callUpdate)
|
||||||
|
if hasConferenceJoinBlock {
|
||||||
|
nextOffset := conferenceJoinBlock.Offset + 1
|
||||||
|
blocks := [][]byte{conferenceJoinBlock.Block}
|
||||||
|
r.pushConferenceChainBlocks(ctx, mut.Call, conferenceJoinBlock.SubChainID, blocks, nextOffset)
|
||||||
|
out.Updates = append(out.Updates, conferenceChainBlocksUpdate(mut.Call, conferenceJoinBlock.SubChainID, blocks, nextOffset))
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -253,12 +344,16 @@ func (r *Router) onPhoneLeaveGroupCall(ctx context.Context, req *tg.PhoneLeaveGr
|
||||||
_ = r.deps.SFU.Leave(ctx, scope.call.ID, scope.userID, sfu.EndpointMain)
|
_ = r.deps.SFU.Leave(ctx, scope.call.ID, scope.userID, sfu.EndpointMain)
|
||||||
}
|
}
|
||||||
channel := r.groupCallMutationFanout(ctx, scope.channel, mut)
|
channel := r.groupCallMutationFanout(ctx, scope.channel, mut)
|
||||||
return r.groupCallUpdateContainer(ctx, scope.userID, channel,
|
out := r.groupCallUpdateContainer(ctx, scope.userID, channel,
|
||||||
&tg.UpdateGroupCallParticipants{
|
&tg.UpdateGroupCallParticipants{
|
||||||
Call: &tg.InputGroupCall{ID: mut.Call.ID, AccessHash: mut.Call.AccessHash},
|
Call: &tg.InputGroupCall{ID: mut.Call.ID, AccessHash: mut.Call.AccessHash},
|
||||||
Participants: tgGroupCallParticipants([]domain.GroupCallParticipant{mut.Participant}, scope.userID),
|
Participants: tgGroupCallParticipants([]domain.GroupCallParticipant{mut.Participant}, scope.userID),
|
||||||
Version: mut.Call.Version,
|
Version: mut.Call.Version,
|
||||||
}, []int64{scope.userID}), nil
|
}, []int64{scope.userID})
|
||||||
|
if mut.Call.Conference() && !mut.Call.Active() {
|
||||||
|
out.Updates = append(out.Updates, groupCallUpdateFor(domain.Channel{}, mut.Call, scope.userID, scope.userID == mut.Call.CreatorUserID))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) onPhoneDiscardGroupCall(ctx context.Context, in tg.InputGroupCallClass) (tg.UpdatesClass, error) {
|
func (r *Router) onPhoneDiscardGroupCall(ctx context.Context, in tg.InputGroupCallClass) (tg.UpdatesClass, error) {
|
||||||
|
|
@ -270,13 +365,18 @@ func (r *Router) onPhoneDiscardGroupCall(ctx context.Context, in tg.InputGroupCa
|
||||||
return nil, tgerr400("CHAT_ADMIN_REQUIRED")
|
return nil, tgerr400("CHAT_ADMIN_REQUIRED")
|
||||||
}
|
}
|
||||||
now := int(r.clock.Now().Unix())
|
now := int(r.clock.Now().Unix())
|
||||||
call, _, err := r.deps.GroupCalls.Discard(ctx, scope.call.ID, now)
|
call, activeBeforeDiscard, err := r.deps.GroupCalls.Discard(ctx, scope.call.ID, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, groupCallErr(err)
|
return nil, groupCallErr(err)
|
||||||
}
|
}
|
||||||
if r.deps.SFU != nil {
|
if r.deps.SFU != nil {
|
||||||
_ = r.deps.SFU.CloseRoom(ctx, call.ID)
|
_ = r.deps.SFU.CloseRoom(ctx, call.ID)
|
||||||
}
|
}
|
||||||
|
if call.Conference() {
|
||||||
|
r.pushConferenceGroupCallUpdateTo(ctx, call, groupCallParticipantUserIDs(activeBeforeDiscard))
|
||||||
|
return r.groupCallUpdateContainer(ctx, scope.userID, domain.Channel{},
|
||||||
|
groupCallUpdateFor(domain.Channel{}, call, scope.userID, true), nil), nil
|
||||||
|
}
|
||||||
// 清 channel 关联 + ended 服务消息(带 duration)。
|
// 清 channel 关联 + ended 服务消息(带 duration)。
|
||||||
channel := scope.channel
|
channel := scope.channel
|
||||||
if updated, err := r.deps.Channels.SetActiveCall(ctx, channel.ID, 0, 0, false); err == nil {
|
if updated, err := r.deps.Channels.SetActiveCall(ctx, channel.ID, 0, 0, false); err == nil {
|
||||||
|
|
@ -328,11 +428,15 @@ func (r *Router) onPhoneGetGroupCall(ctx context.Context, req *tg.PhoneGetGroupC
|
||||||
for _, p := range page.Participants {
|
for _, p := range page.Participants {
|
||||||
userIDs = append(userIDs, p.UserID)
|
userIDs = append(userIDs, p.UserID)
|
||||||
}
|
}
|
||||||
|
chats := []tg.ChatClass{}
|
||||||
|
if !scope.call.Conference() {
|
||||||
|
chats = append(chats, tgChannel(scope.userID, scope.channel, &scope.member))
|
||||||
|
}
|
||||||
return &tg.PhoneGroupCall{
|
return &tg.PhoneGroupCall{
|
||||||
Call: tgGroupCall(scope.call, scope.userID, scope.canManage()),
|
Call: tgGroupCall(scope.call, scope.userID, scope.canManage()),
|
||||||
Participants: tgGroupCallParticipants(page.Participants, scope.userID),
|
Participants: tgGroupCallParticipants(page.Participants, scope.userID),
|
||||||
ParticipantsNextOffset: page.NextOffset,
|
ParticipantsNextOffset: page.NextOffset,
|
||||||
Chats: []tg.ChatClass{tgChannel(scope.userID, scope.channel, &scope.member)},
|
Chats: chats,
|
||||||
Users: r.tgUsersForIDs(ctx, scope.userID, userIDs),
|
Users: r.tgUsersForIDs(ctx, scope.userID, userIDs),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -358,11 +462,15 @@ func (r *Router) onPhoneGetGroupParticipants(ctx context.Context, req *tg.PhoneG
|
||||||
userIDs = append(userIDs, p.UserID)
|
userIDs = append(userIDs, p.UserID)
|
||||||
}
|
}
|
||||||
// 响应 version=当前值:客户端 version 跳号后据此重建本地状态并恢复增量应用。
|
// 响应 version=当前值:客户端 version 跳号后据此重建本地状态并恢复增量应用。
|
||||||
|
chats := []tg.ChatClass{}
|
||||||
|
if !scope.call.Conference() {
|
||||||
|
chats = append(chats, tgChannel(scope.userID, scope.channel, &scope.member))
|
||||||
|
}
|
||||||
return &tg.PhoneGroupParticipants{
|
return &tg.PhoneGroupParticipants{
|
||||||
Count: page.Count,
|
Count: page.Count,
|
||||||
Participants: tgGroupCallParticipants(page.Participants, scope.userID),
|
Participants: tgGroupCallParticipants(page.Participants, scope.userID),
|
||||||
NextOffset: page.NextOffset,
|
NextOffset: page.NextOffset,
|
||||||
Chats: []tg.ChatClass{tgChannel(scope.userID, scope.channel, &scope.member)},
|
Chats: chats,
|
||||||
Users: r.tgUsersForIDs(ctx, scope.userID, userIDs),
|
Users: r.tgUsersForIDs(ctx, scope.userID, userIDs),
|
||||||
Version: page.Version,
|
Version: page.Version,
|
||||||
}, nil
|
}, nil
|
||||||
|
|
@ -417,6 +525,8 @@ func (r *Router) onPhoneCheckGroupCall(ctx context.Context, req *tg.PhoneCheckGr
|
||||||
|
|
||||||
func groupCallUpdateFor(channel domain.Channel, call domain.GroupCall, viewerUserID int64, canManage bool) *tg.UpdateGroupCall {
|
func groupCallUpdateFor(channel domain.Channel, call domain.GroupCall, viewerUserID int64, canManage bool) *tg.UpdateGroupCall {
|
||||||
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerUserID, canManage)}
|
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerUserID, canManage)}
|
||||||
update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
if channel.ID != 0 {
|
||||||
|
update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
||||||
|
}
|
||||||
return update
|
return update
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,9 @@ func (r *Router) onPhoneInviteToGroupCall(ctx context.Context, req *tg.PhoneInvi
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if scope.call.Conference() {
|
||||||
|
return nil, notImplementedErr()
|
||||||
|
}
|
||||||
if len(req.Users) == 0 || len(req.Users) > maxInviteToGroupCallUsers {
|
if len(req.Users) == 0 || len(req.Users) > maxInviteToGroupCallUsers {
|
||||||
return nil, limitInvalidErr()
|
return nil, limitInvalidErr()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,20 @@ type groupCallSessions struct {
|
||||||
|
|
||||||
func (s *groupCallSessions) IsUserOnline(userID int64) bool { return false }
|
func (s *groupCallSessions) IsUserOnline(userID int64) bool { return false }
|
||||||
func (s *groupCallSessions) OnlineUserIDsForCandidates(candidateUserIDs []int64, limit int) []int64 {
|
func (s *groupCallSessions) OnlineUserIDsForCandidates(candidateUserIDs []int64, limit int) []int64 {
|
||||||
return nil
|
online := map[int64]struct{}{}
|
||||||
|
for _, id := range s.online {
|
||||||
|
online[id] = struct{}{}
|
||||||
|
}
|
||||||
|
out := make([]int64, 0, len(candidateUserIDs))
|
||||||
|
for _, id := range candidateUserIDs {
|
||||||
|
if _, ok := online[id]; ok {
|
||||||
|
out = append(out, id)
|
||||||
|
if limit > 0 && len(out) == limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
func (s *groupCallSessions) TrackChannelInterest([8]byte, int64, int64, []int64) {}
|
func (s *groupCallSessions) TrackChannelInterest([8]byte, int64, int64, []int64) {}
|
||||||
func (s *groupCallSessions) ClearChannelInterest([8]byte, int64, int64) {}
|
func (s *groupCallSessions) ClearChannelInterest([8]byte, int64, int64) {}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,10 @@ func (r *Router) groupCallUpdateContainer(ctx context.Context, viewerUserID int6
|
||||||
|
|
||||||
// pushGroupCallUpdate 把 updateGroupCall(call 行变化)推给在线群成员。
|
// pushGroupCallUpdate 把 updateGroupCall(call 行变化)推给在线群成员。
|
||||||
func (r *Router) pushGroupCallUpdate(ctx context.Context, channel domain.Channel, call domain.GroupCall) {
|
func (r *Router) pushGroupCallUpdate(ctx context.Context, channel domain.Channel, call domain.GroupCall) {
|
||||||
|
if call.Conference() {
|
||||||
|
r.pushConferenceGroupCallUpdate(ctx, call)
|
||||||
|
return
|
||||||
|
}
|
||||||
recipients := r.groupCallOnlineRecipients(channel.ID)
|
recipients := r.groupCallOnlineRecipients(channel.ID)
|
||||||
for _, viewerID := range recipients {
|
for _, viewerID := range recipients {
|
||||||
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerID, false)}
|
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerID, false)}
|
||||||
|
|
@ -54,6 +58,10 @@ func (r *Router) pushGroupCallUpdate(ctx context.Context, channel domain.Channel
|
||||||
// pushGroupCallParticipantsUpdate 把参与者增量(version=N)推给在线群成员。
|
// pushGroupCallParticipantsUpdate 把参与者增量(version=N)推给在线群成员。
|
||||||
// 每个 viewer 单独构建:participant.Self flag 是 per-viewer 的。
|
// 每个 viewer 单独构建:participant.Self flag 是 per-viewer 的。
|
||||||
func (r *Router) pushGroupCallParticipantsUpdate(ctx context.Context, channel domain.Channel, call domain.GroupCall, rows []domain.GroupCallParticipant) {
|
func (r *Router) pushGroupCallParticipantsUpdate(ctx context.Context, channel domain.Channel, call domain.GroupCall, rows []domain.GroupCallParticipant) {
|
||||||
|
if call.Conference() {
|
||||||
|
r.pushConferenceGroupCallParticipantsUpdate(ctx, call, rows)
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -73,6 +81,62 @@ func (r *Router) pushGroupCallParticipantsUpdate(ctx context.Context, channel do
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Router) conferenceCallRecipients(ctx context.Context, callID int64) []int64 {
|
||||||
|
return r.conferenceCallRecipientsWith(ctx, callID, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) conferenceCallRecipientsWith(ctx context.Context, callID int64, extraUserIDs []int64) []int64 {
|
||||||
|
if r.deps.GroupCalls == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
recipients, err := r.deps.GroupCalls.ConferenceRecipients(ctx, callID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
recipients = append(recipients, extraUserIDs...)
|
||||||
|
recipients = uniquePositiveUserIDs(recipients)
|
||||||
|
if len(recipients) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if provider, ok := r.deps.Sessions.(OnlineUserProvider); ok {
|
||||||
|
return provider.OnlineUserIDsForCandidates(recipients, domain.MaxChannelRealtimeFanout)
|
||||||
|
}
|
||||||
|
return recipients
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) pushConferenceGroupCallUpdate(ctx context.Context, call domain.GroupCall) {
|
||||||
|
r.pushConferenceGroupCallUpdateTo(ctx, call, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) pushConferenceGroupCallUpdateTo(ctx context.Context, call domain.GroupCall, extraUserIDs []int64) {
|
||||||
|
recipients := r.conferenceCallRecipientsWith(ctx, call.ID, extraUserIDs)
|
||||||
|
for _, viewerID := range recipients {
|
||||||
|
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerID, viewerID == call.CreatorUserID)}
|
||||||
|
r.pushUserMessage(ctx, viewerID, "conference call update",
|
||||||
|
r.groupCallUpdateContainer(ctx, viewerID, domain.Channel{}, update, []int64{call.CreatorUserID}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) pushConferenceGroupCallParticipantsUpdate(ctx context.Context, call domain.GroupCall, rows []domain.GroupCallParticipant) {
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userIDs := make([]int64, 0, len(rows))
|
||||||
|
for _, p := range rows {
|
||||||
|
userIDs = append(userIDs, p.UserID)
|
||||||
|
}
|
||||||
|
recipients := r.conferenceCallRecipients(ctx, call.ID)
|
||||||
|
for _, viewerID := range recipients {
|
||||||
|
update := &tg.UpdateGroupCallParticipants{
|
||||||
|
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash},
|
||||||
|
Participants: tgGroupCallParticipants(rows, viewerID),
|
||||||
|
Version: call.Version,
|
||||||
|
}
|
||||||
|
r.pushUserMessage(ctx, viewerID, "conference call participants",
|
||||||
|
r.groupCallUpdateContainer(ctx, viewerID, domain.Channel{}, update, userIDs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// pushGroupCallServiceMessage 把 started/ended/invite 服务消息(带频道 pts)推给
|
// pushGroupCallServiceMessage 把 started/ended/invite 服务消息(带频道 pts)推给
|
||||||
// 活跃成员(res.Recipients)。复用 channelOperationUpdates 的 per-viewer 构建。
|
// 活跃成员(res.Recipients)。复用 channelOperationUpdates 的 per-viewer 构建。
|
||||||
func (r *Router) pushGroupCallServiceMessage(ctx context.Context, originUserID int64, res domain.SendChannelMessageResult) {
|
func (r *Router) pushGroupCallServiceMessage(ctx context.Context, originUserID int64, res domain.SendChannelMessageResult) {
|
||||||
|
|
@ -93,6 +157,11 @@ func (r *Router) pushGroupCallServiceMessage(ctx context.Context, originUserID i
|
||||||
// groupCallMutationFanout 是参与者维度变更后的统一扇出:participants 增量 +
|
// groupCallMutationFanout 是参与者维度变更后的统一扇出:participants 增量 +
|
||||||
// call_not_empty 翻转时的 channel 维度刷新(Android banner 对 flag 依赖更重)。
|
// call_not_empty 翻转时的 channel 维度刷新(Android banner 对 flag 依赖更重)。
|
||||||
func (r *Router) groupCallMutationFanout(ctx context.Context, channel domain.Channel, mut domain.GroupCallMutation) domain.Channel {
|
func (r *Router) groupCallMutationFanout(ctx context.Context, channel domain.Channel, mut domain.GroupCallMutation) domain.Channel {
|
||||||
|
if mut.Call.Conference() {
|
||||||
|
r.pushConferenceGroupCallParticipantsUpdate(ctx, mut.Call, []domain.GroupCallParticipant{mut.Participant})
|
||||||
|
r.pushConferenceGroupCallUpdate(ctx, mut.Call)
|
||||||
|
return domain.Channel{}
|
||||||
|
}
|
||||||
r.pushGroupCallParticipantsUpdate(ctx, channel, mut.Call, []domain.GroupCallParticipant{mut.Participant})
|
r.pushGroupCallParticipantsUpdate(ctx, channel, mut.Call, []domain.GroupCallParticipant{mut.Participant})
|
||||||
wantNotEmpty := mut.Call.Active() && mut.Call.ParticipantsCount > 0
|
wantNotEmpty := mut.Call.Active() && mut.Call.ParticipantsCount > 0
|
||||||
if channel.ActiveCallNotEmpty != wantNotEmpty && r.deps.Channels != nil {
|
if channel.ActiveCallNotEmpty != wantNotEmpty && r.deps.Channels != nil {
|
||||||
|
|
@ -107,3 +176,33 @@ func (r *Router) groupCallMutationFanout(ctx context.Context, channel domain.Cha
|
||||||
}
|
}
|
||||||
return channel
|
return channel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func groupCallParticipantUserIDs(rows []domain.GroupCallParticipant) []int64 {
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]int64, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, row.UserID)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniquePositiveUserIDs(ids []int64) []int64 {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := make(map[int64]struct{}, len(ids))
|
||||||
|
out := make([]int64, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if id <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,18 @@ func (r *Router) registerPhone(d *tg.ServerDispatcher) {
|
||||||
d.OnPhoneGetGroupCall(r.onPhoneGetGroupCall)
|
d.OnPhoneGetGroupCall(r.onPhoneGetGroupCall)
|
||||||
d.OnPhoneGetGroupParticipants(r.onPhoneGetGroupParticipants)
|
d.OnPhoneGetGroupParticipants(r.onPhoneGetGroupParticipants)
|
||||||
d.OnPhoneCheckGroupCall(r.onPhoneCheckGroupCall)
|
d.OnPhoneCheckGroupCall(r.onPhoneCheckGroupCall)
|
||||||
|
d.OnPhoneExportGroupCallInvite(r.onPhoneExportGroupCallInvite)
|
||||||
d.OnPhoneEditGroupCallParticipant(r.onPhoneEditGroupCallParticipant)
|
d.OnPhoneEditGroupCallParticipant(r.onPhoneEditGroupCallParticipant)
|
||||||
d.OnPhoneEditGroupCallTitle(r.onPhoneEditGroupCallTitle)
|
d.OnPhoneEditGroupCallTitle(r.onPhoneEditGroupCallTitle)
|
||||||
d.OnPhoneToggleGroupCallSettings(r.onPhoneToggleGroupCallSettings)
|
d.OnPhoneToggleGroupCallSettings(r.onPhoneToggleGroupCallSettings)
|
||||||
d.OnPhoneInviteToGroupCall(r.onPhoneInviteToGroupCall)
|
d.OnPhoneInviteToGroupCall(r.onPhoneInviteToGroupCall)
|
||||||
|
// Ad-hoc E2E conference call(P2P 通话升级/拉人路径)。
|
||||||
|
d.OnPhoneCreateConferenceCall(r.onPhoneCreateConferenceCall)
|
||||||
|
d.OnPhoneInviteConferenceCallParticipant(r.onPhoneInviteConferenceCallParticipant)
|
||||||
|
d.OnPhoneDeleteConferenceCallParticipants(r.onPhoneDeleteConferenceCallParticipants)
|
||||||
|
d.OnPhoneSendConferenceCallBroadcast(r.onPhoneSendConferenceCallBroadcast)
|
||||||
|
d.OnPhoneDeclineConferenceCallInvite(r.onPhoneDeclineConferenceCallInvite)
|
||||||
|
d.OnPhoneGetGroupCallChainBlocks(r.onPhoneGetGroupCallChainBlocks)
|
||||||
// 屏幕共享(M4):同参与者第二媒体连接。
|
// 屏幕共享(M4):同参与者第二媒体连接。
|
||||||
d.OnPhoneJoinGroupCallPresentation(r.onPhoneJoinGroupCallPresentation)
|
d.OnPhoneJoinGroupCallPresentation(r.onPhoneJoinGroupCallPresentation)
|
||||||
d.OnPhoneLeaveGroupCallPresentation(r.onPhoneLeaveGroupCallPresentation)
|
d.OnPhoneLeaveGroupCallPresentation(r.onPhoneLeaveGroupCallPresentation)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ type ChannelStore interface {
|
||||||
SetChannelWallpaper(ctx context.Context, req domain.SetChannelWallpaperRequest) (domain.SetChannelWallpaperResult, error)
|
SetChannelWallpaper(ctx context.Context, req domain.SetChannelWallpaperRequest) (domain.SetChannelWallpaperResult, error)
|
||||||
EditChannelAbout(ctx context.Context, req domain.EditChannelAboutRequest) (domain.Channel, error)
|
EditChannelAbout(ctx context.Context, req domain.EditChannelAboutRequest) (domain.Channel, error)
|
||||||
EditChannelAdmin(ctx context.Context, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, 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)
|
EditChannelMemberRank(ctx context.Context, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error)
|
||||||
EditChannelBanned(ctx context.Context, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error)
|
EditChannelBanned(ctx context.Context, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error)
|
||||||
EditChannelDefaultBannedRights(ctx context.Context, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error)
|
EditChannelDefaultBannedRights(ctx context.Context, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error)
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,21 @@ import (
|
||||||
type GroupCallStore interface {
|
type GroupCallStore interface {
|
||||||
// CreateGroupCall 建会;同频道已有活跃通话返回 domain.ErrGroupCallAlreadyStarted。
|
// CreateGroupCall 建会;同频道已有活跃通话返回 domain.ErrGroupCallAlreadyStarted。
|
||||||
CreateGroupCall(ctx context.Context, call domain.GroupCall) (domain.GroupCall, error)
|
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)
|
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 与他人撞活跃唯一
|
// JoinGroupCall 加入/重进(同主键 upsert 换新 ssrc);ssrc 与他人撞活跃唯一
|
||||||
// 约束返回 domain.ErrGroupCallSSRCDuplicate;version++。
|
// 约束返回 domain.ErrGroupCallSSRCDuplicate;version++。
|
||||||
JoinGroupCall(ctx context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error)
|
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)
|
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 终结通话并清空参与者,返回终态 call 与此前活跃的参与者。
|
||||||
DiscardGroupCall(ctx context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error)
|
DiscardGroupCall(ctx context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error)
|
||||||
// TouchParticipant 刷新 checkGroupCall 保活水位,返回该用户当前活跃 ssrc 集合
|
// TouchParticipant 刷新 checkGroupCall 保活水位,返回该用户当前活跃 ssrc 集合
|
||||||
|
|
@ -36,7 +45,7 @@ type GroupCallStore interface {
|
||||||
//(每清一人 version++)。注意调用方必须叠加 SFU 媒体面活性做双过期判定。
|
//(每清一人 version++)。注意调用方必须叠加 SFU 媒体面活性做双过期判定。
|
||||||
SweepStaleParticipants(ctx context.Context, checkOlderThan, now int, limit int) ([]domain.GroupCallMutation, error)
|
SweepStaleParticipants(ctx context.Context, checkOlderThan, now int, limit int) ([]domain.GroupCallMutation, error)
|
||||||
// ResetAllParticipants 服务端重启恢复:把全部活跃通话的参与者批量置 left
|
// ResetAllParticipants 服务端重启恢复:把全部活跃通话的参与者批量置 left
|
||||||
//(每通话 version++),返回受影响的通话。
|
//(每通话 version++),conference 若因此变空则同步转 discarded,返回受影响的通话。
|
||||||
ResetAllParticipants(ctx context.Context, now int) ([]domain.GroupCall, error)
|
ResetAllParticipants(ctx context.Context, now int) ([]domain.GroupCall, error)
|
||||||
// NextRaiseHandRating 分配全局单调递增的举手序号(举手排序用)。
|
// NextRaiseHandRating 分配全局单调递增的举手序号(举手排序用)。
|
||||||
NextRaiseHandRating(ctx context.Context, callID int64) (int64, error)
|
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
|
SetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64, override domain.GroupCallParticipantOverride, clear bool) error
|
||||||
// GetParticipantOverride 取某 setter 对某 target 的覆盖。
|
// GetParticipantOverride 取某 setter 对某 target 的覆盖。
|
||||||
GetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64) (domain.GroupCallParticipantOverride, bool, error)
|
GetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64) (domain.GroupCallParticipantOverride, bool, error)
|
||||||
|
// CreateConferenceInvite 记录一条 conference 私聊邀请与其 message box id。
|
||||||
|
CreateConferenceInvite(ctx context.Context, invite domain.GroupCallInvite) (domain.GroupCallInvite, error)
|
||||||
|
SetConferenceInviteStatus(ctx context.Context, callID int64, inviteeUserID int64, msgID int, status domain.GroupCallInviteStatus, now int) (domain.GroupCallInvite, bool, error)
|
||||||
|
// ListConferenceRecipientUserIDs 返回 conference 在线推送/访问候选人。
|
||||||
|
// active 态只包含 creator、当前活跃参与者、pending/accepted invite 相关人;
|
||||||
|
// discarded 态包含所有历史参与者与 invite 相关人,允许客户端收尾轮询读取终态。
|
||||||
|
ListConferenceRecipientUserIDs(ctx context.Context, callID int64) ([]int64, error)
|
||||||
|
AppendGroupCallChainBlock(ctx context.Context, block domain.GroupCallChainBlock) (domain.GroupCallChainBlock, error)
|
||||||
|
ListGroupCallChainBlocks(ctx context.Context, callID int64, subChainID, offset, limit int) (domain.GroupCallChainBlockPage, error)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,25 +48,12 @@ func (s *ChannelStore) CreateChannel(_ context.Context, req domain.CreateChannel
|
||||||
}
|
}
|
||||||
channel.HasLink = true
|
channel.HasLink = true
|
||||||
creator := domain.ChannelMember{
|
creator := domain.ChannelMember{
|
||||||
ChannelID: channelID,
|
ChannelID: channelID,
|
||||||
UserID: req.CreatorUserID,
|
UserID: req.CreatorUserID,
|
||||||
Role: domain.ChannelRoleCreator,
|
Role: domain.ChannelRoleCreator,
|
||||||
Status: domain.ChannelMemberActive,
|
Status: domain.ChannelMemberActive,
|
||||||
JoinedAt: req.Date,
|
JoinedAt: req.Date,
|
||||||
AdminRights: domain.ChannelAdminRights{
|
AdminRights: domain.CreatorChannelAdminRights(),
|
||||||
ChangeInfo: true,
|
|
||||||
PostMessages: true,
|
|
||||||
EditMessages: true,
|
|
||||||
DeleteMessages: true,
|
|
||||||
PostStories: true,
|
|
||||||
EditStories: true,
|
|
||||||
DeleteStories: true,
|
|
||||||
BanUsers: true,
|
|
||||||
InviteUsers: true,
|
|
||||||
PinMessages: true,
|
|
||||||
AddAdmins: true,
|
|
||||||
ManageCall: true,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
s.channels[channelID] = channel
|
s.channels[channelID] = channel
|
||||||
s.invites[inviteHash] = domain.ChannelInvite{
|
s.invites[inviteHash] = domain.ChannelInvite{
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,6 @@ func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int6
|
||||||
|
|
||||||
type item struct {
|
type item struct {
|
||||||
channel domain.Channel
|
channel domain.Channel
|
||||||
joined bool
|
|
||||||
rank int
|
rank int
|
||||||
}
|
}
|
||||||
items := make([]item, 0, limit)
|
items := make([]item, 0, limit)
|
||||||
|
|
@ -98,9 +97,11 @@ func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int6
|
||||||
}
|
}
|
||||||
member, joined := s.members[channelID][viewerUserID]
|
member, joined := s.members[channelID][viewerUserID]
|
||||||
joined = joined && member.Status == domain.ChannelMemberActive
|
joined = joined && member.Status == domain.ChannelMemberActive
|
||||||
|
if joined {
|
||||||
|
continue
|
||||||
|
}
|
||||||
items = append(items, item{
|
items = append(items, item{
|
||||||
channel: cloneChannel(channel),
|
channel: cloneChannel(channel),
|
||||||
joined: joined,
|
|
||||||
rank: rank,
|
rank: rank,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -108,9 +109,6 @@ func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int6
|
||||||
if items[i].rank != items[j].rank {
|
if items[i].rank != items[j].rank {
|
||||||
return 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 {
|
if items[i].channel.ParticipantsCount != items[j].channel.ParticipantsCount {
|
||||||
return 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{}
|
out := domain.PublicChannelSearchResult{}
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if len(out.MyResults)+len(out.Results) >= limit {
|
if len(out.Results) >= limit {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if item.joined {
|
out.Results = append(out.Results, item.channel)
|
||||||
out.MyResults = append(out.MyResults, item.channel)
|
|
||||||
} else {
|
|
||||||
out.Results = append(out.Results, item.channel)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -311,7 +311,7 @@ func (s *ChannelStore) EditChannelAdmin(_ context.Context, req domain.EditChanne
|
||||||
member.AvailableMinPts = minPts
|
member.AvailableMinPts = minPts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
member.AdminRights = req.AdminRights
|
member.AdminRights = domain.NormalizeFullMegagroupAdminRights(channel, req.AdminRights)
|
||||||
member.Rank = req.Rank
|
member.Rank = req.Rank
|
||||||
if zeroChannelAdminRights(req.AdminRights) {
|
if zeroChannelAdminRights(req.AdminRights) {
|
||||||
member.Role = domain.ChannelRoleMember
|
member.Role = domain.ChannelRoleMember
|
||||||
|
|
@ -350,6 +350,89 @@ func (s *ChannelStore) EditChannelAdmin(_ context.Context, req domain.EditChanne
|
||||||
}, nil
|
}, 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) {
|
func (s *ChannelStore) EditChannelMemberRank(_ context.Context, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error) {
|
||||||
if req.UserID == 0 || req.ChannelID == 0 || req.MemberID == 0 {
|
if req.UserID == 0 || req.ChannelID == 0 || req.MemberID == 0 {
|
||||||
return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
|
return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
|
||||||
|
|
@ -966,20 +1049,7 @@ func zeroChannelBannedRights(rights domain.ChannelBannedRights) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func creatorChannelAdminRights() domain.ChannelAdminRights {
|
func creatorChannelAdminRights() domain.ChannelAdminRights {
|
||||||
return domain.ChannelAdminRights{
|
return domain.CreatorChannelAdminRights()
|
||||||
ChangeInfo: true,
|
|
||||||
PostMessages: true,
|
|
||||||
EditMessages: true,
|
|
||||||
DeleteMessages: true,
|
|
||||||
PostStories: true,
|
|
||||||
EditStories: true,
|
|
||||||
DeleteStories: true,
|
|
||||||
BanUsers: true,
|
|
||||||
InviteUsers: true,
|
|
||||||
PinMessages: true,
|
|
||||||
AddAdmins: true,
|
|
||||||
ManageCall: true,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneChannelMembers(in []domain.ChannelMember) []domain.ChannelMember {
|
func cloneChannelMembers(in []domain.ChannelMember) []domain.ChannelMember {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package memory
|
package memory
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
@ -18,11 +19,31 @@ type overrideKey struct {
|
||||||
callID, setter, target int64
|
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 {
|
type GroupCallStore struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
calls map[int64]domain.GroupCall
|
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
|
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
|
overrides map[overrideKey]domain.GroupCallParticipantOverride
|
||||||
raiseHandSeq map[int64]int64 // callID → 单调举手序号
|
raiseHandSeq map[int64]int64 // callID → 单调举手序号
|
||||||
nextSyntheticID int64
|
nextSyntheticID int64
|
||||||
|
|
@ -31,11 +52,16 @@ type GroupCallStore struct {
|
||||||
// NewGroupCallStore 创建内存实现。
|
// NewGroupCallStore 创建内存实现。
|
||||||
func NewGroupCallStore() *GroupCallStore {
|
func NewGroupCallStore() *GroupCallStore {
|
||||||
return &GroupCallStore{
|
return &GroupCallStore{
|
||||||
calls: make(map[int64]domain.GroupCall),
|
calls: make(map[int64]domain.GroupCall),
|
||||||
activeByChan: make(map[int64]int64),
|
activeByChan: make(map[int64]int64),
|
||||||
participants: make(map[int64]map[int64]domain.GroupCallParticipant),
|
bySlug: make(map[string]int64),
|
||||||
overrides: make(map[overrideKey]domain.GroupCallParticipantOverride),
|
byConferenceRnd: make(map[conferenceRandomKey]int64),
|
||||||
raiseHandSeq: make(map[int64]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 {
|
if _, exists := s.calls[call.ID]; exists {
|
||||||
return domain.GroupCall{}, domain.ErrGroupCallInvalid
|
return domain.GroupCall{}, domain.ErrGroupCallInvalid
|
||||||
}
|
}
|
||||||
|
call.Kind = domain.GroupCallKindChannel
|
||||||
call.State = domain.GroupCallStateActive
|
call.State = domain.GroupCallStateActive
|
||||||
if call.Version <= 0 {
|
if call.Version <= 0 {
|
||||||
call.Version = 1
|
call.Version = 1
|
||||||
|
|
@ -64,6 +91,40 @@ func (s *GroupCallStore) CreateGroupCall(_ context.Context, call domain.GroupCal
|
||||||
return call, nil
|
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) {
|
func (s *GroupCallStore) GetGroupCall(_ context.Context, callID int64) (domain.GroupCall, bool, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
@ -71,6 +132,28 @@ func (s *GroupCallStore) GetGroupCall(_ context.Context, callID int64) (domain.G
|
||||||
return call, ok, nil
|
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) {
|
func (s *GroupCallStore) JoinGroupCall(_ context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error) {
|
||||||
if req.SSRC == 0 {
|
if req.SSRC == 0 {
|
||||||
return domain.GroupCallMutation{}, domain.ErrGroupCallInvalid
|
return domain.GroupCallMutation{}, domain.ErrGroupCallInvalid
|
||||||
|
|
@ -102,6 +185,8 @@ func (s *GroupCallStore) JoinGroupCall(_ context.Context, req domain.JoinGroupCa
|
||||||
// VideoJSON 整体替换、PresentationJSON 随全新行清空(rejoin 后客户端
|
// VideoJSON 整体替换、PresentationJSON 随全新行清空(rejoin 后客户端
|
||||||
// 会重发 joinGroupCallPresentation,旧屏幕登记必须作废)。
|
// 会重发 joinGroupCallPresentation,旧屏幕登记必须作废)。
|
||||||
VideoJSON: append([]byte(nil), req.VideoJSON...),
|
VideoJSON: append([]byte(nil), req.VideoJSON...),
|
||||||
|
PublicKey: append([]byte(nil), req.PublicKey...),
|
||||||
|
JoinBlock: append([]byte(nil), req.JoinBlock...),
|
||||||
}
|
}
|
||||||
if rejoining && wasActive {
|
if rejoining && wasActive {
|
||||||
// 同设备换 ssrc 的 rejoin 保留原 join_date(列表排序稳定)。
|
// 同设备换 ssrc 的 rejoin 保留原 join_date(列表排序稳定)。
|
||||||
|
|
@ -113,6 +198,14 @@ func (s *GroupCallStore) JoinGroupCall(_ context.Context, req domain.JoinGroupCa
|
||||||
p.MutedByAdmin = true
|
p.MutedByAdmin = true
|
||||||
}
|
}
|
||||||
rows[req.UserID] = p
|
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 {
|
if !wasActive {
|
||||||
call.ParticipantsCount++
|
call.ParticipantsCount++
|
||||||
}
|
}
|
||||||
|
|
@ -139,10 +232,97 @@ func (s *GroupCallStore) LeaveGroupCall(_ context.Context, callID, userID int64,
|
||||||
call.ParticipantsCount--
|
call.ParticipantsCount--
|
||||||
}
|
}
|
||||||
call.Version++
|
call.Version++
|
||||||
|
discardEmptyConference(&call, now)
|
||||||
s.calls[callID] = call
|
s.calls[callID] = call
|
||||||
return domain.GroupCallMutation{Call: call, Participant: p}, nil
|
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) {
|
func (s *GroupCallStore) DiscardGroupCall(_ context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
@ -376,12 +556,22 @@ func (s *GroupCallStore) ResetAllParticipants(_ context.Context, now int) ([]dom
|
||||||
}
|
}
|
||||||
call.ParticipantsCount = 0
|
call.ParticipantsCount = 0
|
||||||
call.Version++
|
call.Version++
|
||||||
|
discardEmptyConference(&call, now)
|
||||||
s.calls[callID] = call
|
s.calls[callID] = call
|
||||||
out = append(out, call)
|
out = append(out, call)
|
||||||
}
|
}
|
||||||
return out, nil
|
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 {
|
func applyGroupCallParticipantUpdate(p *domain.GroupCallParticipant, u domain.GroupCallParticipantUpdate) bool {
|
||||||
changed := false
|
changed := false
|
||||||
if u.Muted != nil && p.Muted != *u.Muted {
|
if u.Muted != nil && p.Muted != *u.Muted {
|
||||||
|
|
@ -457,6 +647,162 @@ func (s *GroupCallStore) GetParticipantOverride(_ context.Context, callID, sette
|
||||||
return ov, ok, nil
|
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 {
|
func max(a, b int) int {
|
||||||
if a > b {
|
if a > b {
|
||||||
return a
|
return a
|
||||||
|
|
|
||||||
|
|
@ -150,18 +150,18 @@ func (s *ChannelStore) SearchPublicChannels(ctx context.Context, viewerUserID in
|
||||||
queryPrefix := escapeLike(queryLower) + "%"
|
queryPrefix := escapeLike(queryLower) + "%"
|
||||||
queryLike := "%" + escapeLike(queryLower) + "%"
|
queryLike := "%" + escapeLike(queryLower) + "%"
|
||||||
rows, err := s.db.Query(ctx, `
|
rows, err := s.db.Query(ctx, `
|
||||||
SELECT `+channelColumns+`,
|
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
|
|
||||||
FROM channels c
|
FROM channels c
|
||||||
WHERE NOT c.deleted
|
WHERE NOT c.deleted
|
||||||
AND (c.broadcast OR c.megagroup)
|
AND (c.broadcast OR c.megagroup)
|
||||||
AND COALESCE(c.username, '') <> ''
|
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 (
|
AND (
|
||||||
lower(c.username) = $2
|
lower(c.username) = $2
|
||||||
OR lower(c.username) LIKE $3 ESCAPE '\'
|
OR lower(c.username) LIKE $3 ESCAPE '\'
|
||||||
|
|
@ -176,7 +176,6 @@ ORDER BY CASE
|
||||||
WHEN lower(c.title) LIKE $3 ESCAPE '\' THEN 3
|
WHEN lower(c.title) LIKE $3 ESCAPE '\' THEN 3
|
||||||
ELSE 4
|
ELSE 4
|
||||||
END,
|
END,
|
||||||
viewer_member DESC,
|
|
||||||
c.participants_count DESC,
|
c.participants_count DESC,
|
||||||
c.date DESC,
|
c.date DESC,
|
||||||
c.id DESC
|
c.id DESC
|
||||||
|
|
@ -186,19 +185,14 @@ LIMIT $5`, viewerUserID, queryLower, queryPrefix, queryLike, limit)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
out := domain.PublicChannelSearchResult{
|
out := domain.PublicChannelSearchResult{
|
||||||
MyResults: make([]domain.Channel, 0),
|
Results: make([]domain.Channel, 0, limit),
|
||||||
Results: make([]domain.Channel, 0, limit),
|
|
||||||
}
|
}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
ch, viewerMember, err := scanChannelWithViewerMember(rows)
|
ch, err := scanChannel(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.PublicChannelSearchResult{}, err
|
return domain.PublicChannelSearchResult{}, err
|
||||||
}
|
}
|
||||||
if viewerMember {
|
out.Results = append(out.Results, ch)
|
||||||
out.MyResults = append(out.MyResults, ch)
|
|
||||||
} else {
|
|
||||||
out.Results = append(out.Results, ch)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return domain.PublicChannelSearchResult{}, err
|
return domain.PublicChannelSearchResult{}, err
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ func (s *ChannelStore) EditChannelAdmin(ctx context.Context, req domain.EditChan
|
||||||
member.AvailableMinPts = minPts
|
member.AvailableMinPts = minPts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
member.AdminRights = req.AdminRights
|
member.AdminRights = domain.NormalizeFullMegagroupAdminRights(channel, req.AdminRights)
|
||||||
if zeroChannelAdminRights(req.AdminRights) {
|
if zeroChannelAdminRights(req.AdminRights) {
|
||||||
member.Role = domain.ChannelRoleMember
|
member.Role = domain.ChannelRoleMember
|
||||||
member.Rank = ""
|
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
|
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) {
|
func (s *ChannelStore) EditChannelMemberRank(ctx context.Context, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error) {
|
||||||
if req.UserID == 0 || req.ChannelID == 0 || req.MemberID == 0 {
|
if req.UserID == 0 || req.ChannelID == 0 || req.MemberID == 0 {
|
||||||
return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
|
return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
|
||||||
|
|
|
||||||
|
|
@ -351,25 +351,12 @@ func zeroChannelBannedRights(rights domain.ChannelBannedRights) bool {
|
||||||
|
|
||||||
func creatorChannelMember(channelID, userID int64, date int) domain.ChannelMember {
|
func creatorChannelMember(channelID, userID int64, date int) domain.ChannelMember {
|
||||||
return domain.ChannelMember{
|
return domain.ChannelMember{
|
||||||
ChannelID: channelID,
|
ChannelID: channelID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Role: domain.ChannelRoleCreator,
|
Role: domain.ChannelRoleCreator,
|
||||||
Status: domain.ChannelMemberActive,
|
Status: domain.ChannelMemberActive,
|
||||||
JoinedAt: date,
|
JoinedAt: date,
|
||||||
AdminRights: domain.ChannelAdminRights{
|
AdminRights: domain.CreatorChannelAdminRights(),
|
||||||
ChangeInfo: true,
|
|
||||||
PostMessages: true,
|
|
||||||
EditMessages: true,
|
|
||||||
DeleteMessages: true,
|
|
||||||
PostStories: true,
|
|
||||||
EditStories: true,
|
|
||||||
DeleteStories: true,
|
|
||||||
BanUsers: true,
|
|
||||||
InviteUsers: true,
|
|
||||||
PinMessages: true,
|
|
||||||
AddAdmins: true,
|
|
||||||
ManageCall: true,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
|
@ -24,21 +25,24 @@ func NewGroupCallStore(db sqlcgen.DBTX) *GroupCallStore {
|
||||||
return &GroupCallStore{db: db}
|
return &GroupCallStore{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupCallColumns = `call_id, access_hash, channel_id, creator_user_id, state, title, join_muted,
|
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`
|
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,
|
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) {
|
func scanGroupCall(row rowScanner) (domain.GroupCall, error) {
|
||||||
var c domain.GroupCall
|
var c domain.GroupCall
|
||||||
var state string
|
var kind, state string
|
||||||
if err := row.Scan(
|
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.Version, &c.ParticipantsCount, &c.CreatedAt, &c.DiscardedAt, &c.Duration, &c.StartedMsgID,
|
||||||
|
&c.InviteSlug, &c.InviteLink, &c.RandomID, &c.MigratedFromPhoneCallID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return domain.GroupCall{}, err
|
return domain.GroupCall{}, err
|
||||||
}
|
}
|
||||||
|
c.Kind = domain.GroupCallKind(kind)
|
||||||
c.State = domain.GroupCallState(state)
|
c.State = domain.GroupCallState(state)
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
@ -47,13 +51,39 @@ func scanGroupCallParticipant(row rowScanner) (domain.GroupCallParticipant, erro
|
||||||
var p domain.GroupCallParticipant
|
var p domain.GroupCallParticipant
|
||||||
if err := row.Scan(
|
if err := row.Scan(
|
||||||
&p.CallID, &p.UserID, &p.SSRC, &p.JoinDate, &p.ActiveDate, &p.Muted, &p.MutedByAdmin,
|
&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 {
|
); err != nil {
|
||||||
return domain.GroupCallParticipant{}, err
|
return domain.GroupCallParticipant{}, err
|
||||||
}
|
}
|
||||||
return p, nil
|
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) {
|
func (s *GroupCallStore) begin(ctx context.Context, op string) (pgx.Tx, error) {
|
||||||
beginner, ok := s.db.(txBeginner)
|
beginner, ok := s.db.(txBeginner)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -92,6 +122,31 @@ RETURNING `+groupCallColumns, callID, countDelta))
|
||||||
return call, nil
|
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) {
|
func (s *GroupCallStore) CreateGroupCall(ctx context.Context, call domain.GroupCall) (domain.GroupCall, error) {
|
||||||
if call.ID == 0 || call.ChannelID == 0 || call.AccessHash == 0 {
|
if call.ID == 0 || call.ChannelID == 0 || call.AccessHash == 0 {
|
||||||
return domain.GroupCall{}, domain.ErrGroupCallInvalid
|
return domain.GroupCall{}, domain.ErrGroupCallInvalid
|
||||||
|
|
@ -100,8 +155,8 @@ func (s *GroupCallStore) CreateGroupCall(ctx context.Context, call domain.GroupC
|
||||||
call.Version = 1
|
call.Version = 1
|
||||||
}
|
}
|
||||||
_, err := s.db.Exec(ctx, `
|
_, 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)
|
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, 'active', $5, $6, $7, 0, $8)`,
|
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)
|
call.ID, call.AccessHash, call.ChannelID, call.CreatorUserID, call.Title, call.JoinMuted, call.Version, call.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var pgErr *pgconn.PgError
|
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)
|
return domain.GroupCall{}, fmt.Errorf("insert group call: %w", err)
|
||||||
}
|
}
|
||||||
call.State = domain.GroupCallStateActive
|
call.State = domain.GroupCallStateActive
|
||||||
|
call.Kind = domain.GroupCallKindChannel
|
||||||
call.ParticipantsCount = 0
|
call.ParticipantsCount = 0
|
||||||
return call, nil
|
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) {
|
func (s *GroupCallStore) GetGroupCall(ctx context.Context, callID int64) (domain.GroupCall, bool, error) {
|
||||||
call, err := scanGroupCall(s.db.QueryRow(ctx,
|
call, err := scanGroupCall(s.db.QueryRow(ctx,
|
||||||
`SELECT `+groupCallColumns+` FROM group_calls WHERE call_id = $1`, callID))
|
`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
|
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) {
|
func (s *GroupCallStore) JoinGroupCall(ctx context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error) {
|
||||||
if req.SSRC == 0 {
|
if req.SSRC == 0 {
|
||||||
return domain.GroupCallMutation{}, domain.ErrGroupCallInvalid
|
return domain.GroupCallMutation{}, domain.ErrGroupCallInvalid
|
||||||
|
|
@ -165,6 +305,8 @@ func (s *GroupCallStore) JoinGroupCall(ctx context.Context, req domain.JoinGroup
|
||||||
JoinDate: req.Now,
|
JoinDate: req.Now,
|
||||||
ActiveDate: req.Now,
|
ActiveDate: req.Now,
|
||||||
LastCheckDate: req.Now,
|
LastCheckDate: req.Now,
|
||||||
|
PublicKey: append([]byte(nil), req.PublicKey...),
|
||||||
|
JoinBlock: append([]byte(nil), req.JoinBlock...),
|
||||||
}
|
}
|
||||||
if wasActive {
|
if wasActive {
|
||||||
// 同人换 ssrc 的 rejoin 保留原 join_date(列表排序稳定)。
|
// 同人换 ssrc 的 rejoin 保留原 join_date(列表排序稳定)。
|
||||||
|
|
@ -178,8 +320,8 @@ func (s *GroupCallStore) JoinGroupCall(ctx context.Context, req domain.JoinGroup
|
||||||
// video_json 整体替换、presentation_json 清空(rejoin 后客户端会重发
|
// video_json 整体替换、presentation_json 清空(rejoin 后客户端会重发
|
||||||
// joinGroupCallPresentation,旧屏幕登记必须作废)。
|
// joinGroupCallPresentation,旧屏幕登记必须作废)。
|
||||||
if _, err := tx.Exec(ctx, `
|
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)
|
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, FALSE, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, 0, 0, $8, $9, $10, FALSE, $11)
|
||||||
ON CONFLICT (call_id, user_id) DO UPDATE SET
|
ON CONFLICT (call_id, user_id) DO UPDATE SET
|
||||||
ssrc = EXCLUDED.ssrc,
|
ssrc = EXCLUDED.ssrc,
|
||||||
join_date = EXCLUDED.join_date,
|
join_date = EXCLUDED.join_date,
|
||||||
|
|
@ -190,15 +332,25 @@ ON CONFLICT (call_id, user_id) DO UPDATE SET
|
||||||
raise_hand_rating = 0,
|
raise_hand_rating = 0,
|
||||||
video_json = EXCLUDED.video_json,
|
video_json = EXCLUDED.video_json,
|
||||||
presentation_json = NULL,
|
presentation_json = NULL,
|
||||||
|
public_key = EXCLUDED.public_key,
|
||||||
|
join_block = EXCLUDED.join_block,
|
||||||
left_call = FALSE,
|
left_call = FALSE,
|
||||||
last_check_date = EXCLUDED.last_check_date`,
|
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
|
var pgErr *pgconn.PgError
|
||||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
return domain.GroupCallMutation{}, domain.ErrGroupCallSSRCDuplicate
|
return domain.GroupCallMutation{}, domain.ErrGroupCallSSRCDuplicate
|
||||||
}
|
}
|
||||||
return domain.GroupCallMutation{}, fmt.Errorf("upsert group call participant: %w", err)
|
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
|
countDelta := 0
|
||||||
if !wasActive {
|
if !wasActive {
|
||||||
countDelta = 1
|
countDelta = 1
|
||||||
|
|
@ -239,7 +391,7 @@ RETURNING `+groupCallParticipantColumns, callID, userID, now))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.GroupCallMutation{}, fmt.Errorf("leave group call participant: %w", err)
|
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 {
|
if err != nil {
|
||||||
return domain.GroupCallMutation{}, err
|
return domain.GroupCallMutation{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -250,6 +402,143 @@ RETURNING `+groupCallParticipantColumns, callID, userID, now))
|
||||||
return domain.GroupCallMutation{Call: call, Participant: p}, nil
|
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) {
|
func (s *GroupCallStore) DiscardGroupCall(ctx context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error) {
|
||||||
tx, err := s.begin(ctx, "discard group call")
|
tx, err := s.begin(ctx, "discard group call")
|
||||||
if err != nil {
|
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)
|
return out, fmt.Errorf("reset group call participants: %w", err)
|
||||||
}
|
}
|
||||||
call, err := scanGroupCall(tx.QueryRow(ctx, `
|
call, err := scanGroupCall(tx.QueryRow(ctx, `
|
||||||
UPDATE group_calls SET participants_count = 0, version = version + 1
|
UPDATE group_calls
|
||||||
WHERE call_id = $1 RETURNING `+groupCallColumns, callID))
|
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 {
|
if err != nil {
|
||||||
_ = tx.Rollback(ctx)
|
_ = tx.Rollback(ctx)
|
||||||
return out, fmt.Errorf("reset group call version: %w", err)
|
return out, fmt.Errorf("reset group call version: %w", err)
|
||||||
|
|
@ -613,6 +917,13 @@ func nullableJSON(b []byte) any {
|
||||||
return b
|
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) {
|
func parseGroupCallOffset(offset string) (joinDate int, userID int64, ok bool) {
|
||||||
if offset == "" {
|
if offset == "" {
|
||||||
return 0, 0, false
|
return 0, 0, false
|
||||||
|
|
@ -673,3 +984,216 @@ func (s *GroupCallStore) GetParticipantOverride(ctx context.Context, callID, set
|
||||||
}
|
}
|
||||||
return ov, true, nil
|
return ov, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *GroupCallStore) CreateConferenceInvite(ctx context.Context, invite domain.GroupCallInvite) (domain.GroupCallInvite, error) {
|
||||||
|
if invite.CallID == 0 || invite.InviterUserID == 0 || invite.InviteeUserID == 0 || invite.MessageID == 0 {
|
||||||
|
return domain.GroupCallInvite{}, domain.ErrGroupCallInvalid
|
||||||
|
}
|
||||||
|
if invite.Status == "" {
|
||||||
|
invite.Status = domain.GroupCallInvitePending
|
||||||
|
}
|
||||||
|
var status string
|
||||||
|
err := s.db.QueryRow(ctx, `
|
||||||
|
INSERT INTO group_call_invites (call_id, inviter_user_id, invitee_user_id, message_id, status, video, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
ON CONFLICT (call_id, invitee_user_id, message_id) DO UPDATE SET
|
||||||
|
inviter_user_id = EXCLUDED.inviter_user_id,
|
||||||
|
video = EXCLUDED.video
|
||||||
|
RETURNING call_id, inviter_user_id, invitee_user_id, message_id, status, video, created_at, updated_at`,
|
||||||
|
invite.CallID, invite.InviterUserID, invite.InviteeUserID, invite.MessageID, string(invite.Status), invite.Video, invite.CreatedAt, invite.UpdatedAt,
|
||||||
|
).Scan(&invite.CallID, &invite.InviterUserID, &invite.InviteeUserID, &invite.MessageID, &status, &invite.Video, &invite.CreatedAt, &invite.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return domain.GroupCallInvite{}, fmt.Errorf("create conference invite: %w", err)
|
||||||
|
}
|
||||||
|
invite.Status = domain.GroupCallInviteStatus(status)
|
||||||
|
return invite, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GroupCallStore) SetConferenceInviteStatus(ctx context.Context, callID int64, inviteeUserID int64, msgID int, status domain.GroupCallInviteStatus, now int) (domain.GroupCallInvite, bool, error) {
|
||||||
|
var inv domain.GroupCallInvite
|
||||||
|
var newStatus string
|
||||||
|
err := s.db.QueryRow(ctx, `
|
||||||
|
UPDATE group_call_invites
|
||||||
|
SET status = $4, updated_at = $5
|
||||||
|
WHERE call_id = $1 AND invitee_user_id = $2 AND message_id = $3
|
||||||
|
RETURNING call_id, inviter_user_id, invitee_user_id, message_id, status, video, created_at, updated_at`,
|
||||||
|
callID, inviteeUserID, msgID, string(status), now,
|
||||||
|
).Scan(&inv.CallID, &inv.InviterUserID, &inv.InviteeUserID, &inv.MessageID, &newStatus, &inv.Video, &inv.CreatedAt, &inv.UpdatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.GroupCallInvite{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return domain.GroupCallInvite{}, false, fmt.Errorf("set conference invite status: %w", err)
|
||||||
|
}
|
||||||
|
inv.Status = domain.GroupCallInviteStatus(newStatus)
|
||||||
|
return inv, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GroupCallStore) ListConferenceRecipientUserIDs(ctx context.Context, callID int64) ([]int64, error) {
|
||||||
|
rows, err := s.db.Query(ctx, `
|
||||||
|
WITH c AS (
|
||||||
|
SELECT creator_user_id, state FROM group_calls WHERE call_id = $1
|
||||||
|
)
|
||||||
|
SELECT creator_user_id FROM c
|
||||||
|
UNION
|
||||||
|
SELECT p.user_id
|
||||||
|
FROM group_call_participants p CROSS JOIN c
|
||||||
|
WHERE p.call_id = $1 AND (c.state <> 'active' OR NOT p.left_call)
|
||||||
|
UNION
|
||||||
|
SELECT i.inviter_user_id
|
||||||
|
FROM group_call_invites i CROSS JOIN c
|
||||||
|
WHERE i.call_id = $1 AND (c.state <> 'active' OR i.status IN ('pending', 'accepted'))
|
||||||
|
UNION
|
||||||
|
SELECT i.invitee_user_id
|
||||||
|
FROM group_call_invites i CROSS JOIN c
|
||||||
|
WHERE i.call_id = $1 AND (c.state <> 'active' OR i.status IN ('pending', 'accepted'))
|
||||||
|
ORDER BY 1`, callID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list conference recipients: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if id != 0 {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GroupCallStore) AppendGroupCallChainBlock(ctx context.Context, block domain.GroupCallChainBlock) (domain.GroupCallChainBlock, error) {
|
||||||
|
if block.CallID == 0 || len(block.Block) == 0 {
|
||||||
|
return domain.GroupCallChainBlock{}, domain.ErrGroupCallInvalid
|
||||||
|
}
|
||||||
|
tx, err := s.begin(ctx, "append group call chain block")
|
||||||
|
if err != nil {
|
||||||
|
return domain.GroupCallChainBlock{}, err
|
||||||
|
}
|
||||||
|
committed := false
|
||||||
|
defer func() {
|
||||||
|
if !committed {
|
||||||
|
_ = tx.Rollback(ctx)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
call, err := lockGroupCallTx(ctx, tx, block.CallID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.GroupCallChainBlock{}, err
|
||||||
|
}
|
||||||
|
if !call.Conference() {
|
||||||
|
return domain.GroupCallChainBlock{}, domain.ErrGroupCallInvalid
|
||||||
|
}
|
||||||
|
block, err = appendGroupCallChainBlockTx(ctx, tx, block)
|
||||||
|
if err != nil {
|
||||||
|
return domain.GroupCallChainBlock{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return domain.GroupCallChainBlock{}, fmt.Errorf("commit append group call chain block: %w", err)
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
return block, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendGroupCallChainBlockTx(ctx context.Context, tx pgx.Tx, block domain.GroupCallChainBlock) (domain.GroupCallChainBlock, error) {
|
||||||
|
if block.CallID == 0 || len(block.Block) == 0 {
|
||||||
|
return domain.GroupCallChainBlock{}, domain.ErrGroupCallInvalid
|
||||||
|
}
|
||||||
|
var existing domain.GroupCallChainBlock
|
||||||
|
err := tx.QueryRow(ctx, `
|
||||||
|
SELECT call_id, sub_chain_id, block_offset, author_user_id, block, created_at
|
||||||
|
FROM group_call_chain_blocks
|
||||||
|
WHERE call_id = $1 AND sub_chain_id = $2 AND block = $3
|
||||||
|
ORDER BY block_offset ASC
|
||||||
|
LIMIT 1`, block.CallID, block.SubChainID, block.Block).Scan(
|
||||||
|
&existing.CallID, &existing.SubChainID, &existing.Offset, &existing.AuthorUserID, &existing.Block, &existing.CreatedAt,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
return domain.GroupCallChainBlock{}, domain.ErrConferenceChainInvalid
|
||||||
|
}
|
||||||
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.GroupCallChainBlock{}, fmt.Errorf("get existing group call chain block: %w", err)
|
||||||
|
}
|
||||||
|
var nextOffset int
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT COALESCE(MAX(block_offset) + 1, 0)
|
||||||
|
FROM group_call_chain_blocks
|
||||||
|
WHERE call_id = $1 AND sub_chain_id = $2`, block.CallID, block.SubChainID).Scan(&nextOffset); err != nil {
|
||||||
|
return domain.GroupCallChainBlock{}, fmt.Errorf("next chain block offset: %w", err)
|
||||||
|
}
|
||||||
|
if block.Offset < 0 {
|
||||||
|
block.Offset = nextOffset
|
||||||
|
}
|
||||||
|
if block.Offset != nextOffset {
|
||||||
|
return domain.GroupCallChainBlock{}, domain.ErrConferenceChainInvalid
|
||||||
|
}
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO group_call_chain_blocks (call_id, sub_chain_id, block_offset, author_user_id, block, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING call_id, sub_chain_id, block_offset, author_user_id, block, created_at`,
|
||||||
|
block.CallID, block.SubChainID, block.Offset, block.AuthorUserID, block.Block, block.CreatedAt,
|
||||||
|
).Scan(&block.CallID, &block.SubChainID, &block.Offset, &block.AuthorUserID, &block.Block, &block.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
return domain.GroupCallChainBlock{}, domain.ErrConferenceChainInvalid
|
||||||
|
}
|
||||||
|
return domain.GroupCallChainBlock{}, fmt.Errorf("append group call chain block: %w", err)
|
||||||
|
}
|
||||||
|
return block, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GroupCallStore) ListGroupCallChainBlocks(ctx context.Context, callID int64, subChainID, offset, limit int) (domain.GroupCallChainBlockPage, error) {
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
if offset == domain.GroupCallChainBlockLatestOffset {
|
||||||
|
var block domain.GroupCallChainBlock
|
||||||
|
err := s.db.QueryRow(ctx, `
|
||||||
|
SELECT call_id, sub_chain_id, block_offset, author_user_id, block, created_at
|
||||||
|
FROM group_call_chain_blocks
|
||||||
|
WHERE call_id = $1 AND sub_chain_id = $2
|
||||||
|
ORDER BY block_offset DESC
|
||||||
|
LIMIT 1`, callID, subChainID).Scan(&block.CallID, &block.SubChainID, &block.Offset, &block.AuthorUserID, &block.Block, &block.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.GroupCallChainBlockPage{NextOffset: 0}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return domain.GroupCallChainBlockPage{}, fmt.Errorf("get latest group call chain block: %w", err)
|
||||||
|
}
|
||||||
|
return domain.GroupCallChainBlockPage{
|
||||||
|
Blocks: []domain.GroupCallChainBlock{block},
|
||||||
|
NextOffset: block.Offset + 1,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
return domain.GroupCallChainBlockPage{}, domain.ErrGroupCallInvalid
|
||||||
|
}
|
||||||
|
rows, err := s.db.Query(ctx, `
|
||||||
|
SELECT call_id, sub_chain_id, block_offset, author_user_id, block, created_at
|
||||||
|
FROM group_call_chain_blocks
|
||||||
|
WHERE call_id = $1 AND sub_chain_id = $2 AND block_offset >= $3
|
||||||
|
ORDER BY block_offset ASC
|
||||||
|
LIMIT $4`, callID, subChainID, offset, limit)
|
||||||
|
if err != nil {
|
||||||
|
return domain.GroupCallChainBlockPage{}, fmt.Errorf("list group call chain blocks: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
page := domain.GroupCallChainBlockPage{NextOffset: offset}
|
||||||
|
for rows.Next() {
|
||||||
|
var block domain.GroupCallChainBlock
|
||||||
|
if err := rows.Scan(&block.CallID, &block.SubChainID, &block.Offset, &block.AuthorUserID, &block.Block, &block.CreatedAt); err != nil {
|
||||||
|
return domain.GroupCallChainBlockPage{}, err
|
||||||
|
}
|
||||||
|
page.Blocks = append(page.Blocks, block)
|
||||||
|
page.NextOffset = block.Offset + 1
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return domain.GroupCallChainBlockPage{}, err
|
||||||
|
}
|
||||||
|
return page, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ package storetest
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -36,6 +38,9 @@ func RunGroupCallStoreContract(t *testing.T, factory GroupCallStoreFactory) {
|
||||||
t.Run("UpdateParticipant", func(t *testing.T) { contractUpdateParticipant(t, factory) })
|
t.Run("UpdateParticipant", func(t *testing.T) { contractUpdateParticipant(t, factory) })
|
||||||
t.Run("ResetAllParticipants", func(t *testing.T) { contractReset(t, factory) })
|
t.Run("ResetAllParticipants", func(t *testing.T) { contractReset(t, factory) })
|
||||||
t.Run("JoinVideoStateLifecycle", func(t *testing.T) { contractJoinVideoState(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 {
|
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
|
// contractJoinVideoState:join 携带 VideoJSON 整体替换、rejoin 清空 presentation
|
||||||
//(主连接 rejoin 后客户端会重发 joinGroupCallPresentation,旧屏幕登记必须作废)。
|
// (主连接 rejoin 后客户端会重发 joinGroupCallPresentation,旧屏幕登记必须作废)。
|
||||||
func contractJoinVideoState(t *testing.T, factory GroupCallStoreFactory) {
|
func contractJoinVideoState(t *testing.T, factory GroupCallStoreFactory) {
|
||||||
st, channelID := factory(t)
|
st, channelID := factory(t)
|
||||||
ctx := context.Background()
|
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
|
// 以下为 M2 契约:per-viewer overrides 与举手序号(追加进 RunGroupCallStoreContract
|
||||||
// 之外单独可调,避免改既有签名——两实现测试各自调用 RunGroupCallStoreM2Contract)。
|
// 之外单独可调,避免改既有签名——两实现测试各自调用 RunGroupCallStoreM2Contract)。
|
||||||
|
|
||||||
|
|
|
||||||
146
scripts/analyze-media-download-log.ps1
Normal file
146
scripts/analyze-media-download-log.ps1
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Summarizes upload.getFile requests in telesrv logs.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Use this after opening media history in TDesktop or Android. It groups
|
||||||
|
upload.getFile RPCs by client_type/app_version and reports request count and
|
||||||
|
duration percentiles. Pass -SinceLine from a previous baseline if desired.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$ServerLogPath,
|
||||||
|
[int]$SinceLine = 0,
|
||||||
|
[int]$Tail = 0,
|
||||||
|
[switch]$ShowSamples
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||||
|
if (-not $ServerLogPath) {
|
||||||
|
$latestLog = Get-ChildItem (Join-Path $RepoRoot "logs") -Filter "telesrv-*.err.log" -ErrorAction SilentlyContinue |
|
||||||
|
Sort-Object LastWriteTime -Descending |
|
||||||
|
Select-Object -First 1
|
||||||
|
if ($latestLog) {
|
||||||
|
$ServerLogPath = $latestLog.FullName
|
||||||
|
} else {
|
||||||
|
$ServerLogPath = Join-Path $RepoRoot "logs\telesrv.err.log"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-SharedLogLines {
|
||||||
|
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
$stream = [System.IO.File]::Open($ServerLogPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
|
||||||
|
try {
|
||||||
|
$reader = New-Object System.IO.StreamReader($stream)
|
||||||
|
try {
|
||||||
|
$lines = New-Object System.Collections.Generic.List[string]
|
||||||
|
while (-not $reader.EndOfStream) {
|
||||||
|
$lines.Add($reader.ReadLine()) | Out-Null
|
||||||
|
}
|
||||||
|
return $lines
|
||||||
|
} finally {
|
||||||
|
$reader.Dispose()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$stream.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-Field {
|
||||||
|
param([string]$Line, [string]$Name, [string]$Default = "")
|
||||||
|
if ($Line -match ('"' + [regex]::Escape($Name) + '"\s*:\s*"([^"]*)"')) {
|
||||||
|
return $Matches[1]
|
||||||
|
}
|
||||||
|
if ($Line -match ('"' + [regex]::Escape($Name) + '"\s*:\s*([^,}]+)')) {
|
||||||
|
return $Matches[1]
|
||||||
|
}
|
||||||
|
return $Default
|
||||||
|
}
|
||||||
|
|
||||||
|
function Convert-DurationMs([string]$Text) {
|
||||||
|
if (-not $Text) { return 0.0 }
|
||||||
|
if ($Text -match '^([0-9.]+)ms$') { return [double]$Matches[1] }
|
||||||
|
if ($Text -match '^([0-9.]+)s$') { return [double]$Matches[1] * 1000.0 }
|
||||||
|
if ($Text -match '^([0-9.]+)µs$') { return [double]$Matches[1] / 1000.0 }
|
||||||
|
if ($Text -match '^([0-9.]+)us$') { return [double]$Matches[1] / 1000.0 }
|
||||||
|
if ($Text -match '^([0-9.]+)ns$') { return [double]$Matches[1] / 1000000.0 }
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
function Percentile {
|
||||||
|
param([double[]]$Values, [double]$P)
|
||||||
|
if ($Values.Count -eq 0) { return 0.0 }
|
||||||
|
$sorted = @($Values | Sort-Object)
|
||||||
|
$idx = [int][Math]::Ceiling($P * $sorted.Count) - 1
|
||||||
|
if ($idx -lt 0) { $idx = 0 }
|
||||||
|
if ($idx -ge $sorted.Count) { $idx = $sorted.Count - 1 }
|
||||||
|
return [double]$sorted[$idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
$allLines = @(Read-SharedLogLines)
|
||||||
|
$lineCount = $allLines.Count
|
||||||
|
$lines = $allLines
|
||||||
|
if ($SinceLine -gt 0) {
|
||||||
|
$lines = @($lines | Select-Object -Skip $SinceLine)
|
||||||
|
}
|
||||||
|
if ($Tail -gt 0) {
|
||||||
|
$lines = @($lines | Select-Object -Last $Tail)
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = New-Object System.Collections.Generic.List[object]
|
||||||
|
foreach ($line in $lines) {
|
||||||
|
if ($line -notlike "*upload.getFile*") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$method = Get-Field $line "method"
|
||||||
|
if ($method -notlike "upload.getFile*") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$client = Get-Field $line "client_type" "unknown"
|
||||||
|
$app = Get-Field $line "app_version" ""
|
||||||
|
$dur = Convert-DurationMs (Get-Field $line "dur" "0ms")
|
||||||
|
$items.Add([pscustomobject]@{
|
||||||
|
Client = $client
|
||||||
|
AppVersion = $app
|
||||||
|
DurationMs = $dur
|
||||||
|
Line = $line
|
||||||
|
}) | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "log=$ServerLogPath"
|
||||||
|
Write-Host "total_lines=$lineCount analyzed_lines=$($lines.Count) since_line=$SinceLine upload_get_file=$($items.Count)"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
if ($items.Count -eq 0) {
|
||||||
|
Write-Host "No upload.getFile entries found."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$groups = $items | Group-Object Client, AppVersion
|
||||||
|
foreach ($group in $groups) {
|
||||||
|
$values = @($group.Group | ForEach-Object { [double]$_.DurationMs })
|
||||||
|
$sum = 0.0
|
||||||
|
foreach ($v in $values) { $sum += $v }
|
||||||
|
$avg = $sum / [Math]::Max(1, $values.Count)
|
||||||
|
[pscustomobject]@{
|
||||||
|
Client = ($group.Group[0].Client)
|
||||||
|
AppVersion = ($group.Group[0].AppVersion)
|
||||||
|
Count = $values.Count
|
||||||
|
AvgMs = [Math]::Round($avg, 3)
|
||||||
|
P50Ms = [Math]::Round((Percentile $values 0.50), 3)
|
||||||
|
P95Ms = [Math]::Round((Percentile $values 0.95), 3)
|
||||||
|
P99Ms = [Math]::Round((Percentile $values 0.99), 3)
|
||||||
|
MaxMs = [Math]::Round((($values | Measure-Object -Maximum).Maximum), 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ShowSamples) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Samples:"
|
||||||
|
$items | Select-Object -Last 20 | ForEach-Object { Write-Host $_.Line }
|
||||||
|
}
|
||||||
257
scripts/check-local-runtime.ps1
Normal file
257
scripts/check-local-runtime.ps1
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Checks the local telesrv runtime state.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Reports the listening PID/process, git commit, schema version, MTProto port,
|
||||||
|
Android connection/package status, and recent server log errors. The script is
|
||||||
|
read-only and is intended to run before/after Android and TDesktop validation.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[int]$Port = 2398,
|
||||||
|
[string]$ServerLogPath,
|
||||||
|
[string]$AndroidPackage = "org.telegram.messenger.beta",
|
||||||
|
[string]$DeviceSerial,
|
||||||
|
[string]$PostgresContainer = "telesrv-postgres",
|
||||||
|
[string]$Database = "telesrv",
|
||||||
|
[string]$DbUser = "telesrv",
|
||||||
|
[int]$RecentLogLines = 1200,
|
||||||
|
[switch]$SkipAdb
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||||
|
if (-not $ServerLogPath) {
|
||||||
|
$latestLog = Get-ChildItem (Join-Path $RepoRoot "logs") -Filter "telesrv-*.err.log" -ErrorAction SilentlyContinue |
|
||||||
|
Sort-Object LastWriteTime -Descending |
|
||||||
|
Select-Object -First 1
|
||||||
|
if ($latestLog) {
|
||||||
|
$ServerLogPath = $latestLog.FullName
|
||||||
|
} else {
|
||||||
|
$ServerLogPath = Join-Path $RepoRoot "logs\telesrv.err.log"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$Failures = New-Object System.Collections.Generic.List[string]
|
||||||
|
|
||||||
|
function Write-Step([string]$Message) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "== $Message =="
|
||||||
|
}
|
||||||
|
|
||||||
|
function Add-Failure([string]$Message) {
|
||||||
|
$script:Failures.Add($Message) | Out-Null
|
||||||
|
Write-Host "[fail] $Message"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Ok([string]$Message) {
|
||||||
|
Write-Host "[ok] $Message"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-External {
|
||||||
|
param(
|
||||||
|
[string]$FilePath,
|
||||||
|
[string[]]$Arguments,
|
||||||
|
[switch]$AllowFailure
|
||||||
|
)
|
||||||
|
$oldErrorActionPreference = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
try {
|
||||||
|
$output = & $FilePath @Arguments 2>&1
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
} finally {
|
||||||
|
$ErrorActionPreference = $oldErrorActionPreference
|
||||||
|
}
|
||||||
|
$text = ($output | ForEach-Object { $_.ToString() }) -join "`n"
|
||||||
|
if ($exitCode -ne 0 -and -not $AllowFailure) {
|
||||||
|
throw "$FilePath $($Arguments -join ' ') failed with exit code ${exitCode}:`n$text"
|
||||||
|
}
|
||||||
|
[pscustomobject]@{ ExitCode = $exitCode; Output = $text }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-PsqlScalar([string]$Sql) {
|
||||||
|
$result = Invoke-External "docker" @(
|
||||||
|
"exec", $PostgresContainer,
|
||||||
|
"psql", "-U", $DbUser, "-d", $Database,
|
||||||
|
"-v", "ON_ERROR_STOP=1",
|
||||||
|
"-At", "-c", $Sql
|
||||||
|
) -AllowFailure
|
||||||
|
if ($result.ExitCode -ne 0) {
|
||||||
|
Add-Failure "PostgreSQL query failed: $($result.Output)"
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return $result.Output.Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-SharedLogLines {
|
||||||
|
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
$stream = [System.IO.File]::Open($ServerLogPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
|
||||||
|
try {
|
||||||
|
$reader = New-Object System.IO.StreamReader($stream)
|
||||||
|
try {
|
||||||
|
$lines = New-Object System.Collections.Generic.List[string]
|
||||||
|
while (-not $reader.EndOfStream) {
|
||||||
|
$lines.Add($reader.ReadLine()) | Out-Null
|
||||||
|
}
|
||||||
|
return $lines
|
||||||
|
} finally {
|
||||||
|
$reader.Dispose()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$stream.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-AdbArgs([string[]]$Arguments) {
|
||||||
|
if ($DeviceSerial) {
|
||||||
|
return @("-s", $DeviceSerial) + $Arguments
|
||||||
|
}
|
||||||
|
return $Arguments
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Adb([string[]]$Arguments, [switch]$AllowFailure) {
|
||||||
|
Invoke-External "adb" (Get-AdbArgs $Arguments) -AllowFailure:$AllowFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-JsonFieldFromLog {
|
||||||
|
param([string[]]$Lines, [string]$Field)
|
||||||
|
for ($i = $Lines.Count - 1; $i -ge 0; $i--) {
|
||||||
|
if ($Lines[$i] -match ('"' + [regex]::Escape($Field) + '"\s*:\s*"?([^",}]+)"?')) {
|
||||||
|
return $Matches[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Git"
|
||||||
|
Push-Location $RepoRoot
|
||||||
|
try {
|
||||||
|
$head = (Invoke-External "git" @("rev-parse", "HEAD") -AllowFailure).Output.Trim()
|
||||||
|
$branch = (Invoke-External "git" @("branch", "--show-current") -AllowFailure).Output.Trim()
|
||||||
|
$dirty = (Invoke-External "git" @("status", "--porcelain", "--untracked-files=no") -AllowFailure).Output.Trim()
|
||||||
|
Write-Host "branch=$branch"
|
||||||
|
Write-Host "head=$head"
|
||||||
|
if ($dirty) {
|
||||||
|
Write-Host "tree_state=dirty"
|
||||||
|
} else {
|
||||||
|
Write-Host "tree_state=clean"
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Process and Port"
|
||||||
|
$listeners = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue)
|
||||||
|
if ($listeners.Count -eq 0) {
|
||||||
|
Add-Failure "no process is listening on port $Port"
|
||||||
|
} else {
|
||||||
|
foreach ($ownerPid in @($listeners | Select-Object -ExpandProperty OwningProcess -Unique)) {
|
||||||
|
$proc = Get-Process -Id $ownerPid -ErrorAction SilentlyContinue
|
||||||
|
if ($proc) {
|
||||||
|
$path = $null
|
||||||
|
try { $path = $proc.Path } catch { $path = "" }
|
||||||
|
Write-Host ("pid={0} name={1} start={2} path={3}" -f $proc.Id, $proc.ProcessName, $proc.StartTime, $path)
|
||||||
|
} else {
|
||||||
|
Write-Host "pid=$ownerPid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Ok "port $Port is listening"
|
||||||
|
}
|
||||||
|
$established = @(Get-NetTCPConnection -LocalPort $Port -State Established -ErrorAction SilentlyContinue)
|
||||||
|
Write-Host "established_connections=$($established.Count)"
|
||||||
|
foreach ($conn in $established | Select-Object -First 12) {
|
||||||
|
Write-Host (" {0}:{1} -> {2}:{3} pid={4}" -f $conn.LocalAddress, $conn.LocalPort, $conn.RemoteAddress, $conn.RemotePort, $conn.OwningProcess)
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "PostgreSQL Schema"
|
||||||
|
$schema = Invoke-PsqlScalar "SELECT version::text || '|' || dirty::text FROM schema_migrations ORDER BY version DESC LIMIT 1;"
|
||||||
|
if ($schema) {
|
||||||
|
$parts = $schema -split "\|", 2
|
||||||
|
Write-Host "schema_version=$($parts[0])"
|
||||||
|
Write-Host "schema_dirty=$($parts[1])"
|
||||||
|
if ($parts.Count -gt 1 -and $parts[1] -eq "f") {
|
||||||
|
Write-Ok "schema is clean"
|
||||||
|
} else {
|
||||||
|
Add-Failure "schema_migrations is dirty"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Server Log"
|
||||||
|
Write-Host "log=$ServerLogPath"
|
||||||
|
$lines = @(Read-SharedLogLines)
|
||||||
|
if ($lines.Count -eq 0) {
|
||||||
|
Add-Failure "server log is missing or empty"
|
||||||
|
} else {
|
||||||
|
$readyLines = @($lines | Where-Object { $_ -like "*telesrv 服务就绪*" })
|
||||||
|
if ($readyLines.Count -gt 0) {
|
||||||
|
$ready = @($readyLines)[-1]
|
||||||
|
Write-Host $ready
|
||||||
|
$runtimeCommit = Get-JsonFieldFromLog @($ready) "git_commit"
|
||||||
|
$runtimeSchema = Get-JsonFieldFromLog @($ready) "schema_version"
|
||||||
|
$runtimePID = Get-JsonFieldFromLog @($ready) "pid"
|
||||||
|
Write-Host "runtime_git_commit=$runtimeCommit"
|
||||||
|
Write-Host "runtime_schema_version=$runtimeSchema"
|
||||||
|
Write-Host "runtime_pid=$runtimePID"
|
||||||
|
if ($head -and $runtimeCommit -and $runtimeCommit -ne $head) {
|
||||||
|
Add-Failure "runtime git_commit $runtimeCommit != HEAD $head"
|
||||||
|
} elseif ($runtimeCommit) {
|
||||||
|
Write-Ok "runtime commit matches HEAD"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Add-Failure "server log has no 'telesrv 服务就绪' line"
|
||||||
|
}
|
||||||
|
$recent = @($lines | Select-Object -Last $RecentLogLines)
|
||||||
|
$bad = @($recent | Where-Object {
|
||||||
|
$_ -cmatch "INTERNAL_SERVER_ERROR|Unhandled RPC|NOT_IMPLEMENTED|bad_msg|panic|\tERROR\t"
|
||||||
|
})
|
||||||
|
if ($bad.Count -eq 0) {
|
||||||
|
Write-Ok "recent log has no internal/unhandled/bad_msg errors"
|
||||||
|
} else {
|
||||||
|
Add-Failure "recent log has $($bad.Count) suspicious error lines"
|
||||||
|
$bad | Select-Object -Last 40 | ForEach-Object { Write-Host $_ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Android"
|
||||||
|
if ($SkipAdb) {
|
||||||
|
Write-Host "adb checks skipped"
|
||||||
|
} else {
|
||||||
|
$adb = Get-Command adb -ErrorAction SilentlyContinue
|
||||||
|
if (-not $adb) {
|
||||||
|
Add-Failure "adb is not available"
|
||||||
|
} else {
|
||||||
|
$devices = Invoke-Adb @("devices") -AllowFailure
|
||||||
|
$deviceLines = @($devices.Output -split "`r?`n" | Where-Object { $_ -match "\tdevice$" })
|
||||||
|
Write-Host "adb_devices=$($deviceLines.Count)"
|
||||||
|
if ($deviceLines.Count -lt 1) {
|
||||||
|
Add-Failure "no adb device connected"
|
||||||
|
} elseif ($deviceLines.Count -gt 1 -and -not $DeviceSerial) {
|
||||||
|
Add-Failure "multiple adb devices; pass -DeviceSerial"
|
||||||
|
} else {
|
||||||
|
$model = (Invoke-Adb @("shell", "getprop", "ro.product.model") -AllowFailure).Output.Trim()
|
||||||
|
$sdk = (Invoke-Adb @("shell", "getprop", "ro.build.version.sdk") -AllowFailure).Output.Trim()
|
||||||
|
$pkg = Invoke-Adb @("shell", "dumpsys", "package", $AndroidPackage) -AllowFailure
|
||||||
|
Write-Host "device_model=$model sdk=$sdk"
|
||||||
|
if ($pkg.Output -match "versionName=([^\r\n]+)") {
|
||||||
|
Write-Ok "Android package $AndroidPackage installed version=$($Matches[1])"
|
||||||
|
} else {
|
||||||
|
Add-Failure "Android package $AndroidPackage not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
if ($Failures.Count -gt 0) {
|
||||||
|
Write-Host "Runtime check failed:"
|
||||||
|
foreach ($failure in $Failures) {
|
||||||
|
Write-Host " - $failure"
|
||||||
|
}
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "Runtime check passed."
|
||||||
347
scripts/restart-local-server.ps1
Normal file
347
scripts/restart-local-server.ps1
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Builds and restarts the local telesrv process with explicit runtime logs.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This helper is meant for Windows development loops. It builds the current
|
||||||
|
workspace into a staging executable, stops the repo-local process currently
|
||||||
|
listening on the configured MTProto port, promotes the new executable, starts it
|
||||||
|
hidden, and verifies that the port is listening again.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$Listen = "0.0.0.0:2398",
|
||||||
|
[string]$AdvertiseIP,
|
||||||
|
[string]$ExePath,
|
||||||
|
[string]$LogDir,
|
||||||
|
[int]$HealthTimeoutSeconds = 20,
|
||||||
|
[int]$Tail = 80,
|
||||||
|
[switch]$SkipBuild,
|
||||||
|
[switch]$NoStart
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||||
|
if (-not $ExePath) {
|
||||||
|
$ExePath = Join-Path $RepoRoot "bin\telesrv.exe"
|
||||||
|
}
|
||||||
|
if (-not $LogDir) {
|
||||||
|
$LogDir = Join-Path $RepoRoot "logs"
|
||||||
|
}
|
||||||
|
|
||||||
|
$ExePath = [System.IO.Path]::GetFullPath($ExePath)
|
||||||
|
$LogDir = [System.IO.Path]::GetFullPath($LogDir)
|
||||||
|
$BinDir = Split-Path -Parent $ExePath
|
||||||
|
$NextExePath = Join-Path $BinDir "telesrv.next.exe"
|
||||||
|
|
||||||
|
function Write-Step {
|
||||||
|
param([string]$Message)
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "== $Message =="
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-External {
|
||||||
|
param(
|
||||||
|
[string]$FilePath,
|
||||||
|
[string[]]$Arguments,
|
||||||
|
[switch]$AllowFailure
|
||||||
|
)
|
||||||
|
$oldErrorActionPreference = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
try {
|
||||||
|
$output = & $FilePath @Arguments 2>&1
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
} finally {
|
||||||
|
$ErrorActionPreference = $oldErrorActionPreference
|
||||||
|
}
|
||||||
|
$text = ($output | ForEach-Object { $_.ToString() }) -join "`n"
|
||||||
|
if ($exitCode -ne 0 -and -not $AllowFailure) {
|
||||||
|
throw "$FilePath $($Arguments -join ' ') failed with exit code ${exitCode}:`n$text"
|
||||||
|
}
|
||||||
|
[pscustomobject]@{
|
||||||
|
ExitCode = $exitCode
|
||||||
|
Output = $text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-GitOutput {
|
||||||
|
param([string[]]$Arguments, [string]$Default = "unknown")
|
||||||
|
$res = Invoke-External "git" $Arguments -AllowFailure
|
||||||
|
if ($res.ExitCode -ne 0) {
|
||||||
|
return $Default
|
||||||
|
}
|
||||||
|
$text = $res.Output.Trim()
|
||||||
|
if ($text.Length -eq 0) {
|
||||||
|
return $Default
|
||||||
|
}
|
||||||
|
return $text
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ListenPort {
|
||||||
|
param([string]$Address)
|
||||||
|
if ($Address -match '^\[.+\]:(\d+)$') {
|
||||||
|
return [int]$Matches[1]
|
||||||
|
}
|
||||||
|
if ($Address -match ':(\d+)$') {
|
||||||
|
return [int]$Matches[1]
|
||||||
|
}
|
||||||
|
throw "Cannot parse listen port from '$Address'"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-PathUnderRepo {
|
||||||
|
param([string]$Path)
|
||||||
|
if (-not $Path) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
$full = [System.IO.Path]::GetFullPath($Path)
|
||||||
|
return $full.StartsWith($RepoRoot, [System.StringComparison]::OrdinalIgnoreCase)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-RepoTelesrvProcess {
|
||||||
|
param(
|
||||||
|
[object]$Process,
|
||||||
|
[string]$ExePath
|
||||||
|
)
|
||||||
|
if (-not $Process) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $null
|
||||||
|
try {
|
||||||
|
$path = $Process.Path
|
||||||
|
} catch {
|
||||||
|
$path = $null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($path) {
|
||||||
|
$fullPath = [System.IO.Path]::GetFullPath($path)
|
||||||
|
$fullExePath = [System.IO.Path]::GetFullPath($ExePath)
|
||||||
|
$binDir = [System.IO.Path]::GetFullPath((Split-Path -Parent $fullExePath))
|
||||||
|
$fileName = [System.IO.Path]::GetFileName($fullPath)
|
||||||
|
|
||||||
|
if ($fullPath.Equals($fullExePath, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
if ($fullPath.StartsWith($binDir, [System.StringComparison]::OrdinalIgnoreCase) -and ($fileName -like "telesrv*.exe*")) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
# Path can be unavailable for protected or already-exiting processes. Only
|
||||||
|
# take ownership of telesrv-looking processes in that ambiguous state.
|
||||||
|
return (($Process.ProcessName -eq "telesrv") -or ($Process.ProcessName -like "telesrv*"))
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-RepoTelesrvProcesses {
|
||||||
|
param([int]$Port, [string]$ExePath)
|
||||||
|
# 按 PID 去重,合并两条发现路径:
|
||||||
|
# 1) 端口监听者——主路径,但 Get-NetTCPConnection 偶发返回空(曾漏判成 "no listener",
|
||||||
|
# 导致旧进程没被停、promote 复制撞文件锁)。
|
||||||
|
# 2) 按进程名/路径 + 仓库 bin 下 telesrv* 可执行文件——兜底覆盖端口漏报,并能抓到
|
||||||
|
# “持有 telesrv.exe / telesrv.exe~ 文件锁但端口尚未就绪”的实例(promote 复制前必须停掉)。
|
||||||
|
$foundByPid = @{}
|
||||||
|
|
||||||
|
$listenerPids = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique)
|
||||||
|
foreach ($ownerPid in $listenerPids) {
|
||||||
|
if (-not $ownerPid) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$proc = Get-Process -Id $ownerPid -ErrorAction SilentlyContinue
|
||||||
|
if (-not $proc) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$path = $null
|
||||||
|
try {
|
||||||
|
$path = $proc.Path
|
||||||
|
} catch {
|
||||||
|
$path = $null
|
||||||
|
}
|
||||||
|
$procId = [int]$proc.Id
|
||||||
|
$isRepoProcess = Test-RepoTelesrvProcess -Process $proc -ExePath $ExePath
|
||||||
|
if ($isRepoProcess) {
|
||||||
|
$foundByPid[$procId] = $proc
|
||||||
|
} else {
|
||||||
|
throw "Port $Port is held by non-repo process PID $($proc.Id) ($($proc.ProcessName)) at '$path'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidateProcesses = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -eq "telesrv" -or $_.ProcessName -like "telesrv*" })
|
||||||
|
foreach ($proc in $candidateProcesses) {
|
||||||
|
$procId = [int]$proc.Id
|
||||||
|
if ($foundByPid.ContainsKey($procId)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
# 只接管仓库内的实例:路径在 repo/bin 下,或路径不可读但进程名看起来就是 telesrv(兜底)。
|
||||||
|
# 仓库外的同名进程(用户在别处跑的)一律不动。
|
||||||
|
$isRepoProcess = Test-RepoTelesrvProcess -Process $proc -ExePath $ExePath
|
||||||
|
if ($isRepoProcess) {
|
||||||
|
$foundByPid[$procId] = $proc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return @($foundByPid.Values)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-ProcessesExited {
|
||||||
|
param([int[]]$Pids)
|
||||||
|
if (-not $Pids -or $Pids.Count -eq 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$deadline = (Get-Date).AddSeconds(10)
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
$alive = @($Pids | Where-Object { Get-Process -Id $_ -ErrorAction SilentlyContinue })
|
||||||
|
if ($alive.Count -eq 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
}
|
||||||
|
throw "Timed out waiting for old telesrv process(es) to exit: $($Pids -join ', ')"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-PortFree {
|
||||||
|
param([int]$Port, [int[]]$Pids)
|
||||||
|
$deadline = (Get-Date).AddSeconds(10)
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
$stillListening = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $Pids -contains $_.OwningProcess })
|
||||||
|
if ($stillListening.Count -eq 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
}
|
||||||
|
throw "Timed out waiting for old telesrv listener on port $Port to stop"
|
||||||
|
}
|
||||||
|
|
||||||
|
$ListenPort = Get-ListenPort $Listen
|
||||||
|
New-Item -ItemType Directory -Force -Path $BinDir | Out-Null
|
||||||
|
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
|
||||||
|
|
||||||
|
Push-Location $RepoRoot
|
||||||
|
try {
|
||||||
|
if (-not $SkipBuild) {
|
||||||
|
Write-Step "Build telesrv"
|
||||||
|
$commit = Get-GitOutput @("rev-parse", "HEAD")
|
||||||
|
$branch = Get-GitOutput @("branch", "--show-current")
|
||||||
|
$dirty = Get-GitOutput @("status", "--porcelain", "--untracked-files=no") -Default ""
|
||||||
|
$treeState = "clean"
|
||||||
|
if ($dirty.Length -gt 0) {
|
||||||
|
$treeState = "dirty"
|
||||||
|
}
|
||||||
|
$buildTime = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||||
|
$ldflags = "-X main.gitCommit=$commit -X main.gitBranch=$branch -X main.gitTreeState=$treeState -X main.buildTime=$buildTime"
|
||||||
|
|
||||||
|
Remove-Item -LiteralPath $NextExePath -ErrorAction SilentlyContinue
|
||||||
|
Invoke-External "go" @("build", "-ldflags", $ldflags, "-o", $NextExePath, ".\cmd\telesrv") | Out-Null
|
||||||
|
Write-Host "[ok] built $NextExePath"
|
||||||
|
Write-Host "[ok] commit=$commit branch=$branch tree=$treeState build_time=$buildTime"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Stop old telesrv processes"
|
||||||
|
$oldProcesses = @(Get-RepoTelesrvProcesses $ListenPort $ExePath)
|
||||||
|
if ($oldProcesses.Count -eq 0) {
|
||||||
|
Write-Host "[ok] no existing repo-local listener on port $ListenPort"
|
||||||
|
} else {
|
||||||
|
$oldPids = @($oldProcesses | Select-Object -ExpandProperty Id)
|
||||||
|
foreach ($proc in $oldProcesses) {
|
||||||
|
Write-Host "[stop] PID $($proc.Id) $($proc.ProcessName) $($proc.Path)"
|
||||||
|
Stop-Process -Id $proc.Id -Force
|
||||||
|
}
|
||||||
|
Wait-ProcessesExited $oldPids
|
||||||
|
Wait-PortFree $ListenPort $oldPids
|
||||||
|
Write-Host "[ok] stopped old listener(s): $($oldPids -join ', ')"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $SkipBuild) {
|
||||||
|
Write-Step "Promote executable"
|
||||||
|
# telesrv.exe 可能被外部 watcher/watchdog 抢先重生的实例占用文件锁;停掉持有者后短暂重试,
|
||||||
|
# 避免直接撞 "being used by another process" 复制失败(曾因此 promote 失败)。
|
||||||
|
$promoted = $false
|
||||||
|
for ($attempt = 1; $attempt -le 10; $attempt++) {
|
||||||
|
try {
|
||||||
|
Copy-Item -LiteralPath $NextExePath -Destination $ExePath -Force -ErrorAction Stop
|
||||||
|
$promoted = $true
|
||||||
|
break
|
||||||
|
} catch {
|
||||||
|
$holders = @(Get-RepoTelesrvProcesses $ListenPort $ExePath)
|
||||||
|
foreach ($holder in $holders) {
|
||||||
|
Write-Host "[stop] PID $($holder.Id) holding $ExePath; retry $attempt/10"
|
||||||
|
Stop-Process -Id $holder.Id -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $promoted) {
|
||||||
|
throw "Failed to promote $ExePath after retries (file kept locked; an external watcher may be respawning telesrv)"
|
||||||
|
}
|
||||||
|
Remove-Item -LiteralPath $NextExePath -ErrorAction SilentlyContinue
|
||||||
|
Write-Host "[ok] promoted $ExePath"
|
||||||
|
} elseif (-not (Test-Path -LiteralPath $ExePath)) {
|
||||||
|
throw "Executable not found: $ExePath"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($NoStart) {
|
||||||
|
Write-Host "[ok] NoStart requested; executable is ready but not running"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Start telesrv"
|
||||||
|
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||||
|
$stdoutPath = Join-Path $LogDir "telesrv-$stamp.out.log"
|
||||||
|
$stderrPath = Join-Path $LogDir "telesrv-$stamp.err.log"
|
||||||
|
|
||||||
|
$env:TELESRV_LISTEN = $Listen
|
||||||
|
if ($AdvertiseIP) {
|
||||||
|
$env:TELESRV_ADVERTISE_IP = $AdvertiseIP
|
||||||
|
}
|
||||||
|
|
||||||
|
$proc = Start-Process -FilePath $ExePath `
|
||||||
|
-WorkingDirectory $RepoRoot `
|
||||||
|
-RedirectStandardOutput $stdoutPath `
|
||||||
|
-RedirectStandardError $stderrPath `
|
||||||
|
-PassThru `
|
||||||
|
-WindowStyle Hidden
|
||||||
|
|
||||||
|
$deadline = (Get-Date).AddSeconds($HealthTimeoutSeconds)
|
||||||
|
$listening = $false
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
$proc.Refresh()
|
||||||
|
if ($proc.HasExited) {
|
||||||
|
$errTail = ""
|
||||||
|
if (Test-Path -LiteralPath $stderrPath) {
|
||||||
|
$errTail = (Get-Content -LiteralPath $stderrPath -Tail $Tail -ErrorAction SilentlyContinue) -join "`n"
|
||||||
|
}
|
||||||
|
throw "telesrv exited during startup with code $($proc.ExitCode):`n$errTail"
|
||||||
|
}
|
||||||
|
$conn = @(Get-NetTCPConnection -LocalPort $ListenPort -State Listen -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.OwningProcess -eq $proc.Id })
|
||||||
|
if ($conn.Count -gt 0) {
|
||||||
|
$listening = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
}
|
||||||
|
if (-not $listening) {
|
||||||
|
throw "telesrv PID $($proc.Id) did not listen on port $ListenPort within ${HealthTimeoutSeconds}s"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[ok] started PID $($proc.Id), listening on $Listen"
|
||||||
|
Write-Host "[ok] stdout: $stdoutPath"
|
||||||
|
Write-Host "[ok] stderr: $stderrPath"
|
||||||
|
if (Test-Path -LiteralPath $stderrPath) {
|
||||||
|
Get-Content -LiteralPath $stderrPath -Tail $Tail
|
||||||
|
}
|
||||||
|
|
||||||
|
[pscustomobject]@{
|
||||||
|
Pid = $proc.Id
|
||||||
|
Listen = $Listen
|
||||||
|
AdvertiseIP = $env:TELESRV_ADVERTISE_IP
|
||||||
|
Exe = $ExePath
|
||||||
|
Stdout = $stdoutPath
|
||||||
|
Stderr = $stderrPath
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
515
scripts/validate-android-big-video-upload.ps1
Normal file
515
scripts/validate-android-big-video-upload.ps1
Normal file
|
|
@ -0,0 +1,515 @@
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Validates Android big video upload and interruption recovery.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This helper covers the stable parts of the big-upload loop:
|
||||||
|
fixture generation/push, baseline snapshots, optional server restart when
|
||||||
|
upload.saveBigFilePart appears, post-send media assertions, and upload temp
|
||||||
|
cleanup checks. It intentionally does not automate Android media picker taps.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[ValidateSet("Preflight", "Prepare", "BeforeSend", "WatchRestart", "AfterSend", "All")]
|
||||||
|
[string]$Phase = "Preflight",
|
||||||
|
|
||||||
|
[long]$SenderUserId = 1780269504,
|
||||||
|
[long]$RecipientUserId = 1780269505,
|
||||||
|
|
||||||
|
[string]$AndroidPackage = "org.telegram.messenger.beta",
|
||||||
|
[string]$DeviceSerial,
|
||||||
|
|
||||||
|
[string]$PostgresContainer = "telesrv-postgres",
|
||||||
|
[string]$Database = "telesrv",
|
||||||
|
[string]$DbUser = "telesrv",
|
||||||
|
|
||||||
|
[string]$ServerLogPath,
|
||||||
|
[string]$StatePath,
|
||||||
|
[string]$FixtureDir,
|
||||||
|
[string]$VideoFixture,
|
||||||
|
[string]$BlobDir,
|
||||||
|
[string]$RemoteMovieDir = "/sdcard/Movies/telesrv",
|
||||||
|
[int64]$MinBigFileBytes = 12MB,
|
||||||
|
[int]$RestartAfterParts = 2,
|
||||||
|
[int]$WatchTimeoutSeconds = 90,
|
||||||
|
[string]$RestartScript,
|
||||||
|
|
||||||
|
[switch]$SkipAdb,
|
||||||
|
[switch]$AllowMissingThumb,
|
||||||
|
[switch]$BuildOnRestart
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||||
|
if (-not $ServerLogPath) {
|
||||||
|
$latestLog = Get-ChildItem (Join-Path $RepoRoot "logs") -Filter "telesrv-*.err.log" -ErrorAction SilentlyContinue |
|
||||||
|
Sort-Object LastWriteTime -Descending |
|
||||||
|
Select-Object -First 1
|
||||||
|
if ($latestLog) {
|
||||||
|
$ServerLogPath = $latestLog.FullName
|
||||||
|
} else {
|
||||||
|
$ServerLogPath = Join-Path $RepoRoot "logs\telesrv.err.log"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $StatePath) {
|
||||||
|
$StatePath = Join-Path $RepoRoot "logs\android-big-video-upload-state.json"
|
||||||
|
}
|
||||||
|
if (-not $FixtureDir) {
|
||||||
|
$FixtureDir = Join-Path $RepoRoot "logs\media-fixtures"
|
||||||
|
}
|
||||||
|
if (-not $BlobDir) {
|
||||||
|
$BlobDir = Join-Path $RepoRoot "data\blobs"
|
||||||
|
}
|
||||||
|
if (-not $RestartScript) {
|
||||||
|
$RestartScript = Join-Path $RepoRoot "scripts\restart-local-server.ps1"
|
||||||
|
}
|
||||||
|
|
||||||
|
$Failures = New-Object System.Collections.Generic.List[string]
|
||||||
|
|
||||||
|
function Write-Step([string]$Message) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "== $Message =="
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Ok([string]$Message) {
|
||||||
|
Write-Host "[ok] $Message"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Warn([string]$Message) {
|
||||||
|
Write-Host "[warn] $Message"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Add-Failure([string]$Message) {
|
||||||
|
$script:Failures.Add($Message) | Out-Null
|
||||||
|
Write-Host "[fail] $Message"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-Check([bool]$Condition, [string]$Message) {
|
||||||
|
if ($Condition) { Write-Ok $Message } else { Add-Failure $Message }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-External {
|
||||||
|
param(
|
||||||
|
[string]$FilePath,
|
||||||
|
[string[]]$Arguments,
|
||||||
|
[switch]$AllowFailure
|
||||||
|
)
|
||||||
|
$oldErrorActionPreference = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
try {
|
||||||
|
$output = & $FilePath @Arguments 2>&1
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
} finally {
|
||||||
|
$ErrorActionPreference = $oldErrorActionPreference
|
||||||
|
}
|
||||||
|
$text = ($output | ForEach-Object { $_.ToString() }) -join "`n"
|
||||||
|
if ($exitCode -ne 0 -and -not $AllowFailure) {
|
||||||
|
throw "$FilePath $($Arguments -join ' ') failed with exit code ${exitCode}:`n$text"
|
||||||
|
}
|
||||||
|
[pscustomobject]@{ ExitCode = $exitCode; Output = $text }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-PsqlRows([string]$Sql) {
|
||||||
|
$result = Invoke-External "docker" @(
|
||||||
|
"exec", $PostgresContainer,
|
||||||
|
"psql", "-U", $DbUser, "-d", $Database,
|
||||||
|
"-v", "ON_ERROR_STOP=1",
|
||||||
|
"-At", "-F", "|",
|
||||||
|
"-c", $Sql
|
||||||
|
)
|
||||||
|
if ([string]::IsNullOrWhiteSpace($result.Output)) {
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
return @($result.Output -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-SharedLogLines([string]$Path = $ServerLogPath) {
|
||||||
|
if (-not (Test-Path -LiteralPath $Path)) {
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
$stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
|
||||||
|
try {
|
||||||
|
$reader = New-Object System.IO.StreamReader($stream)
|
||||||
|
try {
|
||||||
|
$lines = New-Object System.Collections.Generic.List[string]
|
||||||
|
while (-not $reader.EndOfStream) {
|
||||||
|
$lines.Add($reader.ReadLine()) | Out-Null
|
||||||
|
}
|
||||||
|
return $lines
|
||||||
|
} finally {
|
||||||
|
$reader.Dispose()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$stream.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-LogLineCount {
|
||||||
|
return @(Read-SharedLogLines).Count
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-LogLinesSince([int]$Skip, [string]$Path = $ServerLogPath) {
|
||||||
|
return @(Read-SharedLogLines $Path | Select-Object -Skip $Skip)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-AdbArgs([string[]]$Arguments) {
|
||||||
|
if ($DeviceSerial) { return @("-s", $DeviceSerial) + $Arguments }
|
||||||
|
return $Arguments
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Adb([string[]]$Arguments, [switch]$AllowFailure) {
|
||||||
|
Invoke-External "adb" (Get-AdbArgs $Arguments) -AllowFailure:$AllowFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
function Save-State([pscustomobject]$State) {
|
||||||
|
$dir = Split-Path -Parent $StatePath
|
||||||
|
if ($dir) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
|
||||||
|
$State | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $StatePath -Encoding UTF8
|
||||||
|
Write-Ok "state saved to $StatePath"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Load-State {
|
||||||
|
if (-not (Test-Path -LiteralPath $StatePath)) {
|
||||||
|
throw "state file not found: $StatePath. Run -Phase BeforeSend first."
|
||||||
|
}
|
||||||
|
Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-PrivateMessageMaxId {
|
||||||
|
$rows = @(Invoke-PsqlRows @"
|
||||||
|
SELECT COALESCE(MAX(id), 0)
|
||||||
|
FROM private_messages
|
||||||
|
WHERE (sender_user_id = $SenderUserId AND recipient_user_id = $RecipientUserId)
|
||||||
|
OR (sender_user_id = $RecipientUserId AND recipient_user_id = $SenderUserId);
|
||||||
|
"@)
|
||||||
|
if ($rows.Count -eq 0) { return 0 }
|
||||||
|
return [long]$rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-UploadPartUsage {
|
||||||
|
$rows = @(Invoke-PsqlRows @"
|
||||||
|
SELECT COUNT(*), COALESCE(SUM(size), 0)
|
||||||
|
FROM upload_parts
|
||||||
|
WHERE owner_user_id = $SenderUserId;
|
||||||
|
"@)
|
||||||
|
if ($rows.Count -eq 0) {
|
||||||
|
return [pscustomobject]@{ Parts = 0; Bytes = 0L }
|
||||||
|
}
|
||||||
|
$parts = $rows[0] -split "\|"
|
||||||
|
return [pscustomobject]@{ Parts = [int]$parts[0]; Bytes = [long]$parts[1] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-UploadTempStats {
|
||||||
|
$root = Join-Path (Join-Path $BlobDir "upload_parts") ([string]$SenderUserId)
|
||||||
|
if (-not (Test-Path -LiteralPath $root)) {
|
||||||
|
return [pscustomobject]@{ Files = 0; Bytes = 0L }
|
||||||
|
}
|
||||||
|
$files = @(Get-ChildItem -LiteralPath $root -Recurse -File -ErrorAction SilentlyContinue)
|
||||||
|
$bytes = 0L
|
||||||
|
foreach ($file in $files) { $bytes += [long]$file.Length }
|
||||||
|
return [pscustomobject]@{ Files = $files.Count; Bytes = $bytes }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-NewVideoMessages([long]$AfterMessageId) {
|
||||||
|
$rows = @(Invoke-PsqlRows @"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
COALESCE(media->>'kind', ''),
|
||||||
|
COALESCE(media->>'video', 'false'),
|
||||||
|
COALESCE(media->'document'->>'id', '0'),
|
||||||
|
COALESCE(media->'document'->>'mime_type', ''),
|
||||||
|
COALESCE(media->'document'->>'size', '0'),
|
||||||
|
COALESCE(jsonb_array_length(COALESCE(media->'document'->'thumbs', '[]'::jsonb)), 0)
|
||||||
|
FROM private_messages
|
||||||
|
WHERE id > $AfterMessageId
|
||||||
|
AND sender_user_id = $SenderUserId
|
||||||
|
AND recipient_user_id = $RecipientUserId
|
||||||
|
ORDER BY id;
|
||||||
|
"@)
|
||||||
|
$items = @()
|
||||||
|
foreach ($row in $rows) {
|
||||||
|
$parts = $row -split "\|", 7
|
||||||
|
$items += [pscustomobject]@{
|
||||||
|
MessageId = [long]$parts[0]
|
||||||
|
Kind = $parts[1]
|
||||||
|
Video = $parts[2]
|
||||||
|
DocumentId = [long]$parts[3]
|
||||||
|
MimeType = $parts[4]
|
||||||
|
Size = [long]$parts[5]
|
||||||
|
ThumbCount = [int]$parts[6]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $items
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-DocumentRows([long[]]$DocumentIds) {
|
||||||
|
if ($DocumentIds.Count -eq 0) { return @() }
|
||||||
|
$ids = ($DocumentIds | ForEach-Object { $_.ToString() }) -join ","
|
||||||
|
$rows = @(Invoke-PsqlRows @"
|
||||||
|
SELECT id, mime_type, size, jsonb_array_length(COALESCE(thumbs, '[]'::jsonb))
|
||||||
|
FROM documents
|
||||||
|
WHERE id IN ($ids)
|
||||||
|
ORDER BY id;
|
||||||
|
"@)
|
||||||
|
$items = @()
|
||||||
|
foreach ($row in $rows) {
|
||||||
|
$parts = $row -split "\|", 4
|
||||||
|
$items += [pscustomobject]@{
|
||||||
|
DocumentId = [long]$parts[0]
|
||||||
|
MimeType = $parts[1]
|
||||||
|
Size = [long]$parts[2]
|
||||||
|
ThumbCount = [int]$parts[3]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $items
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-FileBlobRows([long[]]$DocumentIds) {
|
||||||
|
if ($DocumentIds.Count -eq 0) { return @() }
|
||||||
|
$keys = @()
|
||||||
|
foreach ($docId in $DocumentIds) {
|
||||||
|
$keys += "doc:$docId"
|
||||||
|
$keys += "doc:${docId}:m"
|
||||||
|
}
|
||||||
|
$quoted = ($keys | ForEach-Object { "'" + $_.Replace("'", "''") + "'" }) -join ","
|
||||||
|
$rows = @(Invoke-PsqlRows @"
|
||||||
|
SELECT location_key, backend, object_key, size, mime_type
|
||||||
|
FROM file_blobs
|
||||||
|
WHERE location_key IN ($quoted)
|
||||||
|
ORDER BY location_key;
|
||||||
|
"@)
|
||||||
|
$items = @()
|
||||||
|
foreach ($row in $rows) {
|
||||||
|
$parts = $row -split "\|", 5
|
||||||
|
$items += [pscustomobject]@{
|
||||||
|
LocationKey = $parts[0]
|
||||||
|
Backend = $parts[1]
|
||||||
|
ObjectKey = $parts[2]
|
||||||
|
Size = [long]$parts[3]
|
||||||
|
MimeType = $parts[4]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $items
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-BlobFilePath([string]$ObjectKey) {
|
||||||
|
if ($ObjectKey.Length -lt 4) {
|
||||||
|
return Join-Path $BlobDir $ObjectKey
|
||||||
|
}
|
||||||
|
return Join-Path (Join-Path (Join-Path $BlobDir $ObjectKey.Substring(0, 2)) $ObjectKey.Substring(2, 2)) $ObjectKey
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-BigVideoFixture([string]$Path) {
|
||||||
|
$ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue
|
||||||
|
if (-not $ffmpeg) {
|
||||||
|
Add-Failure "ffmpeg is available or -VideoFixture points at an existing >10MB mp4"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Invoke-External "ffmpeg" @(
|
||||||
|
"-y",
|
||||||
|
"-f", "lavfi",
|
||||||
|
"-i", "testsrc2=size=1280x720:rate=30",
|
||||||
|
"-f", "lavfi",
|
||||||
|
"-i", "sine=frequency=660:sample_rate=44100",
|
||||||
|
"-t", "16",
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-preset", "ultrafast",
|
||||||
|
"-b:v", "8M",
|
||||||
|
"-maxrate", "8M",
|
||||||
|
"-bufsize", "16M",
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-shortest",
|
||||||
|
$Path
|
||||||
|
) | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ensure-BigVideoFixture {
|
||||||
|
New-Item -ItemType Directory -Force -Path $FixtureDir | Out-Null
|
||||||
|
if (-not $VideoFixture) {
|
||||||
|
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||||
|
$script:VideoFixture = Join-Path $FixtureDir "telesrv-android-big-video-$stamp.mp4"
|
||||||
|
New-BigVideoFixture $script:VideoFixture
|
||||||
|
}
|
||||||
|
Assert-Check ($VideoFixture -and (Test-Path -LiteralPath $VideoFixture)) "video fixture exists: $VideoFixture"
|
||||||
|
if ($VideoFixture -and (Test-Path -LiteralPath $VideoFixture)) {
|
||||||
|
$size = (Get-Item -LiteralPath $VideoFixture).Length
|
||||||
|
Write-Host "fixture_size=$size"
|
||||||
|
Assert-Check ($size -ge $MinBigFileBytes) "fixture is large enough to trigger upload.saveBigFilePart"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Push-BigVideoFixture {
|
||||||
|
if ($SkipAdb) {
|
||||||
|
Write-Warn "adb push skipped"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Invoke-Adb @("shell", "mkdir", "-p", $RemoteMovieDir) | Out-Null
|
||||||
|
Invoke-Adb @("push", $VideoFixture, "$RemoteMovieDir/") | Out-Null
|
||||||
|
$leaf = Split-Path -Leaf $VideoFixture
|
||||||
|
Invoke-Adb @("shell", "am", "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", "file://$RemoteMovieDir/$leaf") | Out-Null
|
||||||
|
Write-Ok "big video pushed to Android: $RemoteMovieDir/$leaf"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Run-Preflight {
|
||||||
|
Write-Step "Preflight"
|
||||||
|
Assert-Check (Test-Path -LiteralPath $ServerLogPath) "server log exists: $ServerLogPath"
|
||||||
|
Invoke-PsqlRows "SELECT 1;" | Out-Null
|
||||||
|
Write-Ok "PostgreSQL is reachable"
|
||||||
|
if (-not $SkipAdb) {
|
||||||
|
Assert-Check ([bool](Get-Command adb -ErrorAction SilentlyContinue)) "adb is available"
|
||||||
|
$devices = Invoke-Adb @("devices")
|
||||||
|
$deviceLines = @($devices.Output -split "`r?`n" | Where-Object { $_ -match "\tdevice$" })
|
||||||
|
Assert-Check ($deviceLines.Count -ge 1) "adb has a connected device"
|
||||||
|
Assert-Check (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) "adb selects a single device or -DeviceSerial is set"
|
||||||
|
if (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) {
|
||||||
|
$pkg = Invoke-Adb @("shell", "dumpsys", "package", $AndroidPackage)
|
||||||
|
Assert-Check ($pkg.Output -match "versionName=") "Android package $AndroidPackage is installed"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Warn "adb checks skipped"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Run-Prepare {
|
||||||
|
Write-Step "Prepare big video"
|
||||||
|
Run-Preflight
|
||||||
|
Ensure-BigVideoFixture
|
||||||
|
Push-BigVideoFixture
|
||||||
|
Write-Host "Manual step: send the pushed >10MB video from Android/Alice to Bob."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Run-BeforeSend {
|
||||||
|
Write-Step "BeforeSend snapshot"
|
||||||
|
$usage = Get-UploadPartUsage
|
||||||
|
$temp = Get-UploadTempStats
|
||||||
|
$state = [pscustomobject]@{
|
||||||
|
SenderUserId = $SenderUserId
|
||||||
|
RecipientUserId = $RecipientUserId
|
||||||
|
BaselinePrivateMessageId = Get-PrivateMessageMaxId
|
||||||
|
BaselineUploadParts = $usage.Parts
|
||||||
|
BaselineUploadPartBytes = $usage.Bytes
|
||||||
|
BaselineTempFiles = $temp.Files
|
||||||
|
BaselineTempBytes = $temp.Bytes
|
||||||
|
BaselineLogLineCount = Get-LogLineCount
|
||||||
|
ServerLogPath = $ServerLogPath
|
||||||
|
BlobDir = $BlobDir
|
||||||
|
RestartTriggered = $false
|
||||||
|
RestartedAt = ""
|
||||||
|
ObservedBigPart = $false
|
||||||
|
CreatedAt = (Get-Date -Format o)
|
||||||
|
VideoFixture = $VideoFixture
|
||||||
|
}
|
||||||
|
Write-Host "private_messages max id before send: $($state.BaselinePrivateMessageId)"
|
||||||
|
Write-Host "upload_parts before send: parts=$($usage.Parts) bytes=$($usage.Bytes)"
|
||||||
|
Write-Host "temp upload files before send: files=$($temp.Files) bytes=$($temp.Bytes)"
|
||||||
|
Save-State $state
|
||||||
|
}
|
||||||
|
|
||||||
|
function Run-WatchRestart {
|
||||||
|
Write-Step "Watch upload.saveBigFilePart and restart"
|
||||||
|
$state = Load-State
|
||||||
|
$deadline = (Get-Date).AddSeconds($WatchTimeoutSeconds)
|
||||||
|
$restartDone = $false
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
$lines = @(Get-LogLinesSince ([int]$state.BaselineLogLineCount) ([string]$state.ServerLogPath))
|
||||||
|
$hits = @($lines | Where-Object {
|
||||||
|
$_ -like "*upload.saveBigFilePart*" -and $_ -like '*client_type": "android"*'
|
||||||
|
})
|
||||||
|
if ($hits.Count -ge $RestartAfterParts) {
|
||||||
|
Write-Host "observed upload.saveBigFilePart lines=$($hits.Count); restarting server"
|
||||||
|
$state.ObservedBigPart = $true
|
||||||
|
$state.RestartTriggered = $true
|
||||||
|
$state.RestartedAt = (Get-Date -Format o)
|
||||||
|
Save-State $state
|
||||||
|
$args = @()
|
||||||
|
if (-not $BuildOnRestart) {
|
||||||
|
$args += "-SkipBuild"
|
||||||
|
}
|
||||||
|
Invoke-External "powershell" (@("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $RestartScript) + $args) | Out-Null
|
||||||
|
$restartDone = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
}
|
||||||
|
Assert-Check $restartDone "server restarted after upload.saveBigFilePart was observed"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Run-AfterSend {
|
||||||
|
Write-Step "AfterSend assertions"
|
||||||
|
$state = Load-State
|
||||||
|
$messages = @(Get-NewVideoMessages ([long]$state.BaselinePrivateMessageId))
|
||||||
|
foreach ($message in $messages) {
|
||||||
|
Write-Host ("new private message id={0} kind={1} video={2} doc={3} mime={4} size={5} thumbs={6}" -f $message.MessageId, $message.Kind, $message.Video, $message.DocumentId, $message.MimeType, $message.Size, $message.ThumbCount)
|
||||||
|
}
|
||||||
|
$videos = @($messages | Where-Object {
|
||||||
|
$_.Kind -eq "document" -and $_.Video -eq "true" -and $_.MimeType -eq "video/mp4" -and $_.DocumentId -gt 0 -and $_.Size -ge $MinBigFileBytes
|
||||||
|
})
|
||||||
|
Assert-Check ($videos.Count -ge 1) "new private message includes a >10MB uploaded video/mp4 document"
|
||||||
|
|
||||||
|
$docIds = @($videos | Select-Object -ExpandProperty DocumentId -Unique)
|
||||||
|
$documents = @(Get-DocumentRows $docIds)
|
||||||
|
$blobs = @(Get-FileBlobRows $docIds)
|
||||||
|
Assert-Check (@($documents | Where-Object { $_.MimeType -eq "video/mp4" -and $_.Size -ge $MinBigFileBytes }).Count -ge 1) "documents row persisted for big video"
|
||||||
|
if (-not $AllowMissingThumb) {
|
||||||
|
Assert-Check (@($documents | Where-Object { $_.ThumbCount -gt 0 }).Count -ge 1) "big video document has thumbnail metadata"
|
||||||
|
}
|
||||||
|
$bodyBlobs = @($blobs | Where-Object { $_.LocationKey -like "doc:*" -and $_.LocationKey -notlike "*:m" -and $_.Size -ge $MinBigFileBytes })
|
||||||
|
Assert-Check ($bodyBlobs.Count -ge 1) "big video body file_blobs row exists"
|
||||||
|
if (-not $AllowMissingThumb) {
|
||||||
|
Assert-Check (@($blobs | Where-Object { $_.LocationKey -like "doc:*:m" -and $_.Size -gt 0 }).Count -ge 1) "big video thumbnail file_blobs row exists"
|
||||||
|
}
|
||||||
|
foreach ($blob in $blobs) {
|
||||||
|
if ($blob.Backend -eq "localfs" -and $blob.ObjectKey) {
|
||||||
|
Assert-Check (Test-Path -LiteralPath (Get-BlobFilePath $blob.ObjectKey)) "localfs blob exists: $($blob.LocationKey)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$usage = Get-UploadPartUsage
|
||||||
|
$temp = Get-UploadTempStats
|
||||||
|
Write-Host "upload_parts after send: parts=$($usage.Parts) bytes=$($usage.Bytes)"
|
||||||
|
Write-Host "temp upload files after send: files=$($temp.Files) bytes=$($temp.Bytes)"
|
||||||
|
Assert-Check ($usage.Parts -le [int]$state.BaselineUploadParts) "upload_parts metadata cleaned after successful big upload"
|
||||||
|
Assert-Check ($temp.Files -le [int]$state.BaselineTempFiles) "upload temp files cleaned after successful big upload"
|
||||||
|
|
||||||
|
$oldLines = @(Get-LogLinesSince ([int]$state.BaselineLogLineCount) ([string]$state.ServerLogPath))
|
||||||
|
$newLines = @(Read-SharedLogLines)
|
||||||
|
$combined = @($oldLines + $newLines)
|
||||||
|
$bigHits = @($combined | Where-Object { $_ -like "*upload.saveBigFilePart*" -and $_ -like '*client_type": "android"*' })
|
||||||
|
$sendMediaHits = @($combined | Where-Object { $_ -like "*messages.sendMedia*" -and $_ -like '*client_type": "android"*' })
|
||||||
|
$bad = @($combined | Where-Object { $_ -cmatch "INTERNAL_SERVER_ERROR|rpc error|Unhandled RPC|NOT_IMPLEMENTED|bad_msg|panic|\tERROR\t" })
|
||||||
|
Assert-Check (($bigHits.Count -ge 1) -or [bool]$state.ObservedBigPart) "server log has Android upload.saveBigFilePart"
|
||||||
|
Assert-Check ($sendMediaHits.Count -ge 1) "server log has Android messages.sendMedia"
|
||||||
|
Assert-Check ($bad.Count -eq 0) "server logs have no big-upload-era internal errors or unhandled RPCs"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Finish-Run {
|
||||||
|
if ($Failures.Count -gt 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Validation failed:"
|
||||||
|
foreach ($failure in $Failures) { Write-Host " - $failure" }
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Validation passed."
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($Phase) {
|
||||||
|
"Preflight" { Run-Preflight }
|
||||||
|
"Prepare" { Run-Prepare }
|
||||||
|
"BeforeSend" { Run-BeforeSend }
|
||||||
|
"WatchRestart" { Run-WatchRestart }
|
||||||
|
"AfterSend" { Run-AfterSend }
|
||||||
|
"All" {
|
||||||
|
Run-Prepare
|
||||||
|
Run-BeforeSend
|
||||||
|
Write-Host "Start sending the pushed video from Android/Alice now."
|
||||||
|
Run-WatchRestart
|
||||||
|
Read-Host "After Android finishes sending the video, press Enter"
|
||||||
|
Run-AfterSend
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Finish-Run
|
||||||
536
scripts/validate-android-offline-media-recovery.ps1
Normal file
536
scripts/validate-android-offline-media-recovery.ps1
Normal file
|
|
@ -0,0 +1,536 @@
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Semi-automates the Android -> TDesktop offline channel media recovery check.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This helper covers the stable parts of the local validation loop:
|
||||||
|
preflight checks, fixture generation/push, PostgreSQL state snapshots, server
|
||||||
|
log checks, and pass/fail assertions. It intentionally does not drive the
|
||||||
|
Android media picker or Telegram Desktop UI; those remain manual steps because
|
||||||
|
their coordinates and cached state are device/client dependent.
|
||||||
|
|
||||||
|
Typical flow:
|
||||||
|
1. Run -Phase Prepare to generate and push fixtures to Android.
|
||||||
|
2. Close DebugBob/TDesktop, then run -Phase BeforeSend.
|
||||||
|
3. Send photo, video, and document from Android/Alice.
|
||||||
|
4. Run -Phase AfterSend.
|
||||||
|
5. Start DebugBob/TDesktop, open the channel, confirm media is visible.
|
||||||
|
6. Run -Phase AfterBobOpen.
|
||||||
|
|
||||||
|
Use -Phase All for the same flow with interactive pauses.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[ValidateSet("Preflight", "Prepare", "BeforeSend", "AfterSend", "AfterBobOpen", "All")]
|
||||||
|
[string]$Phase = "Preflight",
|
||||||
|
|
||||||
|
[long]$ChannelId = 24,
|
||||||
|
[long]$AndroidUserId = 1780269504,
|
||||||
|
[long]$BobUserId = 1780269505,
|
||||||
|
|
||||||
|
[string]$AndroidPackage = "org.telegram.messenger.beta",
|
||||||
|
[string]$DeviceSerial,
|
||||||
|
|
||||||
|
[string]$PostgresContainer = "telesrv-postgres",
|
||||||
|
[string]$Database = "telesrv",
|
||||||
|
[string]$DbUser = "telesrv",
|
||||||
|
|
||||||
|
[string]$ServerLogPath,
|
||||||
|
[string]$StatePath,
|
||||||
|
[string]$FixtureDir,
|
||||||
|
|
||||||
|
[string]$PhotoFixture,
|
||||||
|
[string]$VideoFixture,
|
||||||
|
[string]$DocumentFixture,
|
||||||
|
|
||||||
|
[string]$RemotePictureDir = "/sdcard/Pictures/telesrv",
|
||||||
|
[string]$RemoteMovieDir = "/sdcard/Movies/telesrv",
|
||||||
|
[string]$RemoteDocumentDir = "/sdcard/Download/telesrv",
|
||||||
|
|
||||||
|
[int]$ExpectedNewMessages = 3,
|
||||||
|
|
||||||
|
[switch]$AllowMissingVideo,
|
||||||
|
[switch]$AllowMissingGenericDocument,
|
||||||
|
[switch]$SkipAdb
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||||
|
if (-not $ServerLogPath) {
|
||||||
|
$ServerLogPath = Join-Path $RepoRoot "logs\app-version-observe-20260609-165537.err.log"
|
||||||
|
}
|
||||||
|
if (-not $StatePath) {
|
||||||
|
$StatePath = Join-Path $RepoRoot "logs\android-offline-media-recovery-state.json"
|
||||||
|
}
|
||||||
|
if (-not $FixtureDir) {
|
||||||
|
$FixtureDir = Join-Path $RepoRoot "logs\media-fixtures"
|
||||||
|
}
|
||||||
|
|
||||||
|
$Failures = New-Object System.Collections.Generic.List[string]
|
||||||
|
|
||||||
|
function Write-Step {
|
||||||
|
param([string]$Message)
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "== $Message =="
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Ok {
|
||||||
|
param([string]$Message)
|
||||||
|
Write-Host "[ok] $Message"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Warn {
|
||||||
|
param([string]$Message)
|
||||||
|
Write-Host "[warn] $Message"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Add-Failure {
|
||||||
|
param([string]$Message)
|
||||||
|
$script:Failures.Add($Message) | Out-Null
|
||||||
|
Write-Host "[fail] $Message"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-Check {
|
||||||
|
param(
|
||||||
|
[bool]$Condition,
|
||||||
|
[string]$Message
|
||||||
|
)
|
||||||
|
if ($Condition) {
|
||||||
|
Write-Ok $Message
|
||||||
|
} else {
|
||||||
|
Add-Failure $Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-External {
|
||||||
|
param(
|
||||||
|
[string]$FilePath,
|
||||||
|
[string[]]$Arguments,
|
||||||
|
[switch]$AllowFailure
|
||||||
|
)
|
||||||
|
$oldErrorActionPreference = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
try {
|
||||||
|
$output = & $FilePath @Arguments 2>&1
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
} finally {
|
||||||
|
$ErrorActionPreference = $oldErrorActionPreference
|
||||||
|
}
|
||||||
|
$text = ($output | ForEach-Object { $_.ToString() }) -join "`n"
|
||||||
|
if ($exitCode -ne 0 -and -not $AllowFailure) {
|
||||||
|
throw "$FilePath $($Arguments -join ' ') failed with exit code ${exitCode}:`n$text"
|
||||||
|
}
|
||||||
|
[pscustomobject]@{
|
||||||
|
ExitCode = $exitCode
|
||||||
|
Output = $text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-Command {
|
||||||
|
param([string]$Name)
|
||||||
|
$cmd = Get-Command $Name -ErrorAction SilentlyContinue
|
||||||
|
Assert-Check ([bool]$cmd) "$Name is available"
|
||||||
|
return [bool]$cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-AdbArgs {
|
||||||
|
param([string[]]$Arguments)
|
||||||
|
if ($DeviceSerial) {
|
||||||
|
return @("-s", $DeviceSerial) + $Arguments
|
||||||
|
}
|
||||||
|
return $Arguments
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Adb {
|
||||||
|
param([string[]]$Arguments)
|
||||||
|
Invoke-External "adb" (Get-AdbArgs $Arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-PsqlRows {
|
||||||
|
param([string]$Sql)
|
||||||
|
$args = @(
|
||||||
|
"exec", $PostgresContainer,
|
||||||
|
"psql", "-U", $DbUser, "-d", $Database,
|
||||||
|
"-v", "ON_ERROR_STOP=1",
|
||||||
|
"-At", "-F", "|",
|
||||||
|
"-c", $Sql
|
||||||
|
)
|
||||||
|
$result = Invoke-External "docker" $args
|
||||||
|
if ([string]::IsNullOrWhiteSpace($result.Output)) {
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
return @($result.Output -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-LogLineCount {
|
||||||
|
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return @((Get-Content -LiteralPath $ServerLogPath)).Count
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-LogLinesSince {
|
||||||
|
param([int]$Skip)
|
||||||
|
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
|
||||||
|
Add-Failure "server log exists at $ServerLogPath"
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
return @(Get-Content -LiteralPath $ServerLogPath | Select-Object -Skip $Skip)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-DialogState {
|
||||||
|
param([long]$UserId)
|
||||||
|
$rows = @(Invoke-PsqlRows @"
|
||||||
|
SELECT top_message_id, read_inbox_max_id, unread_count
|
||||||
|
FROM channel_dialogs
|
||||||
|
WHERE channel_id = $ChannelId AND user_id = $UserId;
|
||||||
|
"@)
|
||||||
|
if ($rows.Count -ne 1) {
|
||||||
|
Add-Failure "channel_dialogs has one row for channel_id=$ChannelId user_id=$UserId"
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
$parts = $rows[0] -split "\|"
|
||||||
|
[pscustomobject]@{
|
||||||
|
UserId = $UserId
|
||||||
|
TopMessageId = [int]$parts[0]
|
||||||
|
ReadInboxMaxId = [int]$parts[1]
|
||||||
|
UnreadCount = [int]$parts[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ChannelPts {
|
||||||
|
$rows = @(Invoke-PsqlRows "SELECT COALESCE(MAX(pts), 0) FROM channel_update_events WHERE channel_id = $ChannelId;")
|
||||||
|
if ($rows.Count -eq 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return [int]$rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-NewMessages {
|
||||||
|
param([int]$AfterMessageId)
|
||||||
|
$rows = @(Invoke-PsqlRows @"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
sender_user_id,
|
||||||
|
media->>'kind',
|
||||||
|
COALESCE(media->'document'->>'mime_type', ''),
|
||||||
|
COALESCE((
|
||||||
|
SELECT attr->>'file_name'
|
||||||
|
FROM jsonb_array_elements(COALESCE(media->'document'->'attributes', '[]'::jsonb)) attr
|
||||||
|
WHERE attr->>'kind' = 'filename'
|
||||||
|
LIMIT 1
|
||||||
|
), '')
|
||||||
|
FROM channel_messages
|
||||||
|
WHERE channel_id = $ChannelId AND id > $AfterMessageId
|
||||||
|
ORDER BY id;
|
||||||
|
"@)
|
||||||
|
$items = @()
|
||||||
|
foreach ($row in $rows) {
|
||||||
|
$parts = $row -split "\|", 5
|
||||||
|
$items += [pscustomobject]@{
|
||||||
|
Id = [int]$parts[0]
|
||||||
|
SenderUserId = [long]$parts[1]
|
||||||
|
Kind = $parts[2]
|
||||||
|
MimeType = $parts[3]
|
||||||
|
FileName = $parts[4]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $items
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-NewEventSummary {
|
||||||
|
param([int]$AfterPts)
|
||||||
|
$rows = @(Invoke-PsqlRows @"
|
||||||
|
SELECT COUNT(*), COALESCE(MAX(pts), 0)
|
||||||
|
FROM channel_update_events
|
||||||
|
WHERE channel_id = $ChannelId
|
||||||
|
AND pts > $AfterPts
|
||||||
|
AND event_type = 'new_channel_message';
|
||||||
|
"@)
|
||||||
|
if ($rows.Count -eq 0) {
|
||||||
|
return [pscustomobject]@{ Count = 0; MaxPts = 0 }
|
||||||
|
}
|
||||||
|
$parts = $rows[0] -split "\|"
|
||||||
|
[pscustomobject]@{
|
||||||
|
Count = [int]$parts[0]
|
||||||
|
MaxPts = [int]$parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Save-State {
|
||||||
|
param([pscustomobject]$State)
|
||||||
|
$dir = Split-Path -Parent $StatePath
|
||||||
|
if ($dir) {
|
||||||
|
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||||
|
}
|
||||||
|
$State | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $StatePath -Encoding UTF8
|
||||||
|
Write-Ok "state saved to $StatePath"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Load-State {
|
||||||
|
if (-not (Test-Path -LiteralPath $StatePath)) {
|
||||||
|
throw "state file not found: $StatePath. Run -Phase BeforeSend first."
|
||||||
|
}
|
||||||
|
Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-PhotoFixture {
|
||||||
|
param([string]$Path)
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
$bitmap = New-Object System.Drawing.Bitmap 900, 520
|
||||||
|
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||||
|
$graphics.Clear([System.Drawing.Color]::FromArgb(34, 137, 116))
|
||||||
|
$fontLarge = New-Object System.Drawing.Font("Arial", 36, [System.Drawing.FontStyle]::Bold)
|
||||||
|
$fontSmall = New-Object System.Drawing.Font("Arial", 22, [System.Drawing.FontStyle]::Regular)
|
||||||
|
$brushWhite = [System.Drawing.Brushes]::White
|
||||||
|
$brushYellow = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(245, 184, 60))
|
||||||
|
$graphics.DrawString("telesrv offline photo", $fontLarge, $brushWhite, 55, 85)
|
||||||
|
$graphics.DrawString((Get-Date -Format "yyyyMMdd-HHmmss"), $fontSmall, $brushWhite, 58, 150)
|
||||||
|
$graphics.FillRectangle($brushYellow, 58, 300, 780, 42)
|
||||||
|
$bitmap.Save($Path, [System.Drawing.Imaging.ImageFormat]::Jpeg)
|
||||||
|
$graphics.Dispose()
|
||||||
|
$bitmap.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-DocumentFixture {
|
||||||
|
param([string]$Path)
|
||||||
|
$content = @"
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<telesrv-offline-media-recovery generated_at="$(Get-Date -Format o)">
|
||||||
|
<purpose>Android to TDesktop offline media recovery validation</purpose>
|
||||||
|
</telesrv-offline-media-recovery>
|
||||||
|
"@
|
||||||
|
Set-Content -LiteralPath $Path -Value $content -Encoding UTF8
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-VideoFixture {
|
||||||
|
param([string]$Path)
|
||||||
|
$ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue
|
||||||
|
if (-not $ffmpeg) {
|
||||||
|
Write-Warn "ffmpeg not found; video fixture was not generated"
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
Invoke-External "ffmpeg" @(
|
||||||
|
"-y",
|
||||||
|
"-f", "lavfi",
|
||||||
|
"-i", "testsrc=size=640x360:rate=30",
|
||||||
|
"-t", "1",
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
$Path
|
||||||
|
) | Out-Null
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ensure-Fixtures {
|
||||||
|
New-Item -ItemType Directory -Force -Path $FixtureDir | Out-Null
|
||||||
|
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||||
|
if (-not $PhotoFixture) {
|
||||||
|
$script:PhotoFixture = Join-Path $FixtureDir "telesrv-offline-photo-$stamp.jpg"
|
||||||
|
New-PhotoFixture $script:PhotoFixture
|
||||||
|
}
|
||||||
|
if (-not $DocumentFixture) {
|
||||||
|
$script:DocumentFixture = Join-Path $FixtureDir "telesrv-offline-doc-$stamp.xml"
|
||||||
|
New-DocumentFixture $script:DocumentFixture
|
||||||
|
}
|
||||||
|
if (-not $VideoFixture) {
|
||||||
|
$candidate = Join-Path $FixtureDir "telesrv-offline-video-$stamp.mp4"
|
||||||
|
if (New-VideoFixture $candidate) {
|
||||||
|
$script:VideoFixture = $candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Assert-Check (Test-Path -LiteralPath $PhotoFixture) "photo fixture exists: $PhotoFixture"
|
||||||
|
Assert-Check (Test-Path -LiteralPath $DocumentFixture) "document fixture exists: $DocumentFixture"
|
||||||
|
if (-not $AllowMissingVideo) {
|
||||||
|
Assert-Check ($VideoFixture -and (Test-Path -LiteralPath $VideoFixture)) "video fixture exists: $VideoFixture"
|
||||||
|
} elseif ($VideoFixture) {
|
||||||
|
Assert-Check (Test-Path -LiteralPath $VideoFixture) "video fixture exists: $VideoFixture"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Push-Fixtures {
|
||||||
|
if ($SkipAdb) {
|
||||||
|
Write-Warn "adb push skipped"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Invoke-Adb @("shell", "mkdir", "-p", $RemotePictureDir, $RemoteMovieDir, $RemoteDocumentDir) | Out-Null
|
||||||
|
Invoke-Adb @("push", $PhotoFixture, "$RemotePictureDir/") | Out-Null
|
||||||
|
Invoke-Adb @("push", $DocumentFixture, "$RemoteDocumentDir/") | Out-Null
|
||||||
|
Invoke-Adb @("shell", "am", "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", "file://$RemotePictureDir/$(Split-Path -Leaf $PhotoFixture)") | Out-Null
|
||||||
|
Invoke-Adb @("shell", "am", "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", "file://$RemoteDocumentDir/$(Split-Path -Leaf $DocumentFixture)") | Out-Null
|
||||||
|
if ($VideoFixture -and (Test-Path -LiteralPath $VideoFixture)) {
|
||||||
|
Invoke-Adb @("push", $VideoFixture, "$RemoteMovieDir/") | Out-Null
|
||||||
|
Invoke-Adb @("shell", "am", "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", "file://$RemoteMovieDir/$(Split-Path -Leaf $VideoFixture)") | Out-Null
|
||||||
|
}
|
||||||
|
Write-Ok "fixtures pushed to Android media folders"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Run-Preflight {
|
||||||
|
Write-Step "Preflight"
|
||||||
|
if (-not $SkipAdb) {
|
||||||
|
if (Assert-Command "adb") {
|
||||||
|
$devices = Invoke-Adb @("devices")
|
||||||
|
$deviceLines = @($devices.Output -split "`r?`n" | Where-Object { $_ -match "\tdevice$" })
|
||||||
|
Assert-Check ($deviceLines.Count -ge 1) "adb has at least one connected device"
|
||||||
|
Assert-Check (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) "adb selects a single device or -DeviceSerial is set"
|
||||||
|
if (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) {
|
||||||
|
$pkg = Invoke-Adb @("shell", "dumpsys", "package", $AndroidPackage)
|
||||||
|
Assert-Check ($pkg.Output -match "versionName=") "Android package $AndroidPackage is installed"
|
||||||
|
$model = (Invoke-Adb @("shell", "getprop", "ro.product.model")).Output.Trim()
|
||||||
|
$sdk = (Invoke-Adb @("shell", "getprop", "ro.build.version.sdk")).Output.Trim()
|
||||||
|
Write-Host "Android device: model=$model sdk=$sdk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Warn "adb checks skipped"
|
||||||
|
}
|
||||||
|
if (Assert-Command "docker") {
|
||||||
|
Invoke-PsqlRows "SELECT 1;" | Out-Null
|
||||||
|
Write-Ok "PostgreSQL is reachable through docker container $PostgresContainer"
|
||||||
|
}
|
||||||
|
Assert-Check (Test-Path -LiteralPath $ServerLogPath) "server log exists: $ServerLogPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Run-Prepare {
|
||||||
|
Write-Step "Prepare fixtures"
|
||||||
|
Run-Preflight
|
||||||
|
Ensure-Fixtures
|
||||||
|
Push-Fixtures
|
||||||
|
Write-Host "Manual step: close DebugBob/TDesktop, then send these files from Android/Alice:"
|
||||||
|
Write-Host " Photo: $PhotoFixture"
|
||||||
|
if ($VideoFixture) {
|
||||||
|
Write-Host " Video: $VideoFixture"
|
||||||
|
}
|
||||||
|
Write-Host " Document: $DocumentFixture"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Run-BeforeSend {
|
||||||
|
Write-Step "BeforeSend snapshot"
|
||||||
|
$bob = Get-DialogState $BobUserId
|
||||||
|
$alice = Get-DialogState $AndroidUserId
|
||||||
|
$pts = Get-ChannelPts
|
||||||
|
$lineCount = Get-LogLineCount
|
||||||
|
Assert-Check ($null -ne $bob) "Bob dialog state can be read"
|
||||||
|
Assert-Check ($null -ne $alice) "Android/Alice dialog state can be read"
|
||||||
|
if ($bob) {
|
||||||
|
Write-Host "Bob before send: top=$($bob.TopMessageId) read=$($bob.ReadInboxMaxId) unread=$($bob.UnreadCount)"
|
||||||
|
}
|
||||||
|
if ($alice) {
|
||||||
|
Write-Host "Alice before send: top=$($alice.TopMessageId) read=$($alice.ReadInboxMaxId) unread=$($alice.UnreadCount)"
|
||||||
|
}
|
||||||
|
Write-Host "Channel pts before send: $pts"
|
||||||
|
$state = [pscustomobject]@{
|
||||||
|
ChannelId = $ChannelId
|
||||||
|
AndroidUserId = $AndroidUserId
|
||||||
|
BobUserId = $BobUserId
|
||||||
|
BaselineTopMessageId = if ($bob) { $bob.TopMessageId } else { 0 }
|
||||||
|
BaselineBobReadInboxMaxId = if ($bob) { $bob.ReadInboxMaxId } else { 0 }
|
||||||
|
BaselineBobUnreadCount = if ($bob) { $bob.UnreadCount } else { 0 }
|
||||||
|
BaselineChannelPts = $pts
|
||||||
|
BaselineLogLineCount = $lineCount
|
||||||
|
ServerLogPath = $ServerLogPath
|
||||||
|
CreatedAt = (Get-Date -Format o)
|
||||||
|
PhotoFixture = $PhotoFixture
|
||||||
|
VideoFixture = $VideoFixture
|
||||||
|
DocumentFixture = $DocumentFixture
|
||||||
|
}
|
||||||
|
Save-State $state
|
||||||
|
}
|
||||||
|
|
||||||
|
function Run-AfterSend {
|
||||||
|
Write-Step "AfterSend assertions"
|
||||||
|
$state = Load-State
|
||||||
|
$bob = Get-DialogState $BobUserId
|
||||||
|
$messages = @(Get-NewMessages ([int]$state.BaselineTopMessageId))
|
||||||
|
$events = Get-NewEventSummary ([int]$state.BaselineChannelPts)
|
||||||
|
foreach ($message in $messages) {
|
||||||
|
Write-Host ("new message id={0} sender={1} kind={2} mime={3} file={4}" -f $message.Id, $message.SenderUserId, $message.Kind, $message.MimeType, $message.FileName)
|
||||||
|
}
|
||||||
|
if ($bob) {
|
||||||
|
Write-Host "Bob after send: top=$($bob.TopMessageId) read=$($bob.ReadInboxMaxId) unread=$($bob.UnreadCount)"
|
||||||
|
}
|
||||||
|
Write-Host "new channel events after baseline: count=$($events.Count) max_pts=$($events.MaxPts)"
|
||||||
|
Assert-Check ($messages.Count -ge $ExpectedNewMessages) "at least $ExpectedNewMessages new channel messages were written"
|
||||||
|
Assert-Check (@($messages | Where-Object { $_.SenderUserId -eq $AndroidUserId }).Count -ge $ExpectedNewMessages) "new channel messages are from Android/Alice"
|
||||||
|
Assert-Check (@($messages | Where-Object { $_.Kind -eq "photo" }).Count -ge 1) "new messages include uploaded photo"
|
||||||
|
Assert-Check (@($messages | Where-Object { $_.Kind -eq "document" }).Count -ge 1) "new messages include uploaded document"
|
||||||
|
if (-not $AllowMissingVideo) {
|
||||||
|
Assert-Check (@($messages | Where-Object { $_.MimeType -eq "video/mp4" }).Count -ge 1) "new messages include video/mp4 document"
|
||||||
|
}
|
||||||
|
if (-not $AllowMissingGenericDocument) {
|
||||||
|
Assert-Check (@($messages | Where-Object { $_.Kind -eq "document" -and $_.MimeType -ne "video/mp4" }).Count -ge 1) "new messages include a generic non-video document"
|
||||||
|
}
|
||||||
|
if ($bob) {
|
||||||
|
Assert-Check ($bob.TopMessageId -gt [int]$state.BaselineTopMessageId) "Bob top_message_id advanced while offline"
|
||||||
|
Assert-Check ($bob.ReadInboxMaxId -eq [int]$state.BaselineBobReadInboxMaxId) "Bob read_inbox_max_id did not advance before opening TDesktop"
|
||||||
|
Assert-Check ($bob.UnreadCount -ge $ExpectedNewMessages) "Bob unread_count reflects offline messages"
|
||||||
|
}
|
||||||
|
Assert-Check ($events.Count -ge $ExpectedNewMessages) "durable channel_update_events exist for new messages"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Run-AfterBobOpen {
|
||||||
|
Write-Step "AfterBobOpen assertions"
|
||||||
|
$state = Load-State
|
||||||
|
$bob = Get-DialogState $BobUserId
|
||||||
|
if ($bob) {
|
||||||
|
Write-Host "Bob after open: top=$($bob.TopMessageId) read=$($bob.ReadInboxMaxId) unread=$($bob.UnreadCount)"
|
||||||
|
Assert-Check ($bob.ReadInboxMaxId -ge $bob.TopMessageId) "Bob read_inbox_max_id catches up to current top"
|
||||||
|
Assert-Check ($bob.UnreadCount -eq 0) "Bob unread_count is cleared after opening the channel"
|
||||||
|
}
|
||||||
|
$lines = @(Get-LogLinesSince ([int]$state.BaselineLogLineCount))
|
||||||
|
$bad = @($lines | Where-Object { $_ -match "Unhandled RPC|NOT_IMPLEMENTED|bad_msg" })
|
||||||
|
Assert-Check ($bad.Count -eq 0) "server log has no new Unhandled RPC / NOT_IMPLEMENTED / bad_msg entries"
|
||||||
|
$expectedTDesktop = @(
|
||||||
|
"messages.getHistory",
|
||||||
|
"messages.getPeerDialogs",
|
||||||
|
"upload.getFile",
|
||||||
|
"channels.readHistory",
|
||||||
|
"updates.getChannelDifference"
|
||||||
|
)
|
||||||
|
foreach ($method in $expectedTDesktop) {
|
||||||
|
$hits = @($lines | Where-Object { $_ -like "*$method*" -and $_ -like '*client_type": "tdesktop"*' })
|
||||||
|
Assert-Check ($hits.Count -ge 1) "TDesktop issued $method after Bob opened the channel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Finish-Run {
|
||||||
|
if ($Failures.Count -gt 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Validation failed:"
|
||||||
|
foreach ($failure in $Failures) {
|
||||||
|
Write-Host " - $failure"
|
||||||
|
}
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Validation phase '$Phase' passed."
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($Phase) {
|
||||||
|
"Preflight" {
|
||||||
|
Run-Preflight
|
||||||
|
}
|
||||||
|
"Prepare" {
|
||||||
|
Run-Prepare
|
||||||
|
}
|
||||||
|
"BeforeSend" {
|
||||||
|
Run-BeforeSend
|
||||||
|
}
|
||||||
|
"AfterSend" {
|
||||||
|
Run-AfterSend
|
||||||
|
}
|
||||||
|
"AfterBobOpen" {
|
||||||
|
Run-AfterBobOpen
|
||||||
|
}
|
||||||
|
"All" {
|
||||||
|
Run-Prepare
|
||||||
|
Run-BeforeSend
|
||||||
|
Read-Host "Close DebugBob/TDesktop if needed, send photo/video/document from Android, then press Enter"
|
||||||
|
Run-AfterSend
|
||||||
|
Read-Host "Start DebugBob/TDesktop, open the channel, confirm media renders, then press Enter"
|
||||||
|
Run-AfterBobOpen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Finish-Run
|
||||||
586
scripts/validate-android-video-upload.ps1
Normal file
586
scripts/validate-android-video-upload.ps1
Normal file
|
|
@ -0,0 +1,586 @@
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Semi-automates Android video upload validation against local telesrv.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
The script handles the stable parts of the Android upload regression loop:
|
||||||
|
preflight checks, optional video fixture generation/push, baseline snapshots,
|
||||||
|
server log scanning, PostgreSQL media/message assertions, upload_parts cleanup,
|
||||||
|
and local blob existence checks.
|
||||||
|
|
||||||
|
It intentionally does not drive the Android media picker. Send the prepared
|
||||||
|
video manually from Android/Alice, then run -Phase AfterSend.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[ValidateSet("Preflight", "Prepare", "BeforeSend", "AfterSend", "All")]
|
||||||
|
[string]$Phase = "Preflight",
|
||||||
|
|
||||||
|
[long]$SenderUserId = 1780269504,
|
||||||
|
[long]$RecipientUserId = 1780269505,
|
||||||
|
|
||||||
|
[string]$AndroidPackage = "org.telegram.messenger.beta",
|
||||||
|
[string]$DeviceSerial,
|
||||||
|
|
||||||
|
[string]$PostgresContainer = "telesrv-postgres",
|
||||||
|
[string]$Database = "telesrv",
|
||||||
|
[string]$DbUser = "telesrv",
|
||||||
|
|
||||||
|
[string]$ServerLogPath,
|
||||||
|
[string]$StatePath,
|
||||||
|
[string]$FixtureDir,
|
||||||
|
[string]$VideoFixture,
|
||||||
|
[string]$BlobDir,
|
||||||
|
[string]$RemoteMovieDir = "/sdcard/Movies/telesrv",
|
||||||
|
|
||||||
|
[switch]$SkipAdb,
|
||||||
|
[switch]$AllowMissingThumb
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||||
|
if (-not $ServerLogPath) {
|
||||||
|
$latestLog = Get-ChildItem (Join-Path $RepoRoot "logs") -Filter "telesrv-*.err.log" -ErrorAction SilentlyContinue |
|
||||||
|
Sort-Object LastWriteTime -Descending |
|
||||||
|
Select-Object -First 1
|
||||||
|
if ($latestLog) {
|
||||||
|
$ServerLogPath = $latestLog.FullName
|
||||||
|
} else {
|
||||||
|
$ServerLogPath = Join-Path $RepoRoot "logs\telesrv.err.log"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $StatePath) {
|
||||||
|
$StatePath = Join-Path $RepoRoot "logs\android-video-upload-state.json"
|
||||||
|
}
|
||||||
|
if (-not $FixtureDir) {
|
||||||
|
$FixtureDir = Join-Path $RepoRoot "logs\media-fixtures"
|
||||||
|
}
|
||||||
|
if (-not $BlobDir) {
|
||||||
|
$BlobDir = Join-Path $RepoRoot "data\blobs"
|
||||||
|
}
|
||||||
|
|
||||||
|
$Failures = New-Object System.Collections.Generic.List[string]
|
||||||
|
|
||||||
|
function Write-Step {
|
||||||
|
param([string]$Message)
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "== $Message =="
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Ok {
|
||||||
|
param([string]$Message)
|
||||||
|
Write-Host "[ok] $Message"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Warn {
|
||||||
|
param([string]$Message)
|
||||||
|
Write-Host "[warn] $Message"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Add-Failure {
|
||||||
|
param([string]$Message)
|
||||||
|
$script:Failures.Add($Message) | Out-Null
|
||||||
|
Write-Host "[fail] $Message"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-Check {
|
||||||
|
param(
|
||||||
|
[bool]$Condition,
|
||||||
|
[string]$Message
|
||||||
|
)
|
||||||
|
if ($Condition) {
|
||||||
|
Write-Ok $Message
|
||||||
|
} else {
|
||||||
|
Add-Failure $Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-External {
|
||||||
|
param(
|
||||||
|
[string]$FilePath,
|
||||||
|
[string[]]$Arguments,
|
||||||
|
[switch]$AllowFailure
|
||||||
|
)
|
||||||
|
$oldErrorActionPreference = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
try {
|
||||||
|
$output = & $FilePath @Arguments 2>&1
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
} finally {
|
||||||
|
$ErrorActionPreference = $oldErrorActionPreference
|
||||||
|
}
|
||||||
|
$text = ($output | ForEach-Object { $_.ToString() }) -join "`n"
|
||||||
|
if ($exitCode -ne 0 -and -not $AllowFailure) {
|
||||||
|
throw "$FilePath $($Arguments -join ' ') failed with exit code ${exitCode}:`n$text"
|
||||||
|
}
|
||||||
|
[pscustomobject]@{
|
||||||
|
ExitCode = $exitCode
|
||||||
|
Output = $text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-Command {
|
||||||
|
param([string]$Name)
|
||||||
|
$cmd = Get-Command $Name -ErrorAction SilentlyContinue
|
||||||
|
Assert-Check ([bool]$cmd) "$Name is available"
|
||||||
|
return [bool]$cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-AdbArgs {
|
||||||
|
param([string[]]$Arguments)
|
||||||
|
if ($DeviceSerial) {
|
||||||
|
return @("-s", $DeviceSerial) + $Arguments
|
||||||
|
}
|
||||||
|
return $Arguments
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Adb {
|
||||||
|
param([string[]]$Arguments, [switch]$AllowFailure)
|
||||||
|
Invoke-External "adb" (Get-AdbArgs $Arguments) -AllowFailure:$AllowFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-PsqlRows {
|
||||||
|
param([string]$Sql)
|
||||||
|
$args = @(
|
||||||
|
"exec", $PostgresContainer,
|
||||||
|
"psql", "-U", $DbUser, "-d", $Database,
|
||||||
|
"-v", "ON_ERROR_STOP=1",
|
||||||
|
"-At", "-F", "|",
|
||||||
|
"-c", $Sql
|
||||||
|
)
|
||||||
|
$result = Invoke-External "docker" $args
|
||||||
|
if ([string]::IsNullOrWhiteSpace($result.Output)) {
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
return @($result.Output -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-LogLineCount {
|
||||||
|
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return @(Read-SharedLogLines).Count
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-LogLinesSince {
|
||||||
|
param([int]$Skip)
|
||||||
|
if (-not (Test-Path -LiteralPath $ServerLogPath)) {
|
||||||
|
Add-Failure "server log exists at $ServerLogPath"
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
return @(Read-SharedLogLines | Select-Object -Skip $Skip)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-SharedLogLines {
|
||||||
|
$stream = [System.IO.File]::Open($ServerLogPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
|
||||||
|
try {
|
||||||
|
$reader = New-Object System.IO.StreamReader($stream)
|
||||||
|
try {
|
||||||
|
$lines = New-Object System.Collections.Generic.List[string]
|
||||||
|
while (-not $reader.EndOfStream) {
|
||||||
|
$lines.Add($reader.ReadLine()) | Out-Null
|
||||||
|
}
|
||||||
|
return $lines
|
||||||
|
} finally {
|
||||||
|
$reader.Dispose()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$stream.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-PrivateMessageMaxId {
|
||||||
|
$rows = @(Invoke-PsqlRows @"
|
||||||
|
SELECT COALESCE(MAX(id), 0)
|
||||||
|
FROM private_messages
|
||||||
|
WHERE (sender_user_id = $SenderUserId AND recipient_user_id = $RecipientUserId)
|
||||||
|
OR (sender_user_id = $RecipientUserId AND recipient_user_id = $SenderUserId);
|
||||||
|
"@)
|
||||||
|
if ($rows.Count -eq 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return [long]$rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-UploadPartUsage {
|
||||||
|
$rows = @(Invoke-PsqlRows @"
|
||||||
|
SELECT COUNT(*), COALESCE(SUM(size), 0)
|
||||||
|
FROM upload_parts
|
||||||
|
WHERE owner_user_id = $SenderUserId;
|
||||||
|
"@)
|
||||||
|
if ($rows.Count -eq 0) {
|
||||||
|
return [pscustomobject]@{ Parts = 0; Bytes = 0L }
|
||||||
|
}
|
||||||
|
$parts = $rows[0] -split "\|"
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Parts = [int]$parts[0]
|
||||||
|
Bytes = [long]$parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-NewVideoMessages {
|
||||||
|
param([long]$AfterMessageId)
|
||||||
|
$rows = @(Invoke-PsqlRows @"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
sender_user_id,
|
||||||
|
recipient_user_id,
|
||||||
|
COALESCE(media->>'kind', ''),
|
||||||
|
COALESCE(media->>'video', 'false'),
|
||||||
|
COALESCE(media->'document'->>'id', '0'),
|
||||||
|
COALESCE(media->'document'->>'mime_type', ''),
|
||||||
|
COALESCE(media->'document'->>'size', '0'),
|
||||||
|
COALESCE(jsonb_array_length(COALESCE(media->'document'->'thumbs', '[]'::jsonb)), 0)
|
||||||
|
FROM private_messages
|
||||||
|
WHERE id > $AfterMessageId
|
||||||
|
AND sender_user_id = $SenderUserId
|
||||||
|
AND recipient_user_id = $RecipientUserId
|
||||||
|
ORDER BY id;
|
||||||
|
"@)
|
||||||
|
$items = @()
|
||||||
|
foreach ($row in $rows) {
|
||||||
|
$parts = $row -split "\|", 9
|
||||||
|
$items += [pscustomobject]@{
|
||||||
|
MessageId = [long]$parts[0]
|
||||||
|
SenderUserId = [long]$parts[1]
|
||||||
|
RecipientUserId = [long]$parts[2]
|
||||||
|
Kind = $parts[3]
|
||||||
|
Video = $parts[4]
|
||||||
|
DocumentId = [long]$parts[5]
|
||||||
|
MimeType = $parts[6]
|
||||||
|
Size = [long]$parts[7]
|
||||||
|
ThumbCount = [int]$parts[8]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $items
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-DocumentRows {
|
||||||
|
param([long[]]$DocumentIds)
|
||||||
|
if ($DocumentIds.Count -eq 0) {
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
$ids = ($DocumentIds | ForEach-Object { $_.ToString() }) -join ","
|
||||||
|
$rows = @(Invoke-PsqlRows @"
|
||||||
|
SELECT id, mime_type, size, jsonb_array_length(COALESCE(thumbs, '[]'::jsonb))
|
||||||
|
FROM documents
|
||||||
|
WHERE id IN ($ids)
|
||||||
|
ORDER BY id;
|
||||||
|
"@)
|
||||||
|
$items = @()
|
||||||
|
foreach ($row in $rows) {
|
||||||
|
$parts = $row -split "\|", 4
|
||||||
|
$items += [pscustomobject]@{
|
||||||
|
DocumentId = [long]$parts[0]
|
||||||
|
MimeType = $parts[1]
|
||||||
|
Size = [long]$parts[2]
|
||||||
|
ThumbCount = [int]$parts[3]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $items
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-FileBlobRows {
|
||||||
|
param([long[]]$DocumentIds)
|
||||||
|
if ($DocumentIds.Count -eq 0) {
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
$keys = @()
|
||||||
|
foreach ($docId in $DocumentIds) {
|
||||||
|
$keys += "doc:$docId"
|
||||||
|
$keys += "doc:${docId}:m"
|
||||||
|
}
|
||||||
|
$quoted = ($keys | ForEach-Object { "'" + $_.Replace("'", "''") + "'" }) -join ","
|
||||||
|
$rows = @(Invoke-PsqlRows @"
|
||||||
|
SELECT location_key, backend, object_key, size, mime_type
|
||||||
|
FROM file_blobs
|
||||||
|
WHERE location_key IN ($quoted)
|
||||||
|
ORDER BY location_key;
|
||||||
|
"@)
|
||||||
|
$items = @()
|
||||||
|
foreach ($row in $rows) {
|
||||||
|
$parts = $row -split "\|", 5
|
||||||
|
$items += [pscustomobject]@{
|
||||||
|
LocationKey = $parts[0]
|
||||||
|
Backend = $parts[1]
|
||||||
|
ObjectKey = $parts[2]
|
||||||
|
Size = [long]$parts[3]
|
||||||
|
MimeType = $parts[4]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $items
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-FileBlobRows {
|
||||||
|
param(
|
||||||
|
[long[]]$DocumentIds,
|
||||||
|
[int]$TimeoutSeconds = 10
|
||||||
|
)
|
||||||
|
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||||
|
$last = @()
|
||||||
|
while ($true) {
|
||||||
|
$last = @(Get-FileBlobRows $DocumentIds)
|
||||||
|
$hasBody = @($last | Where-Object { $_.LocationKey -like "doc:*" -and $_.LocationKey -notlike "*:m" -and $_.Size -gt 0 }).Count -ge 1
|
||||||
|
$hasThumb = $AllowMissingThumb -or (@($last | Where-Object { $_.LocationKey -like "doc:*:m" -and $_.Size -gt 0 }).Count -ge 1)
|
||||||
|
if ($hasBody -and $hasThumb) {
|
||||||
|
return $last
|
||||||
|
}
|
||||||
|
if ((Get-Date) -ge $deadline) {
|
||||||
|
return $last
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-EffectiveLogSkip {
|
||||||
|
param([pscustomobject]$State)
|
||||||
|
$stateLog = ""
|
||||||
|
if ($State.PSObject.Properties.Name -contains "ServerLogPath") {
|
||||||
|
$stateLog = [string]$State.ServerLogPath
|
||||||
|
}
|
||||||
|
if ($stateLog) {
|
||||||
|
$stateFull = [System.IO.Path]::GetFullPath($stateLog)
|
||||||
|
$currentFull = [System.IO.Path]::GetFullPath($ServerLogPath)
|
||||||
|
if ($stateFull.Equals($currentFull, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
return [int]$State.BaselineLogLineCount
|
||||||
|
}
|
||||||
|
Write-Warn "server log changed since baseline; scanning current log from the beginning"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return [int]$State.BaselineLogLineCount
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-BlobFilePath {
|
||||||
|
param([string]$ObjectKey)
|
||||||
|
if ($ObjectKey.Length -lt 4) {
|
||||||
|
return Join-Path $BlobDir $ObjectKey
|
||||||
|
}
|
||||||
|
return Join-Path (Join-Path (Join-Path $BlobDir $ObjectKey.Substring(0, 2)) $ObjectKey.Substring(2, 2)) $ObjectKey
|
||||||
|
}
|
||||||
|
|
||||||
|
function Save-State {
|
||||||
|
param([pscustomobject]$State)
|
||||||
|
$dir = Split-Path -Parent $StatePath
|
||||||
|
if ($dir) {
|
||||||
|
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||||
|
}
|
||||||
|
$State | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $StatePath -Encoding UTF8
|
||||||
|
Write-Ok "state saved to $StatePath"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Load-State {
|
||||||
|
if (-not (Test-Path -LiteralPath $StatePath)) {
|
||||||
|
throw "state file not found: $StatePath. Run -Phase BeforeSend first."
|
||||||
|
}
|
||||||
|
Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-VideoFixture {
|
||||||
|
param([string]$Path)
|
||||||
|
$ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue
|
||||||
|
if (-not $ffmpeg) {
|
||||||
|
Add-Failure "ffmpeg is available or -VideoFixture is supplied"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Invoke-External "ffmpeg" @(
|
||||||
|
"-y",
|
||||||
|
"-f", "lavfi",
|
||||||
|
"-i", "testsrc=size=568x1280:rate=30",
|
||||||
|
"-f", "lavfi",
|
||||||
|
"-i", "sine=frequency=880:sample_rate=44100",
|
||||||
|
"-t", "3",
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-shortest",
|
||||||
|
$Path
|
||||||
|
) | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ensure-VideoFixture {
|
||||||
|
New-Item -ItemType Directory -Force -Path $FixtureDir | Out-Null
|
||||||
|
if (-not $VideoFixture) {
|
||||||
|
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||||
|
$script:VideoFixture = Join-Path $FixtureDir "telesrv-android-video-upload-$stamp.mp4"
|
||||||
|
New-VideoFixture $script:VideoFixture
|
||||||
|
}
|
||||||
|
Assert-Check ($VideoFixture -and (Test-Path -LiteralPath $VideoFixture)) "video fixture exists: $VideoFixture"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Push-VideoFixture {
|
||||||
|
if ($SkipAdb) {
|
||||||
|
Write-Warn "adb push skipped"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Invoke-Adb @("shell", "mkdir", "-p", $RemoteMovieDir) | Out-Null
|
||||||
|
Invoke-Adb @("push", $VideoFixture, "$RemoteMovieDir/") | Out-Null
|
||||||
|
$leaf = Split-Path -Leaf $VideoFixture
|
||||||
|
Invoke-Adb @("shell", "am", "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", "file://$RemoteMovieDir/$leaf") | Out-Null
|
||||||
|
Write-Ok "video pushed to Android: $RemoteMovieDir/$leaf"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Run-Preflight {
|
||||||
|
Write-Step "Preflight"
|
||||||
|
if (-not $SkipAdb) {
|
||||||
|
if (Assert-Command "adb") {
|
||||||
|
$devices = Invoke-Adb @("devices")
|
||||||
|
$deviceLines = @($devices.Output -split "`r?`n" | Where-Object { $_ -match "\tdevice$" })
|
||||||
|
Assert-Check ($deviceLines.Count -ge 1) "adb has at least one connected device"
|
||||||
|
Assert-Check (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) "adb selects a single device or -DeviceSerial is set"
|
||||||
|
if (($deviceLines.Count -eq 1) -or [bool]$DeviceSerial) {
|
||||||
|
$pkg = Invoke-Adb @("shell", "dumpsys", "package", $AndroidPackage)
|
||||||
|
Assert-Check ($pkg.Output -match "versionName=") "Android package $AndroidPackage is installed"
|
||||||
|
$model = (Invoke-Adb @("shell", "getprop", "ro.product.model")).Output.Trim()
|
||||||
|
$sdk = (Invoke-Adb @("shell", "getprop", "ro.build.version.sdk")).Output.Trim()
|
||||||
|
Write-Host "Android device: model=$model sdk=$sdk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Warn "adb checks skipped"
|
||||||
|
}
|
||||||
|
if (Assert-Command "docker") {
|
||||||
|
Invoke-PsqlRows "SELECT 1;" | Out-Null
|
||||||
|
Write-Ok "PostgreSQL is reachable through docker container $PostgresContainer"
|
||||||
|
}
|
||||||
|
Assert-Check (Test-Path -LiteralPath $ServerLogPath) "server log exists: $ServerLogPath"
|
||||||
|
Write-Host "server log: $ServerLogPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Run-Prepare {
|
||||||
|
Write-Step "Prepare video fixture"
|
||||||
|
Run-Preflight
|
||||||
|
Ensure-VideoFixture
|
||||||
|
Push-VideoFixture
|
||||||
|
Write-Host "Manual step: send this video from Android/Alice to Bob:"
|
||||||
|
Write-Host " $VideoFixture"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Run-BeforeSend {
|
||||||
|
Write-Step "BeforeSend snapshot"
|
||||||
|
$maxMessageId = Get-PrivateMessageMaxId
|
||||||
|
$usage = Get-UploadPartUsage
|
||||||
|
$lineCount = Get-LogLineCount
|
||||||
|
Write-Host "private_messages max id before send: $maxMessageId"
|
||||||
|
Write-Host "upload_parts before send: parts=$($usage.Parts) bytes=$($usage.Bytes)"
|
||||||
|
$state = [pscustomobject]@{
|
||||||
|
SenderUserId = $SenderUserId
|
||||||
|
RecipientUserId = $RecipientUserId
|
||||||
|
BaselinePrivateMessageId = $maxMessageId
|
||||||
|
BaselineUploadParts = $usage.Parts
|
||||||
|
BaselineUploadPartBytes = $usage.Bytes
|
||||||
|
BaselineLogLineCount = $lineCount
|
||||||
|
ServerLogPath = $ServerLogPath
|
||||||
|
BlobDir = $BlobDir
|
||||||
|
CreatedAt = (Get-Date -Format o)
|
||||||
|
VideoFixture = $VideoFixture
|
||||||
|
}
|
||||||
|
Save-State $state
|
||||||
|
}
|
||||||
|
|
||||||
|
function Run-AfterSend {
|
||||||
|
Write-Step "AfterSend assertions"
|
||||||
|
$state = Load-State
|
||||||
|
$messages = @(Get-NewVideoMessages ([long]$state.BaselinePrivateMessageId))
|
||||||
|
foreach ($message in $messages) {
|
||||||
|
Write-Host ("new private message id={0} kind={1} video={2} doc={3} mime={4} size={5} thumbs={6}" -f $message.MessageId, $message.Kind, $message.Video, $message.DocumentId, $message.MimeType, $message.Size, $message.ThumbCount)
|
||||||
|
}
|
||||||
|
$videos = @($messages | Where-Object {
|
||||||
|
$_.Kind -eq "document" -and $_.Video -eq "true" -and $_.MimeType -eq "video/mp4" -and $_.DocumentId -gt 0
|
||||||
|
})
|
||||||
|
Assert-Check ($videos.Count -ge 1) "new private message includes uploaded video/mp4 document"
|
||||||
|
|
||||||
|
$docIds = @($videos | Select-Object -ExpandProperty DocumentId -Unique)
|
||||||
|
$documents = @(Get-DocumentRows $docIds)
|
||||||
|
$blobs = @(Wait-FileBlobRows $docIds)
|
||||||
|
foreach ($doc in $documents) {
|
||||||
|
Write-Host ("document id={0} mime={1} size={2} thumbs={3}" -f $doc.DocumentId, $doc.MimeType, $doc.Size, $doc.ThumbCount)
|
||||||
|
}
|
||||||
|
foreach ($blob in $blobs) {
|
||||||
|
Write-Host ("blob key={0} backend={1} object={2} size={3} mime={4}" -f $blob.LocationKey, $blob.Backend, $blob.ObjectKey, $blob.Size, $blob.MimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert-Check ($documents.Count -ge $docIds.Count) "documents rows exist for uploaded video"
|
||||||
|
Assert-Check (@($documents | Where-Object { $_.MimeType -eq "video/mp4" -and $_.Size -gt 0 }).Count -ge 1) "uploaded video document metadata is persisted"
|
||||||
|
if (-not $AllowMissingThumb) {
|
||||||
|
Assert-Check (@($documents | Where-Object { $_.ThumbCount -gt 0 }).Count -ge 1) "uploaded video document has thumbnail metadata"
|
||||||
|
}
|
||||||
|
Assert-Check (@($blobs | Where-Object { $_.LocationKey -like "doc:*" -and $_.LocationKey -notlike "*:m" -and $_.MimeType -eq "video/mp4" -and $_.Size -gt 0 }).Count -ge 1) "video body file_blobs row exists"
|
||||||
|
if (-not $AllowMissingThumb) {
|
||||||
|
Assert-Check (@($blobs | Where-Object { $_.LocationKey -like "doc:*:m" -and $_.Size -gt 0 }).Count -ge 1) "video thumbnail file_blobs row exists"
|
||||||
|
}
|
||||||
|
foreach ($blob in $blobs) {
|
||||||
|
if ($blob.Backend -eq "localfs" -and $blob.ObjectKey) {
|
||||||
|
$path = Get-BlobFilePath $blob.ObjectKey
|
||||||
|
Assert-Check (Test-Path -LiteralPath $path) "localfs blob exists: $($blob.LocationKey)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$usage = Get-UploadPartUsage
|
||||||
|
Write-Host "upload_parts after send: parts=$($usage.Parts) bytes=$($usage.Bytes)"
|
||||||
|
if ([int]$state.BaselineUploadParts -eq 0) {
|
||||||
|
Assert-Check ($usage.Parts -eq 0) "upload_parts cleaned after successful upload"
|
||||||
|
} else {
|
||||||
|
Assert-Check ($usage.Parts -le [int]$state.BaselineUploadParts) "upload_parts did not grow after successful upload"
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = @(Get-LogLinesSince (Get-EffectiveLogSkip $state))
|
||||||
|
$savePartHits = @($lines | Where-Object {
|
||||||
|
($_ -like "*upload.saveFilePart*" -or $_ -like "*upload.saveBigFilePart*") -and $_ -like '*client_type": "android"*'
|
||||||
|
})
|
||||||
|
$sendMediaHits = @($lines | Where-Object {
|
||||||
|
$_ -like "*messages.sendMedia*" -and $_ -like '*client_type": "android"*'
|
||||||
|
})
|
||||||
|
$bad = @($lines | Where-Object {
|
||||||
|
$_ -cmatch "INTERNAL_SERVER_ERROR|rpc error|Unhandled RPC|NOT_IMPLEMENTED|bad_msg|panic|\tERROR\t"
|
||||||
|
})
|
||||||
|
Assert-Check ($savePartHits.Count -ge 1) "server log has Android upload.saveFilePart/saveBigFilePart"
|
||||||
|
Assert-Check ($sendMediaHits.Count -ge 1) "server log has Android messages.sendMedia"
|
||||||
|
Assert-Check ($bad.Count -eq 0) "server log has no upload-era internal errors or unhandled RPCs"
|
||||||
|
|
||||||
|
if (-not $SkipAdb) {
|
||||||
|
$logcat = Invoke-Adb @("logcat", "-d", "-t", "1200") -AllowFailure
|
||||||
|
if ($logcat.ExitCode -eq 0) {
|
||||||
|
$androidErrors = @($logcat.Output -split "`r?`n" | Where-Object {
|
||||||
|
$_ -match "INTERNAL_SERVER_ERROR|rpc error 500|saveFilePart|saveBigFilePart|FileUploadOperation"
|
||||||
|
})
|
||||||
|
if ($androidErrors.Count -gt 0) {
|
||||||
|
Write-Host "Recent Android upload log lines:"
|
||||||
|
$androidErrors | Select-Object -Last 40 | ForEach-Object { Write-Host $_ }
|
||||||
|
}
|
||||||
|
$fatalAndroidErrors = @($androidErrors | Where-Object { $_ -match "INTERNAL_SERVER_ERROR|rpc error 500" })
|
||||||
|
Assert-Check ($fatalAndroidErrors.Count -eq 0) "recent Android logcat has no upload 500"
|
||||||
|
} else {
|
||||||
|
Write-Warn "adb logcat scan failed: $($logcat.Output)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Finish-Run {
|
||||||
|
if ($Failures.Count -gt 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Validation failed:"
|
||||||
|
foreach ($failure in $Failures) {
|
||||||
|
Write-Host " - $failure"
|
||||||
|
}
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Validation passed."
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($Phase) {
|
||||||
|
"Preflight" { Run-Preflight }
|
||||||
|
"Prepare" { Run-Prepare }
|
||||||
|
"BeforeSend" { Run-BeforeSend }
|
||||||
|
"AfterSend" { Run-AfterSend }
|
||||||
|
"All" {
|
||||||
|
Run-Prepare
|
||||||
|
Run-BeforeSend
|
||||||
|
Read-Host "Send the prepared video from Android/Alice to Bob, then press Enter"
|
||||||
|
Run-AfterSend
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Finish-Run
|
||||||
Loading…
Add table
Add a link
Reference in a new issue