Skip to content

Commit

Permalink
runfix: conversation admin mutes a single call participant [WPB-5724] (
Browse files Browse the repository at this point in the history
…#16397)

* feat: add recipients data to calling remote mute event

* runfix: use qualified user clients map for targets field

* runfix: ignore mute events from non-admin users

* chore: add todo comment

* feat: only targeted clients are being muted

* refactor: procesds call event

* refactor: process calling message

* runfix: pass message to avs by default

* runfxi: qualified conversation field

* refactor: proccess calling message

* test: receive remote mute event
  • Loading branch information
PatrykBuniX authored Dec 18, 2023
1 parent 47c05f7 commit 07763e3
Show file tree
Hide file tree
Showing 5 changed files with 271 additions and 45 deletions.
145 changes: 143 additions & 2 deletions src/script/calling/CallingRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
*
*/

import {ConversationProtocol, CONVERSATION_TYPE} from '@wireapp/api-client/lib/conversation';
import {
ConversationProtocol,
CONVERSATION_TYPE,
DefaultConversationRoleName,
} from '@wireapp/api-client/lib/conversation';
import {QualifiedId} from '@wireapp/api-client/lib/user';
import 'jsdom-worker';
import ko, {Subscription} from 'knockout';
Expand All @@ -28,7 +32,7 @@ import {Runtime} from '@wireapp/commons';

import {Call} from 'src/script/calling/Call';
import {CallingRepository} from 'src/script/calling/CallingRepository';
import {CallState} from 'src/script/calling/CallState';
import {CallState, MuteState} from 'src/script/calling/CallState';
import {Participant} from 'src/script/calling/Participant';
import {Conversation} from 'src/script/entity/Conversation';
import {User} from 'src/script/entity/User';
Expand All @@ -41,6 +45,7 @@ import {createUuid} from 'Util/uuid';
import {CALL_MESSAGE_TYPE} from './enum/CallMessageType';
import {LEAVE_CALL_REASON} from './enum/LeaveCallReason';

import {CallingEvent} from '../event/CallingEvent';
import {CALL} from '../event/Client';
import {MediaDevicesHandler} from '../media/MediaDevicesHandler';
import {Core} from '../service/CoreSingleton';
Expand Down Expand Up @@ -104,6 +109,142 @@ describe('CallingRepository', () => {
return wCall && wCall.destroy(wUser);
});

describe('onCallEvent', () => {
it('does mute itself when remote muted message arrives', async () => {
const conversation = createConversation();
const selfParticipant = createSelfParticipant();
const senderUserId = {domain: 'senderdomain', id: 'senderid'};
const selfUserId = callingRepository['selfUser']?.qualifiedId!;
const selfClientId = callingRepository['selfClientId']!;
const call = new Call(
selfUserId,
conversation.qualifiedId,
CONV_TYPE.CONFERENCE,
selfParticipant,
CALL_TYPE.NORMAL,
{
currentAvailableDeviceId: mediaDevices,
} as unknown as MediaDevicesHandler,
);

conversation.roles({[senderUserId.id]: DefaultConversationRoleName.WIRE_ADMIN});

callingRepository['conversationState'].conversations.push(conversation);
spyOn(callingRepository, 'findCall').and.returnValue(call);
spyOn(callingRepository, 'muteCall').and.callThrough();
spyOn(wCall, 'recvMsg').and.callThrough();

const event: CallingEvent = {
content: {
type: CALL_MESSAGE_TYPE.REMOTE_MUTE,
version: '',
data: {targets: {[selfUserId.domain]: {[selfUserId.id]: [selfClientId]}}},
},
conversation: conversation.id,
from: senderUserId.id,
qualified_from: senderUserId,
sender: 'test',
time: new Date().toISOString(),
type: CALL.E_CALL,
qualified_conversation: conversation.qualifiedId,
};

await callingRepository.onCallEvent(event, EventRepository.SOURCE.WEB_SOCKET);
expect(callingRepository.muteCall).toHaveBeenCalledWith(call, true, MuteState.REMOTE_MUTED);
expect(wCall.recvMsg).toHaveBeenCalled();
});

it('should not mute itself when remote muted message arrives but the event sender is not an admin', async () => {
const conversation = createConversation();
const selfParticipant = createSelfParticipant();
const senderUserId = {domain: 'senderdomain', id: 'senderid'};
const selfUserId = callingRepository['selfUser']?.qualifiedId!;
const selfClientId = callingRepository['selfClientId']!;
const call = new Call(
selfUserId,
conversation.qualifiedId,
CONV_TYPE.CONFERENCE,
selfParticipant,
CALL_TYPE.NORMAL,
{
currentAvailableDeviceId: mediaDevices,
} as unknown as MediaDevicesHandler,
);

conversation.roles({[senderUserId.id]: DefaultConversationRoleName.WIRE_MEMBER});

callingRepository['conversationState'].conversations.push(conversation);
spyOn(callingRepository, 'findCall').and.returnValue(call);
spyOn(callingRepository, 'muteCall').and.callThrough();
spyOn(wCall, 'recvMsg').and.callThrough();

const event: CallingEvent = {
content: {
type: CALL_MESSAGE_TYPE.REMOTE_MUTE,
version: '',
data: {targets: {[selfUserId.domain]: {[selfUserId.id]: [selfClientId]}}},
},
conversation: conversation.id,
from: senderUserId.id,
qualified_from: senderUserId,
sender: 'test',
time: new Date().toISOString(),
type: CALL.E_CALL,
qualified_conversation: conversation.qualifiedId,
};

await callingRepository.onCallEvent(event, EventRepository.SOURCE.WEB_SOCKET);
expect(callingRepository.muteCall).not.toHaveBeenCalled();
expect(wCall.recvMsg).not.toHaveBeenCalled();
});

it('should not mute itself when remote muted message arrives but client was not included in targets list', async () => {
const conversation = createConversation();
const selfParticipant = createSelfParticipant();
const senderUserId = {domain: 'senderdomain', id: 'senderid'};
const selfUserId = callingRepository['selfUser']?.qualifiedId!;

const call = new Call(
selfUserId,
conversation.qualifiedId,
CONV_TYPE.CONFERENCE,
selfParticipant,
CALL_TYPE.NORMAL,
{
currentAvailableDeviceId: mediaDevices,
} as unknown as MediaDevicesHandler,
);

conversation.roles({[senderUserId.id]: DefaultConversationRoleName.WIRE_ADMIN});

callingRepository['conversationState'].conversations.push(conversation);
spyOn(callingRepository, 'findCall').and.returnValue(call);
spyOn(callingRepository, 'muteCall').and.callThrough();
spyOn(wCall, 'recvMsg').and.callThrough();

const someOtherClientId = 'some-other-client';

const event: CallingEvent = {
content: {
type: CALL_MESSAGE_TYPE.REMOTE_MUTE,
version: '',
data: {targets: {[selfUserId.domain]: {[selfUserId.id]: [someOtherClientId]}}},
},
conversation: conversation.id,
from: senderUserId.id,
qualified_from: senderUserId,
sender: 'test',
time: new Date().toISOString(),
type: CALL.E_CALL,
qualified_conversation: conversation.qualifiedId,
};

await callingRepository.onCallEvent(event, EventRepository.SOURCE.WEB_SOCKET);
expect(callingRepository.muteCall).not.toHaveBeenCalled();
expect(wCall.recvMsg).not.toHaveBeenCalled();
});
});

describe('startCall', () => {
it.each([ConversationProtocol.PROTEUS, ConversationProtocol.MLS])(
'starts a ONEONONE call for proteus or MLS 1:1 conversation',
Expand Down
85 changes: 63 additions & 22 deletions src/script/calling/CallingRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,12 @@ import {Config} from '../Config';
import {isGroupMLSConversation, isMLSConversation, MLSConversation} from '../conversation/ConversationSelectors';
import {ConversationState} from '../conversation/ConversationState';
import {ConversationVerificationState} from '../conversation/ConversationVerificationState';
import {CallingEvent, EventBuilder} from '../conversation/EventBuilder';
import {EventBuilder} from '../conversation/EventBuilder';
import {CONSENT_TYPE, MessageRepository, MessageSendingOptions} from '../conversation/MessageRepository';
import {Conversation} from '../entity/Conversation';
import type {User} from '../entity/User';
import {NoAudioInputError} from '../error/NoAudioInputError';
import {CallingEvent} from '../event/CallingEvent';
import {EventRepository} from '../event/EventRepository';
import {EventSource} from '../event/EventSource';
import type {MediaDevicesHandler} from '../media/MediaDevicesHandler';
Expand Down Expand Up @@ -630,20 +631,9 @@ export class CallingRepository {
* Handle incoming calling events from backend.
*/
onCallEvent = async (event: CallingEvent, source: string): Promise<void> => {
const {
content,
qualified_conversation,
from,
qualified_from,
sender: clientId,
time = new Date().toISOString(),
senderClientId: senderFullyQualifiedClientId = '',
} = event;
const {content, qualified_conversation, from, qualified_from} = event;
const isFederated = this.core.backendFeatures.isFederated && qualified_conversation && qualified_from;
const userId = isFederated ? qualified_from : {domain: '', id: from};
const currentTimestamp = this.serverTimeHandler.toServerTimestamp();
const toSecond = (timestamp: number) => Math.floor(timestamp / 1000);
const contentStr = JSON.stringify(content);
const conversationId = this.extractTargetedConversationId(event);
const conversation = this.getConversationById(conversationId);

Expand All @@ -667,20 +657,65 @@ export class CallingRepository {
this.abortCall(conversationId, LEAVE_CALL_REASON.ABORTED_BECAUSE_FAILED_TO_UPDATE_MISSING_CLIENTS);
}
}
break;
return this.processCallingMessage(conversation, event);
}

case CALL_MESSAGE_TYPE.REMOTE_MUTE: {
const call = this.findCall(conversationId);
if (call) {
this.muteCall(call, true, MuteState.REMOTE_MUTED);
if (!call) {
return;
}
break;

const isSenderAdmin = conversation.isAdmin(userId);
if (!isSenderAdmin) {
return;
}

const selfUserId = this.selfUser?.qualifiedId;
const selfClientId = this.selfClientId;

if (!selfUserId || !selfClientId) {
return;
}

const isSelfClientTargetted =
!!content.data.targets[selfUserId.domain]?.[selfUserId.id]?.includes(selfClientId);

if (!isSelfClientTargetted) {
return;
}

this.muteCall(call, true, MuteState.REMOTE_MUTED);
return this.processCallingMessage(conversation, event);
}

case CALL_MESSAGE_TYPE.REMOTE_KICK: {
this.leaveCall(conversationId, LEAVE_CALL_REASON.REMOTE_KICK);
break;
return this.processCallingMessage(conversation, event);
}

default: {
return this.processCallingMessage(conversation, event);
}
}
};

private readonly processCallingMessage = (conversation: Conversation, event: CallingEvent): void => {
const {
content,
time = new Date().toISOString(),
qualified_conversation,
from,
qualified_from,
sender: clientId,
senderClientId: senderFullyQualifiedClientId = '',
} = event;
const contentStr = JSON.stringify(content);
const currentTimestamp = this.serverTimeHandler.toServerTimestamp();
const toSecond = (timestamp: number) => Math.floor(timestamp / 1000);

const isFederated = this.core.backendFeatures.isFederated && qualified_conversation && qualified_from;
const userId = isFederated ? qualified_from : {domain: '', id: from};

let senderClientId = '';
if (senderFullyQualifiedClientId) {
Expand All @@ -693,7 +728,7 @@ export class CallingRepository {
contentStr.length,
toSecond(currentTimestamp),
toSecond(new Date(time).getTime()),
this.serializeQualifiedId(conversationId),
this.serializeQualifiedId(conversation.qualifiedId),
this.serializeQualifiedId(userId),
conversation && isMLSConversation(conversation) ? senderClientId : clientId,
conversation && isGroupMLSConversation(conversation) ? CONV_TYPE.CONFERENCE_MLS : CONV_TYPE.CONFERENCE,
Expand All @@ -702,11 +737,13 @@ export class CallingRepository {
if (res !== 0) {
this.logger.warn(`recv_msg failed with code: ${res}`);
if (
this.callState.acceptedVersionWarnings().every(acceptedId => !matchQualifiedIds(acceptedId, conversationId)) &&
this.callState
.acceptedVersionWarnings()
.every(acceptedId => !matchQualifiedIds(acceptedId, conversation.qualifiedId)) &&
res === ERROR.UNKNOWN_PROTOCOL &&
event.content.type === 'CONFSTART'
) {
this.warnOutdatedClient(conversationId);
this.warnOutdatedClient(conversation.qualifiedId);
}
}
};
Expand Down Expand Up @@ -1272,7 +1309,11 @@ export class CallingRepository {

readonly sendModeratorMute = (conversationId: QualifiedId, participants: Participant[]) => {
const recipients = this.convertParticipantsToCallingMessageRecepients(participants);
this.sendCallingMessage(conversationId, {type: CALL_MESSAGE_TYPE.REMOTE_MUTE}, {nativePush: true, recipients});
this.sendCallingMessage(
conversationId,
{type: CALL_MESSAGE_TYPE.REMOTE_MUTE, data: {targets: recipients}},
{nativePush: true, recipients},
);
};

readonly sendModeratorKick = (conversationId: QualifiedId, participants: Participant[]) => {
Expand Down
22 changes: 1 addition & 21 deletions src/script/conversation/EventBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,10 @@ import type {Asset, LegalHoldStatus} from '@wireapp/protocol-messaging';
import {createUuid} from 'Util/uuid';

import {AssetTransferState} from '../assets/AssetTransferState';
import {CALL_MESSAGE_TYPE} from '../calling/enum/CallMessageType';
import type {Conversation} from '../entity/Conversation';
import type {Message} from '../entity/message/Message';
import type {User} from '../entity/User';
import {CALL, ClientEvent, CONVERSATION} from '../event/Client';
import {ClientEvent, CONVERSATION} from '../event/Client';
import {E2EIVerificationMessageType} from '../message/E2EIVerificationMessageType';
import {StatusType} from '../message/StatusType';
import {VerificationMessageType} from '../message/VerificationMessageType';
Expand All @@ -61,25 +60,6 @@ export interface ConversationEvent<Type extends CONVERSATION | CONVERSATION_EVEN
type: Type;
}

export interface CallingEvent {
/**
* content is an object that comes from avs
*/
content: {
type: CALL_MESSAGE_TYPE;
version: string;
};
targetConversation?: QualifiedId;
conversation: string;
from: string;
qualified_conversation?: QualifiedId;
qualified_from?: QualifiedId;
sender: string;
time?: string;
type: CALL;
senderClientId?: string;
}

export interface BackendEventMessage<Type, Data> extends Omit<BaseEvent, 'id'> {
data: Data;
id?: string;
Expand Down
5 changes: 5 additions & 0 deletions src/script/entity/Conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
CONVERSATION_ACCESS,
CONVERSATION_LEGACY_ACCESS_ROLE,
CONVERSATION_TYPE,
DefaultConversationRoleName,
} from '@wireapp/api-client/lib/conversation/';
import {RECEIPT_MODE} from '@wireapp/api-client/lib/conversation/data';
import {ConversationProtocol} from '@wireapp/api-client/lib/conversation/NewConversation';
Expand Down Expand Up @@ -609,6 +610,10 @@ export class Conversation {
}
}

public isAdmin(userId: QualifiedId) {
return this.roles()[userId.id] === DefaultConversationRoleName.WIRE_ADMIN;
}

/**
* Set the timestamp of a given type.
* @note This will only increment timestamps
Expand Down
Loading

0 comments on commit 07763e3

Please sign in to comment.