Message receiving refactor: handleDataMessage onwards
This commit is contained in:
parent
38f64cf172
commit
8ca7b8cfb4
|
@ -31,6 +31,7 @@ test/test.js
|
|||
# React / TypeScript
|
||||
ts/**/*.js
|
||||
ts/protobuf/*.d.ts
|
||||
ts/**/*.js.map
|
||||
|
||||
# Swapfiles
|
||||
**/*.swp
|
||||
|
|
228
js/background.js
228
js/background.js
|
@ -1564,7 +1564,10 @@
|
|||
mySignalingKey,
|
||||
options
|
||||
);
|
||||
messageReceiver.addEventListener('message', onMessageReceived);
|
||||
messageReceiver.addEventListener(
|
||||
'message',
|
||||
window.NewReceiver.handleMessageEvent
|
||||
);
|
||||
messageReceiver.addEventListener('contact', onContactReceived);
|
||||
window.textsecure.messaging = new textsecure.MessageSender(
|
||||
USERNAME,
|
||||
|
@ -1581,11 +1584,17 @@
|
|||
mySignalingKey,
|
||||
options
|
||||
);
|
||||
messageReceiver.addEventListener('message', onMessageReceived);
|
||||
messageReceiver.addEventListener(
|
||||
'message',
|
||||
window.NewReceiver.handleMessageEvent
|
||||
);
|
||||
messageReceiver.addEventListener('delivery', onDeliveryReceipt);
|
||||
messageReceiver.addEventListener('contact', onContactReceived);
|
||||
messageReceiver.addEventListener('group', onGroupReceived);
|
||||
messageReceiver.addEventListener('sent', onSentMessage);
|
||||
messageReceiver.addEventListener(
|
||||
'sent',
|
||||
window.NewReceiver.handleMessageEvent
|
||||
);
|
||||
messageReceiver.addEventListener('readSync', onReadSync);
|
||||
messageReceiver.addEventListener('read', onReadReceipt);
|
||||
messageReceiver.addEventListener('verified', onVerified);
|
||||
|
@ -1597,7 +1606,7 @@
|
|||
messageReceiver.addEventListener('typing', onTyping);
|
||||
|
||||
Whisper.events.on('endSession', source => {
|
||||
messageReceiver.handleEndSession(source);
|
||||
window.NewReceiver.handleEndSession(source);
|
||||
});
|
||||
|
||||
window.Signal.AttachmentDownloads.start({
|
||||
|
@ -2003,182 +2012,8 @@
|
|||
ev.confirm();
|
||||
}
|
||||
|
||||
// Descriptors
|
||||
const getGroupDescriptor = group => ({
|
||||
type: Message.GROUP,
|
||||
id: group.id,
|
||||
});
|
||||
|
||||
// Matches event data from `libtextsecure` `MessageReceiver::handleSentMessage`:
|
||||
const getDescriptorForSent = ({ message, destination }) =>
|
||||
message.group
|
||||
? getGroupDescriptor(message.group)
|
||||
: { type: Message.PRIVATE, id: destination };
|
||||
|
||||
// Matches event data from `libtextsecure` `MessageReceiver::handleDataMessage`:
|
||||
const getDescriptorForReceived = ({ message, source }) =>
|
||||
message.group
|
||||
? getGroupDescriptor(message.group)
|
||||
: { type: Message.PRIVATE, id: source };
|
||||
|
||||
function createMessageHandler({
|
||||
createMessage,
|
||||
getMessageDescriptor,
|
||||
handleProfileUpdate,
|
||||
}) {
|
||||
return async event => {
|
||||
const { data, confirm } = event;
|
||||
if (!data) {
|
||||
window.log.warn('Invalid data passed to createMessageHandler.', event);
|
||||
return confirm();
|
||||
}
|
||||
|
||||
const messageDescriptor = getMessageDescriptor(data);
|
||||
|
||||
// Funnel messages to primary device conversation if multi-device
|
||||
const authorisation = await libloki.storage.getGrantAuthorisationForSecondaryPubKey(
|
||||
messageDescriptor.id
|
||||
);
|
||||
if (authorisation) {
|
||||
messageDescriptor.id = authorisation.primaryDevicePubKey;
|
||||
}
|
||||
|
||||
const { PROFILE_KEY_UPDATE } = textsecure.protobuf.DataMessage.Flags;
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const isProfileUpdate = Boolean(data.message.flags & PROFILE_KEY_UPDATE);
|
||||
if (isProfileUpdate) {
|
||||
return handleProfileUpdate({ data, confirm, messageDescriptor });
|
||||
}
|
||||
|
||||
const descriptorId = await textsecure.MessageReceiver.arrayBufferToString(
|
||||
messageDescriptor.id
|
||||
);
|
||||
const message = await createMessage(data);
|
||||
|
||||
const isDuplicate = await isMessageDuplicate(message);
|
||||
if (isDuplicate) {
|
||||
// RSS expects duplicates, so squelch log
|
||||
if (!descriptorId.match(/^rss:/)) {
|
||||
window.log.warn('Received duplicate message', message.idForLogging());
|
||||
}
|
||||
return confirm();
|
||||
}
|
||||
|
||||
await ConversationController.getOrCreateAndWait(
|
||||
messageDescriptor.id,
|
||||
messageDescriptor.type
|
||||
);
|
||||
return message.handleDataMessage(data.message, confirm, {
|
||||
initialLoadComplete,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Received:
|
||||
async function handleMessageReceivedProfileUpdate({
|
||||
data,
|
||||
confirm,
|
||||
messageDescriptor,
|
||||
}) {
|
||||
const profileKey = data.message.profileKey.toString('base64');
|
||||
const sender = await ConversationController.getOrCreateAndWait(
|
||||
messageDescriptor.id,
|
||||
'private'
|
||||
);
|
||||
|
||||
// Will do the save for us
|
||||
await sender.setProfileKey(profileKey);
|
||||
|
||||
return confirm();
|
||||
}
|
||||
|
||||
const onMessageReceived = createMessageHandler({
|
||||
handleProfileUpdate: handleMessageReceivedProfileUpdate,
|
||||
getMessageDescriptor: getDescriptorForReceived,
|
||||
createMessage: initIncomingMessage,
|
||||
});
|
||||
|
||||
// Sent:
|
||||
async function handleMessageSentProfileUpdate({
|
||||
data,
|
||||
confirm,
|
||||
messageDescriptor,
|
||||
}) {
|
||||
// First set profileSharing = true for the conversation we sent to
|
||||
const { id, type } = messageDescriptor;
|
||||
const conversation = await ConversationController.getOrCreateAndWait(
|
||||
id,
|
||||
type
|
||||
);
|
||||
|
||||
conversation.set({ profileSharing: true });
|
||||
await window.Signal.Data.updateConversation(id, conversation.attributes, {
|
||||
Conversation: Whisper.Conversation,
|
||||
});
|
||||
|
||||
// Then we update our own profileKey if it's different from what we have
|
||||
const ourNumber = textsecure.storage.user.getNumber();
|
||||
const profileKey = data.message.profileKey.toString('base64');
|
||||
const me = await ConversationController.getOrCreate(ourNumber, 'private');
|
||||
|
||||
// Will do the save for us if needed
|
||||
await me.setProfileKey(profileKey);
|
||||
|
||||
return confirm();
|
||||
}
|
||||
|
||||
function createSentMessage(data) {
|
||||
const now = Date.now();
|
||||
let sentTo = [];
|
||||
|
||||
if (data.unidentifiedStatus && data.unidentifiedStatus.length) {
|
||||
sentTo = data.unidentifiedStatus.map(item => item.destination);
|
||||
const unidentified = _.filter(data.unidentifiedStatus, item =>
|
||||
Boolean(item.unidentified)
|
||||
);
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
data.unidentifiedDeliveries = unidentified.map(item => item.destination);
|
||||
}
|
||||
|
||||
return new Whisper.Message({
|
||||
source: textsecure.storage.user.getNumber(),
|
||||
sourceDevice: data.sourceDevice,
|
||||
sent_at: data.timestamp,
|
||||
sent_to: sentTo,
|
||||
received_at: data.isPublic ? data.receivedAt : now,
|
||||
conversationId: data.destination,
|
||||
type: 'outgoing',
|
||||
sent: true,
|
||||
unidentifiedDeliveries: data.unidentifiedDeliveries || [],
|
||||
expirationStartTimestamp: Math.min(
|
||||
data.expirationStartTimestamp || data.timestamp || Date.now(),
|
||||
Date.now()
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const onSentMessage = createMessageHandler({
|
||||
handleProfileUpdate: handleMessageSentProfileUpdate,
|
||||
getMessageDescriptor: getDescriptorForSent,
|
||||
createMessage: createSentMessage,
|
||||
});
|
||||
|
||||
async function isMessageDuplicate(message) {
|
||||
try {
|
||||
const { attributes } = message;
|
||||
const result = await window.Signal.Data.getMessageBySender(attributes, {
|
||||
Message: Whisper.Message,
|
||||
});
|
||||
|
||||
return Boolean(result);
|
||||
} catch (error) {
|
||||
window.log.error('isMessageDuplicate error:', Errors.toLogFormat(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function initIncomingMessage(data, options = {}) {
|
||||
const { isError } = options;
|
||||
async function initIncomingMessage(data) {
|
||||
// Now this function is only called for errors, so no delivery receipts
|
||||
|
||||
let messageData = {
|
||||
source: data.source,
|
||||
|
@ -2205,35 +2040,6 @@
|
|||
|
||||
const message = new Whisper.Message(messageData);
|
||||
|
||||
// Send a delivery receipt
|
||||
// If we don't return early here, we can get into infinite error loops. So, no delivery receipts for sealed sender errors.
|
||||
// Note(LOKI): don't send receipt for FR as we don't have a session yet
|
||||
const isGroup = data && data.message && data.message.group;
|
||||
const shouldSendReceipt =
|
||||
!isError &&
|
||||
data.unidentifiedDeliveryReceived &&
|
||||
!data.friendRequest &&
|
||||
!isGroup;
|
||||
|
||||
// Send the receipt async and hope that it succeeds
|
||||
if (shouldSendReceipt) {
|
||||
const { wrap, sendOptions } = ConversationController.prepareForSend(
|
||||
data.source
|
||||
);
|
||||
wrap(
|
||||
textsecure.messaging.sendDeliveryReceipt(
|
||||
data.source,
|
||||
data.timestamp,
|
||||
sendOptions
|
||||
)
|
||||
).catch(error => {
|
||||
window.log.error(
|
||||
`Failed to send delivery receipt to ${data.source} for message ${data.timestamp}:`,
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
|
@ -2365,7 +2171,9 @@
|
|||
return;
|
||||
}
|
||||
const envelope = ev.proto;
|
||||
const message = await initIncomingMessage(envelope, { isError: true });
|
||||
|
||||
// TODO: see if we could reuse the one in receiver.ts
|
||||
const message = await initIncomingMessage(envelope);
|
||||
|
||||
await message.saveErrors(error || new Error('Error was null'));
|
||||
const id = message.get('conversationId');
|
||||
|
|
|
@ -12,7 +12,6 @@
|
|||
Whisper,
|
||||
clipboard,
|
||||
libloki,
|
||||
lokiFileServerAPI,
|
||||
*/
|
||||
|
||||
/* eslint-disable more/no-then */
|
||||
|
@ -23,7 +22,7 @@
|
|||
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
||||
const { Message: TypedMessage, Contact, PhoneNumber, Errors } = Signal.Types;
|
||||
const { Message: TypedMessage, Contact, PhoneNumber } = Signal.Types;
|
||||
|
||||
const {
|
||||
deleteExternalMessageFiles,
|
||||
|
@ -31,7 +30,6 @@
|
|||
loadAttachmentData,
|
||||
loadQuoteData,
|
||||
loadPreviewData,
|
||||
upgradeMessageSchema,
|
||||
} = window.Signal.Migrations;
|
||||
const { bytesFromString } = window.Signal.Crypto;
|
||||
|
||||
|
@ -1683,7 +1681,7 @@
|
|||
});
|
||||
errors = errors.concat(this.get('errors') || []);
|
||||
|
||||
if (this.isEndSession) {
|
||||
if (this.isEndSession()) {
|
||||
this.set({ endSessionType: 'failed' });
|
||||
}
|
||||
|
||||
|
@ -1703,965 +1701,6 @@
|
|||
);
|
||||
return !!error;
|
||||
},
|
||||
async queueAttachmentDownloads() {
|
||||
const messageId = this.id;
|
||||
let count = 0;
|
||||
let bodyPending;
|
||||
|
||||
const [longMessageAttachments, normalAttachments] = _.partition(
|
||||
this.get('attachments') || [],
|
||||
attachment =>
|
||||
attachment.contentType === Whisper.Message.LONG_MESSAGE_CONTENT_TYPE
|
||||
);
|
||||
|
||||
if (longMessageAttachments.length > 1) {
|
||||
window.log.error(
|
||||
`Received more than one long message attachment in message ${this.idForLogging()}`
|
||||
);
|
||||
}
|
||||
if (longMessageAttachments.length > 0) {
|
||||
count += 1;
|
||||
bodyPending = true;
|
||||
await window.Signal.AttachmentDownloads.addJob(
|
||||
longMessageAttachments[0],
|
||||
{
|
||||
messageId,
|
||||
type: 'long-message',
|
||||
index: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const attachments = await Promise.all(
|
||||
normalAttachments.map((attachment, index) => {
|
||||
count += 1;
|
||||
return window.Signal.AttachmentDownloads.addJob(attachment, {
|
||||
messageId,
|
||||
type: 'attachment',
|
||||
index,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const preview = await Promise.all(
|
||||
(this.get('preview') || []).map(async (item, index) => {
|
||||
if (!item.image) {
|
||||
return item;
|
||||
}
|
||||
|
||||
count += 1;
|
||||
return {
|
||||
...item,
|
||||
image: await window.Signal.AttachmentDownloads.addJob(item.image, {
|
||||
messageId,
|
||||
type: 'preview',
|
||||
index,
|
||||
}),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const contact = await Promise.all(
|
||||
(this.get('contact') || []).map(async (item, index) => {
|
||||
if (!item.avatar || !item.avatar.avatar) {
|
||||
return item;
|
||||
}
|
||||
|
||||
count += 1;
|
||||
return {
|
||||
...item,
|
||||
avatar: {
|
||||
...item.avatar,
|
||||
avatar: await window.Signal.AttachmentDownloads.addJob(
|
||||
item.avatar.avatar,
|
||||
{
|
||||
messageId,
|
||||
type: 'contact',
|
||||
index,
|
||||
}
|
||||
),
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
let quote = this.get('quote');
|
||||
if (quote && quote.attachments && quote.attachments.length) {
|
||||
quote = {
|
||||
...quote,
|
||||
attachments: await Promise.all(
|
||||
(quote.attachments || []).map(async (item, index) => {
|
||||
// If we already have a path, then we copied this image from the quoted
|
||||
// message and we don't need to download the attachment.
|
||||
if (!item.thumbnail || item.thumbnail.path) {
|
||||
return item;
|
||||
}
|
||||
|
||||
count += 1;
|
||||
return {
|
||||
...item,
|
||||
thumbnail: await window.Signal.AttachmentDownloads.addJob(
|
||||
item.thumbnail,
|
||||
{
|
||||
messageId,
|
||||
type: 'quote',
|
||||
index,
|
||||
}
|
||||
),
|
||||
};
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let group = this.get('group');
|
||||
if (group && group.avatar) {
|
||||
group = {
|
||||
...group,
|
||||
avatar: await window.Signal.AttachmentDownloads.addJob(group.avatar, {
|
||||
messageId,
|
||||
type: 'group-avatar',
|
||||
index: 0,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
this.set({ bodyPending, attachments, preview, contact, quote, group });
|
||||
|
||||
await window.Signal.Data.saveMessage(this.attributes, {
|
||||
Message: Whisper.Message,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
async copyFromQuotedMessage(message, attemptCount = 1) {
|
||||
const { quote } = message;
|
||||
if (!quote) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const { attachments, id, author } = quote;
|
||||
const firstAttachment = attachments[0];
|
||||
|
||||
const collection = await window.Signal.Data.getMessagesBySentAt(id, {
|
||||
MessageCollection: Whisper.MessageCollection,
|
||||
});
|
||||
const found = collection.find(item => {
|
||||
const messageAuthor = item.getContact();
|
||||
|
||||
return messageAuthor && author === messageAuthor.id;
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
// Exponential backoff, giving up after 5 attempts:
|
||||
if (attemptCount < 5) {
|
||||
setTimeout(() => {
|
||||
window.log.info(
|
||||
`Looking for the message id : ${id}, attempt: ${attemptCount + 1}`
|
||||
);
|
||||
this.copyFromQuotedMessage(message, attemptCount + 1);
|
||||
}, attemptCount * attemptCount * 500);
|
||||
}
|
||||
|
||||
quote.referencedMessageNotFound = true;
|
||||
return message;
|
||||
}
|
||||
|
||||
window.log.info(`Found quoted message id: ${id}`);
|
||||
quote.referencedMessageNotFound = false;
|
||||
|
||||
const queryMessage = MessageController.register(found.id, found);
|
||||
quote.text = queryMessage.get('body');
|
||||
|
||||
if (attemptCount > 1) {
|
||||
// Normally the caller would save the message, but in case we are
|
||||
// called by a timer, we need to update the message manually
|
||||
this.set({ quote });
|
||||
await window.Signal.Data.saveMessage(this.attributes, {
|
||||
Message: Whisper.Message,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (firstAttachment) {
|
||||
firstAttachment.thumbnail = null;
|
||||
}
|
||||
|
||||
if (
|
||||
!firstAttachment ||
|
||||
(!window.Signal.Util.GoogleChrome.isImageTypeSupported(
|
||||
firstAttachment.contentType
|
||||
) &&
|
||||
!window.Signal.Util.GoogleChrome.isVideoTypeSupported(
|
||||
firstAttachment.contentType
|
||||
))
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
queryMessage.get('schemaVersion') <
|
||||
TypedMessage.VERSION_NEEDED_FOR_DISPLAY
|
||||
) {
|
||||
const upgradedMessage = await upgradeMessageSchema(
|
||||
queryMessage.attributes
|
||||
);
|
||||
queryMessage.set(upgradedMessage);
|
||||
await window.Signal.Data.saveMessage(upgradedMessage, {
|
||||
Message: Whisper.Message,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
window.log.error(
|
||||
'Problem upgrading message quoted message from database',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
return message;
|
||||
}
|
||||
|
||||
const queryAttachments = queryMessage.get('attachments') || [];
|
||||
|
||||
if (queryAttachments.length > 0) {
|
||||
const queryFirst = queryAttachments[0];
|
||||
const { thumbnail } = queryFirst;
|
||||
|
||||
if (thumbnail && thumbnail.path) {
|
||||
firstAttachment.thumbnail = {
|
||||
...thumbnail,
|
||||
copied: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const queryPreview = queryMessage.get('preview') || [];
|
||||
if (queryPreview.length > 0) {
|
||||
const queryFirst = queryPreview[0];
|
||||
const { image } = queryFirst;
|
||||
|
||||
if (image && image.path) {
|
||||
firstAttachment.thumbnail = {
|
||||
...image,
|
||||
copied: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
},
|
||||
|
||||
async handleSecondaryDeviceFriendRequest(pubKey) {
|
||||
// fetch the device mapping from the server
|
||||
const deviceMapping = await lokiFileServerAPI.getUserDeviceMapping(
|
||||
pubKey
|
||||
);
|
||||
if (!deviceMapping) {
|
||||
return false;
|
||||
}
|
||||
// Only handle secondary pubkeys
|
||||
if (deviceMapping.isPrimary === '1' || !deviceMapping.authorisations) {
|
||||
return false;
|
||||
}
|
||||
const { authorisations } = deviceMapping;
|
||||
// Secondary devices should only have 1 authorisation from a primary device
|
||||
if (authorisations.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
const authorisation = authorisations[0];
|
||||
if (!authorisation) {
|
||||
return false;
|
||||
}
|
||||
if (!authorisation.grantSignature) {
|
||||
return false;
|
||||
}
|
||||
const isValid = await libloki.crypto.validateAuthorisation(authorisation);
|
||||
if (!isValid) {
|
||||
return false;
|
||||
}
|
||||
const correctSender = pubKey === authorisation.secondaryDevicePubKey;
|
||||
if (!correctSender) {
|
||||
return false;
|
||||
}
|
||||
const { primaryDevicePubKey } = authorisation;
|
||||
// ensure the primary device is a friend
|
||||
const c = window.ConversationController.get(primaryDevicePubKey);
|
||||
if (!c || !(await c.isFriendWithAnyDevice())) {
|
||||
return false;
|
||||
}
|
||||
await libloki.storage.savePairingAuthorisation(authorisation);
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns true if the message is already completely handled and confirmed
|
||||
* and the processing of this message must stop.
|
||||
*/
|
||||
handleGroupMessage(source, initialMessage, primarySource, confirm) {
|
||||
const conversationId = initialMessage.group.id;
|
||||
const conversation = ConversationController.get(conversationId);
|
||||
const GROUP_TYPES = textsecure.protobuf.GroupContext.Type;
|
||||
|
||||
if (this.shouldIgnoreBlockedGroup(initialMessage, source)) {
|
||||
window.log.warn(`Message ignored; destined for blocked group`);
|
||||
confirm();
|
||||
return true;
|
||||
}
|
||||
|
||||
// NOTE: we use friends status to tell if this is
|
||||
// the creation of the group (initial update)
|
||||
const newGroup = !conversation.isFriend();
|
||||
const knownMembers = conversation.get('members');
|
||||
|
||||
if (!newGroup && knownMembers) {
|
||||
const fromMember = knownMembers.includes(primarySource);
|
||||
// if the group exists and we have its members,
|
||||
// we must drop a message from anyone else than the existing members.
|
||||
if (!fromMember) {
|
||||
window.log.warn(
|
||||
`Ignoring group message from non-member: ${primarySource}`
|
||||
);
|
||||
confirm();
|
||||
// returning true drops the message
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (initialMessage.group.type === GROUP_TYPES.REQUEST_INFO && !newGroup) {
|
||||
libloki.api.debug.logGroupRequestInfo(
|
||||
`Received GROUP_TYPES.REQUEST_INFO from source: ${source}, primarySource: ${primarySource}, sending back group info.`
|
||||
);
|
||||
conversation.sendGroupInfo([source]);
|
||||
confirm();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
initialMessage.group.members &&
|
||||
initialMessage.group.type === GROUP_TYPES.UPDATE
|
||||
) {
|
||||
if (newGroup) {
|
||||
conversation.updateGroupAdmins(initialMessage.group.admins);
|
||||
|
||||
conversation.setFriendRequestStatus(
|
||||
window.friends.friendRequestStatusEnum.friends
|
||||
);
|
||||
} else {
|
||||
// be sure to drop a message from a non admin if it tries to change group members
|
||||
// or change the group name
|
||||
const fromAdmin = conversation
|
||||
.get('groupAdmins')
|
||||
.includes(primarySource);
|
||||
|
||||
if (!fromAdmin) {
|
||||
// Make sure the message is not removing members / renaming the group
|
||||
const nameChanged =
|
||||
conversation.get('name') !== initialMessage.group.name;
|
||||
|
||||
if (nameChanged) {
|
||||
window.log.warn(
|
||||
'Non-admin attempts to change the name of the group'
|
||||
);
|
||||
}
|
||||
|
||||
const membersMissing =
|
||||
_.difference(
|
||||
conversation.get('members'),
|
||||
initialMessage.group.members
|
||||
).length > 0;
|
||||
|
||||
if (membersMissing) {
|
||||
window.log.warn('Non-admin attempts to remove group members');
|
||||
}
|
||||
|
||||
const messageAllowed = !nameChanged && !membersMissing;
|
||||
|
||||
// Returning true drops the message
|
||||
if (!messageAllowed) {
|
||||
confirm();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// send a session request for all the members we do not have a session with
|
||||
window.libloki.api.sendSessionRequestsToMembers(
|
||||
initialMessage.group.members
|
||||
);
|
||||
} else if (newGroup) {
|
||||
// We have an unknown group, we should request info from the sender
|
||||
textsecure.messaging.requestGroupInfo(conversationId, [primarySource]);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
async handleSessionRequest(source, confirm) {
|
||||
window.console.log(`Received SESSION_REQUEST from source: ${source}`);
|
||||
window.libloki.api.sendSessionEstablishedMessage(source);
|
||||
confirm();
|
||||
},
|
||||
isGroupBlocked(groupId) {
|
||||
return textsecure.storage.get('blocked-groups', []).indexOf(groupId) >= 0;
|
||||
},
|
||||
shouldIgnoreBlockedGroup(message, senderPubKey) {
|
||||
const groupId = message.group && message.group.id;
|
||||
const isBlocked = this.isGroupBlocked(groupId);
|
||||
const isLeavingGroup = Boolean(
|
||||
message.group &&
|
||||
message.group.type === textsecure.protobuf.GroupContext.Type.QUIT
|
||||
);
|
||||
|
||||
const primaryDevicePubKey = window.storage.get('primaryDevicePubKey');
|
||||
const isMe =
|
||||
senderPubKey === textsecure.storage.user.getNumber() ||
|
||||
senderPubKey === primaryDevicePubKey;
|
||||
|
||||
return groupId && isBlocked && !(isMe && isLeavingGroup);
|
||||
},
|
||||
|
||||
async handleAutoFriendRequestMessage(
|
||||
source,
|
||||
ourPubKey,
|
||||
conversation,
|
||||
confirm
|
||||
) {
|
||||
const isMe = source === ourPubKey;
|
||||
// If we got a friend request message (session request excluded) or
|
||||
// if we're not friends with the current user that sent this private message
|
||||
// Check to see if we need to auto accept their friend request
|
||||
if (isMe) {
|
||||
window.log.info('refusing to add a friend request to ourselves');
|
||||
throw new Error('Cannot add a friend request for ourselves!');
|
||||
} else {
|
||||
// auto-accept friend request if the device is paired to one of our friend's primary device
|
||||
const shouldAutoAcceptFR = await this.handleSecondaryDeviceFriendRequest(
|
||||
source
|
||||
);
|
||||
if (shouldAutoAcceptFR) {
|
||||
libloki.api.debug.logAutoFriendRequest(
|
||||
`Received AUTO_FRIEND_REQUEST from source: ${source}`
|
||||
);
|
||||
// Directly setting friend request status to skip the pending state
|
||||
await conversation.setFriendRequestStatus(
|
||||
window.friends.friendRequestStatusEnum.friends
|
||||
);
|
||||
// sending a message back = accepting friend request
|
||||
|
||||
window.libloki.api.sendBackgroundMessage(
|
||||
source,
|
||||
window.textsecure.OutgoingMessage.DebugMessageType.AUTO_FR_ACCEPT
|
||||
);
|
||||
confirm();
|
||||
// return true to notify the message is fully processed
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
async handleDataMessage(initialMessage, confirm) {
|
||||
// This function is called from the background script in a few scenarios:
|
||||
// 1. on an incoming message
|
||||
// 2. on a sent message sync'd from another device
|
||||
// 3. in rare cases, an incoming message can be retried, though it will
|
||||
// still go through one of the previous two codepaths
|
||||
const ourNumber = textsecure.storage.user.getNumber();
|
||||
const message = this;
|
||||
const source = message.get('source');
|
||||
let conversationId = message.get('conversationId');
|
||||
const authorisation = await libloki.storage.getGrantAuthorisationForSecondaryPubKey(
|
||||
source
|
||||
);
|
||||
const primarySource =
|
||||
(authorisation && authorisation.primaryDevicePubKey) || source;
|
||||
const isGroupMessage = !!initialMessage.group;
|
||||
if (isGroupMessage) {
|
||||
/* handle one part of the group logic here:
|
||||
handle requesting info of a new group,
|
||||
dropping an admin only update from a non admin, ...
|
||||
*/
|
||||
conversationId = initialMessage.group.id;
|
||||
const shouldReturn = this.handleGroupMessage(
|
||||
source,
|
||||
initialMessage,
|
||||
primarySource,
|
||||
confirm
|
||||
);
|
||||
|
||||
// handleGroupMessage() can process fully a message in some cases
|
||||
// so we need to return early if that's the case
|
||||
if (shouldReturn) {
|
||||
return null;
|
||||
}
|
||||
} else if (source !== ourNumber && authorisation) {
|
||||
// Ignore auth from our devices
|
||||
conversationId = authorisation.primaryDevicePubKey;
|
||||
}
|
||||
|
||||
// the conversation with the primary device of that source (can be the same as conversationOrigin)
|
||||
const conversationPrimary = ConversationController.get(conversationId);
|
||||
// the conversation with this real device
|
||||
const conversationOrigin = ConversationController.get(source);
|
||||
|
||||
if (
|
||||
// eslint-disable-next-line no-bitwise
|
||||
initialMessage.flags &
|
||||
textsecure.protobuf.DataMessage.Flags.SESSION_RESTORE
|
||||
) {
|
||||
// Show that the session reset is "in progress" even though we had a valid session
|
||||
this.set({ endSessionType: 'ongoing' });
|
||||
}
|
||||
/**
|
||||
* A session request message is a friend-request message with the flag
|
||||
* SESSION_REQUEST set to true.
|
||||
*/
|
||||
const sessionRequestFlag =
|
||||
textsecure.protobuf.DataMessage.Flags.SESSION_REQUEST;
|
||||
/* eslint-disable no-bitwise */
|
||||
if (
|
||||
message.isFriendRequest() &&
|
||||
!!(initialMessage.flags & sessionRequestFlag)
|
||||
) {
|
||||
await this.handleSessionRequest(source, confirm);
|
||||
|
||||
// Wether or not we accepted the FR, we exit early so session requests
|
||||
// cannot be used for establishing regular private conversations
|
||||
return null;
|
||||
}
|
||||
/* eslint-enable no-bitwise */
|
||||
|
||||
// Session request have been dealt with before, so a friend request here is
|
||||
// not a session request message. Also, handleAutoFriendRequestMessage() only handles the autoAccept logic of an auto friend request.
|
||||
if (
|
||||
message.isFriendRequest() ||
|
||||
(!isGroupMessage && !conversationOrigin.isFriend())
|
||||
) {
|
||||
const shouldReturn = await this.handleAutoFriendRequestMessage(
|
||||
source,
|
||||
ourNumber,
|
||||
conversationOrigin,
|
||||
confirm
|
||||
);
|
||||
// handleAutoFriendRequestMessage can process fully a message in some cases
|
||||
// so we need to return early if that's the case
|
||||
if (shouldReturn) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return conversationPrimary.queueJob(async () => {
|
||||
window.log.info(
|
||||
`Starting handleDataMessage for message ${message.idForLogging()} in conversation ${conversationPrimary.idForLogging()}`
|
||||
);
|
||||
const GROUP_TYPES = textsecure.protobuf.GroupContext.Type;
|
||||
const type = message.get('type');
|
||||
|
||||
const withQuoteReference = await this.copyFromQuotedMessage(
|
||||
initialMessage
|
||||
);
|
||||
const dataMessage = await upgradeMessageSchema(withQuoteReference);
|
||||
|
||||
try {
|
||||
const now = new Date().getTime();
|
||||
let attributes = {
|
||||
...conversationPrimary.attributes,
|
||||
};
|
||||
|
||||
if (dataMessage.group) {
|
||||
let groupUpdate = null;
|
||||
attributes = {
|
||||
...attributes,
|
||||
type: 'group',
|
||||
groupId: dataMessage.group.id,
|
||||
};
|
||||
if (dataMessage.group.type === GROUP_TYPES.UPDATE) {
|
||||
attributes = {
|
||||
...attributes,
|
||||
name: dataMessage.group.name,
|
||||
members: dataMessage.group.members,
|
||||
};
|
||||
|
||||
groupUpdate =
|
||||
conversationPrimary.changedAttributes(
|
||||
_.pick(dataMessage.group, 'name', 'avatar')
|
||||
) || {};
|
||||
|
||||
const addedMembers = _.difference(
|
||||
attributes.members,
|
||||
conversationPrimary.get('members')
|
||||
);
|
||||
if (addedMembers.length > 0) {
|
||||
groupUpdate.joined = addedMembers;
|
||||
}
|
||||
if (conversationPrimary.get('left')) {
|
||||
// TODO: Maybe we shouldn't assume this message adds us:
|
||||
// we could maybe still get this message by mistake
|
||||
window.log.warn('re-added to a left group');
|
||||
attributes.left = false;
|
||||
}
|
||||
|
||||
if (attributes.isKickedFromGroup) {
|
||||
// Assume somebody re-invited us since we received this update
|
||||
attributes.isKickedFromGroup = false;
|
||||
}
|
||||
|
||||
// Check if anyone got kicked:
|
||||
const removedMembers = _.difference(
|
||||
conversationPrimary.get('members'),
|
||||
attributes.members
|
||||
);
|
||||
|
||||
if (removedMembers.length > 0) {
|
||||
if (
|
||||
removedMembers.includes(textsecure.storage.user.getNumber())
|
||||
) {
|
||||
groupUpdate.kicked = 'You';
|
||||
attributes.isKickedFromGroup = true;
|
||||
} else {
|
||||
groupUpdate.kicked = removedMembers;
|
||||
}
|
||||
}
|
||||
} else if (dataMessage.group.type === GROUP_TYPES.QUIT) {
|
||||
if (source === textsecure.storage.user.getNumber()) {
|
||||
attributes.left = true;
|
||||
groupUpdate = { left: 'You' };
|
||||
} else {
|
||||
groupUpdate = { left: source };
|
||||
}
|
||||
attributes.members = _.without(
|
||||
conversationPrimary.get('members'),
|
||||
source
|
||||
);
|
||||
}
|
||||
|
||||
if (groupUpdate !== null) {
|
||||
message.set({ group_update: groupUpdate });
|
||||
}
|
||||
}
|
||||
|
||||
if (initialMessage.groupInvitation) {
|
||||
message.set({ groupInvitation: initialMessage.groupInvitation });
|
||||
}
|
||||
|
||||
const urls = window.Signal.LinkPreviews.findLinks(dataMessage.body);
|
||||
const incomingPreview = dataMessage.preview || [];
|
||||
const preview = incomingPreview.filter(
|
||||
item =>
|
||||
(item.image || item.title) &&
|
||||
urls.includes(item.url) &&
|
||||
window.Signal.LinkPreviews.isLinkInWhitelist(item.url)
|
||||
);
|
||||
if (preview.length < incomingPreview.length) {
|
||||
window.log.info(
|
||||
`${message.idForLogging()}: Eliminated ${preview.length -
|
||||
incomingPreview.length} previews with invalid urls'`
|
||||
);
|
||||
}
|
||||
|
||||
message.set({
|
||||
attachments: dataMessage.attachments,
|
||||
body: dataMessage.body,
|
||||
contact: dataMessage.contact,
|
||||
conversationId: conversationPrimary.id,
|
||||
decrypted_at: now,
|
||||
errors: [],
|
||||
flags: dataMessage.flags,
|
||||
hasAttachments: dataMessage.hasAttachments,
|
||||
hasFileAttachments: dataMessage.hasFileAttachments,
|
||||
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
|
||||
quote: dataMessage.quote,
|
||||
preview,
|
||||
schemaVersion: dataMessage.schemaVersion,
|
||||
});
|
||||
|
||||
if (type === 'outgoing') {
|
||||
const receipts = Whisper.DeliveryReceipts.forMessage(
|
||||
conversationPrimary,
|
||||
message
|
||||
);
|
||||
receipts.forEach(receipt =>
|
||||
message.set({
|
||||
delivered: (message.get('delivered') || 0) + 1,
|
||||
delivered_to: _.union(message.get('delivered_to') || [], [
|
||||
receipt.get('source'),
|
||||
]),
|
||||
})
|
||||
);
|
||||
}
|
||||
attributes.active_at = now;
|
||||
conversationPrimary.set(attributes);
|
||||
|
||||
// Re-enable typing if re-joined the group
|
||||
conversationPrimary.updateTextInputState();
|
||||
|
||||
if (message.isExpirationTimerUpdate()) {
|
||||
message.set({
|
||||
expirationTimerUpdate: {
|
||||
source,
|
||||
expireTimer: dataMessage.expireTimer,
|
||||
},
|
||||
});
|
||||
conversationPrimary.set({ expireTimer: dataMessage.expireTimer });
|
||||
} else if (dataMessage.expireTimer) {
|
||||
message.set({ expireTimer: dataMessage.expireTimer });
|
||||
}
|
||||
|
||||
// NOTE: Remove once the above uses
|
||||
// `Conversation::updateExpirationTimer`:
|
||||
const { expireTimer } = dataMessage;
|
||||
const shouldLogExpireTimerChange =
|
||||
message.isExpirationTimerUpdate() || expireTimer;
|
||||
if (shouldLogExpireTimerChange) {
|
||||
window.log.info("Update conversation 'expireTimer'", {
|
||||
id: conversationPrimary.idForLogging(),
|
||||
expireTimer,
|
||||
source: 'handleDataMessage',
|
||||
});
|
||||
}
|
||||
|
||||
if (!message.isEndSession()) {
|
||||
if (dataMessage.expireTimer) {
|
||||
if (
|
||||
dataMessage.expireTimer !==
|
||||
conversationPrimary.get('expireTimer')
|
||||
) {
|
||||
conversationPrimary.updateExpirationTimer(
|
||||
dataMessage.expireTimer,
|
||||
source,
|
||||
message.get('received_at'),
|
||||
{
|
||||
fromGroupUpdate: message.isGroupUpdate(),
|
||||
}
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
conversationPrimary.get('expireTimer') &&
|
||||
// We only turn off timers if it's not a group update
|
||||
!message.isGroupUpdate()
|
||||
) {
|
||||
conversationPrimary.updateExpirationTimer(
|
||||
null,
|
||||
source,
|
||||
message.get('received_at')
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const endSessionType = conversationPrimary.isSessionResetReceived()
|
||||
? 'ongoing'
|
||||
: 'done';
|
||||
this.set({ endSessionType });
|
||||
}
|
||||
if (type === 'incoming' || message.isFriendRequest()) {
|
||||
const readSync = Whisper.ReadSyncs.forMessage(message);
|
||||
if (readSync) {
|
||||
if (
|
||||
message.get('expireTimer') &&
|
||||
!message.get('expirationStartTimestamp')
|
||||
) {
|
||||
message.set(
|
||||
'expirationStartTimestamp',
|
||||
Math.min(readSync.get('read_at'), Date.now())
|
||||
);
|
||||
}
|
||||
}
|
||||
if (readSync || message.isExpirationTimerUpdate()) {
|
||||
message.unset('unread');
|
||||
// This is primarily to allow the conversation to mark all older
|
||||
// messages as read, as is done when we receive a read sync for
|
||||
// a message we already know about.
|
||||
const c = message.getConversation();
|
||||
if (c) {
|
||||
c.onReadMessage(message);
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
message.attributes.body &&
|
||||
message.attributes.body.indexOf(`@${ourNumber}`) !== -1
|
||||
) {
|
||||
conversationPrimary.set({ mentionedUs: true });
|
||||
}
|
||||
|
||||
conversationPrimary.set({
|
||||
unreadCount: conversationPrimary.get('unreadCount') + 1,
|
||||
isArchived: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'outgoing') {
|
||||
const reads = Whisper.ReadReceipts.forMessage(
|
||||
conversationPrimary,
|
||||
message
|
||||
);
|
||||
if (reads.length) {
|
||||
const readBy = reads.map(receipt => receipt.get('reader'));
|
||||
message.set({
|
||||
read_by: _.union(message.get('read_by'), readBy),
|
||||
});
|
||||
}
|
||||
|
||||
// A sync'd message to ourself is automatically considered read and delivered
|
||||
if (conversationPrimary.isMe()) {
|
||||
message.set({
|
||||
read_by: conversationPrimary.getRecipients(),
|
||||
delivered_to: conversationPrimary.getRecipients(),
|
||||
});
|
||||
}
|
||||
|
||||
message.set({ recipients: conversationPrimary.getRecipients() });
|
||||
}
|
||||
|
||||
const conversationTimestamp = conversationPrimary.get('timestamp');
|
||||
if (
|
||||
!conversationTimestamp ||
|
||||
message.get('sent_at') > conversationTimestamp
|
||||
) {
|
||||
conversationPrimary.lastMessage = message.getNotificationText();
|
||||
conversationPrimary.set({
|
||||
timestamp: message.get('sent_at'),
|
||||
});
|
||||
}
|
||||
|
||||
if (dataMessage.profileKey) {
|
||||
const profileKey = dataMessage.profileKey.toString('base64');
|
||||
if (source === textsecure.storage.user.getNumber()) {
|
||||
conversationPrimary.set({ profileSharing: true });
|
||||
} else if (conversationPrimary.isPrivate()) {
|
||||
conversationPrimary.setProfileKey(profileKey);
|
||||
} else {
|
||||
conversationOrigin.setProfileKey(profileKey);
|
||||
}
|
||||
}
|
||||
|
||||
let autoAccept = false;
|
||||
// Make sure friend request logic doesn't trigger on messages aimed at groups
|
||||
if (!isGroupMessage) {
|
||||
// We already handled (and returned) session request and auto Friend Request before,
|
||||
// so that can only be a normal Friend Request
|
||||
if (message.isFriendRequest()) {
|
||||
/*
|
||||
Here is the before and after state diagram for the operation before.
|
||||
|
||||
None -> RequestReceived
|
||||
PendingSend -> RequestReceived
|
||||
RequestReceived -> RequestReceived
|
||||
Sent -> Friends
|
||||
Expired -> Friends
|
||||
Friends -> Friends
|
||||
|
||||
The cases where we auto accept are the following:
|
||||
- We sent the user a friend request,
|
||||
and that user sent us a friend request.
|
||||
- We are friends with the user,
|
||||
and that user just sent us a friend request.
|
||||
*/
|
||||
|
||||
const isFriend = conversationOrigin.isFriend();
|
||||
const hasSentFriendRequest = conversationOrigin.hasSentFriendRequest();
|
||||
autoAccept = isFriend || hasSentFriendRequest;
|
||||
|
||||
if (autoAccept) {
|
||||
message.set({ friendStatus: 'accepted' });
|
||||
}
|
||||
|
||||
libloki.api.debug.logNormalFriendRequest(
|
||||
`Received a NORMAL_FRIEND_REQUEST from source: ${source}, primarySource: ${primarySource}, isAlreadyFriend: ${isFriend}, didWeAlreadySentFR: ${hasSentFriendRequest}`
|
||||
);
|
||||
|
||||
if (isFriend) {
|
||||
window.Whisper.events.trigger('endSession', source);
|
||||
} else if (hasSentFriendRequest) {
|
||||
await conversationOrigin.onFriendRequestAccepted();
|
||||
} else {
|
||||
await conversationOrigin.onFriendRequestReceived();
|
||||
}
|
||||
} else if (message.get('type') !== 'outgoing') {
|
||||
// Ignore 'outgoing' messages because they are sync messages
|
||||
await conversationOrigin.onFriendRequestAccepted();
|
||||
}
|
||||
}
|
||||
|
||||
// We need to map the original message source to the primary device
|
||||
if (source !== ourNumber) {
|
||||
message.set({ source: primarySource });
|
||||
}
|
||||
|
||||
const id = await window.Signal.Data.saveMessage(message.attributes, {
|
||||
Message: Whisper.Message,
|
||||
});
|
||||
message.set({ id });
|
||||
MessageController.register(message.id, message);
|
||||
|
||||
// Note that this can save the message again, if jobs were queued. We need to
|
||||
// call it after we have an id for this message, because the jobs refer back
|
||||
// to their source message.
|
||||
await message.queueAttachmentDownloads();
|
||||
|
||||
await window.Signal.Data.updateConversation(
|
||||
conversationId,
|
||||
conversationPrimary.attributes,
|
||||
{ Conversation: Whisper.Conversation }
|
||||
);
|
||||
|
||||
conversationPrimary.trigger('newmessage', message);
|
||||
|
||||
try {
|
||||
// We go to the database here because, between the message save above and
|
||||
// the previous line's trigger() call, we might have marked all messages
|
||||
// unread in the database. This message might already be read!
|
||||
const fetched = await window.Signal.Data.getMessageById(
|
||||
message.get('id'),
|
||||
{
|
||||
Message: Whisper.Message,
|
||||
}
|
||||
);
|
||||
const previousUnread = message.get('unread');
|
||||
|
||||
// Important to update message with latest read state from database
|
||||
message.merge(fetched);
|
||||
|
||||
if (previousUnread !== message.get('unread')) {
|
||||
window.log.warn(
|
||||
'Caught race condition on new message read state! ' +
|
||||
'Manually starting timers.'
|
||||
);
|
||||
// We call markRead() even though the message is already
|
||||
// marked read because we need to start expiration
|
||||
// timers, etc.
|
||||
message.markRead();
|
||||
}
|
||||
} catch (error) {
|
||||
window.log.warn(
|
||||
'handleDataMessage: Message',
|
||||
message.idForLogging(),
|
||||
'was deleted'
|
||||
);
|
||||
}
|
||||
|
||||
if (message.get('unread')) {
|
||||
// Need to do this here because the conversation has already changed states
|
||||
if (autoAccept) {
|
||||
await conversationPrimary.notifyFriendRequest(source, 'accepted');
|
||||
} else {
|
||||
await conversationPrimary.notify(message);
|
||||
}
|
||||
}
|
||||
|
||||
confirm();
|
||||
} catch (error) {
|
||||
const errorForLog = error && error.stack ? error.stack : error;
|
||||
window.log.error(
|
||||
'handleDataMessage',
|
||||
message.idForLogging(),
|
||||
'error:',
|
||||
errorForLog
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
},
|
||||
async markRead(readAt) {
|
||||
this.unset('unread');
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
/* global Whisper, Signal, setTimeout, clearTimeout, MessageController */
|
||||
/* global Whisper, Signal, setTimeout, clearTimeout, MessageController, NewReceiver */
|
||||
|
||||
const { isFunction, isNumber, omit } = require('lodash');
|
||||
const getGuid = require('uuid/v4');
|
||||
|
@ -168,7 +168,7 @@ async function _runJob(job) {
|
|||
}
|
||||
|
||||
try {
|
||||
downloaded = await messageReceiver.downloadAttachment(attachment);
|
||||
downloaded = await NewReceiver.downloadAttachment(attachment);
|
||||
} catch (error) {
|
||||
// Attachments on the server expire after 30 days, then start returning 404
|
||||
if (error && error.code === 404) {
|
||||
|
|
|
@ -421,20 +421,19 @@
|
|||
this.model.id
|
||||
);
|
||||
|
||||
const allMembers = await Promise.all(
|
||||
allPubKeys.map(async pubKey => {
|
||||
const conv = ConversationController.get(pubKey);
|
||||
let profileName = 'Anonymous';
|
||||
if (conv) {
|
||||
profileName = await conv.getProfileName();
|
||||
}
|
||||
return {
|
||||
id: pubKey,
|
||||
authorPhoneNumber: pubKey,
|
||||
authorProfileName: profileName,
|
||||
};
|
||||
})
|
||||
);
|
||||
const allMembers = allPubKeys.map(pubKey => {
|
||||
const conv = ConversationController.get(pubKey);
|
||||
let profileName = 'Anonymous';
|
||||
if (conv) {
|
||||
profileName = conv.getProfileName();
|
||||
}
|
||||
return {
|
||||
id: pubKey,
|
||||
authorPhoneNumber: pubKey,
|
||||
authorProfileName: profileName,
|
||||
};
|
||||
});
|
||||
|
||||
window.lokiPublicChatAPI.setListOfMembers(allMembers);
|
||||
};
|
||||
|
||||
|
@ -1295,6 +1294,7 @@
|
|||
}
|
||||
},
|
||||
|
||||
// THIS DOES NOT DOWNLOAD ANYTHING!
|
||||
downloadAttachment({ attachment, message, isDangerous }) {
|
||||
if (isDangerous) {
|
||||
const toast = new Whisper.DangerousFileTypeToast();
|
||||
|
|
|
@ -15,10 +15,8 @@
|
|||
/* global lokiMessageAPI: false */
|
||||
/* global feeds: false */
|
||||
/* global Whisper: false */
|
||||
/* global lokiFileServerAPI: false */
|
||||
/* global WebAPI: false */
|
||||
/* global ConversationController: false */
|
||||
/* global Signal: false */
|
||||
/* global log: false */
|
||||
/* global libsession: false */
|
||||
|
||||
|
@ -27,6 +25,17 @@
|
|||
|
||||
let openGroupBound = false;
|
||||
|
||||
// TODO: remove this when no longer needed here
|
||||
function getEnvelopeId(envelope) {
|
||||
if (envelope.source) {
|
||||
return `${envelope.source}.${
|
||||
envelope.sourceDevice
|
||||
} ${envelope.timestamp.toNumber()} (${envelope.id})`;
|
||||
}
|
||||
|
||||
return envelope.id;
|
||||
}
|
||||
|
||||
function MessageReceiver(username, password, signalingKey, options = {}) {
|
||||
this.count = 0;
|
||||
|
||||
|
@ -115,7 +124,7 @@ MessageReceiver.prototype.extend({
|
|||
message.source,
|
||||
'private'
|
||||
);
|
||||
await this.updateProfile(
|
||||
await window.NewReceiver.updateProfile(
|
||||
conversation,
|
||||
message.message.profile,
|
||||
message.message.profileKey
|
||||
|
@ -194,7 +203,6 @@ MessageReceiver.prototype.extend({
|
|||
},
|
||||
|
||||
handleRequest(request, options) {
|
||||
const { onSuccess, onFailure } = options;
|
||||
this.incoming = this.incoming || [];
|
||||
const lastPromise = _.last(this.incoming);
|
||||
|
||||
|
@ -254,7 +262,7 @@ MessageReceiver.prototype.extend({
|
|||
|
||||
// To ensure that we queue in the same order we receive messages
|
||||
await lastPromise;
|
||||
this.queueEnvelope(envelope, onSuccess, onFailure);
|
||||
this.queueEnvelope(envelope);
|
||||
},
|
||||
error => {
|
||||
request.respond(500, 'Failed to cache message');
|
||||
|
@ -431,15 +439,6 @@ MessageReceiver.prototype.extend({
|
|||
}
|
||||
}
|
||||
},
|
||||
getEnvelopeId(envelope) {
|
||||
if (envelope.source) {
|
||||
return `${envelope.source}.${
|
||||
envelope.sourceDevice
|
||||
} ${envelope.timestamp.toNumber()} (${envelope.id})`;
|
||||
}
|
||||
|
||||
return envelope.id;
|
||||
},
|
||||
async getAllFromCache() {
|
||||
window.log.info('getAllFromCache');
|
||||
const count = await textsecure.storage.unprocessed.getCount();
|
||||
|
@ -533,7 +532,7 @@ MessageReceiver.prototype.extend({
|
|||
return textsecure.storage.unprocessed.remove(id);
|
||||
},
|
||||
queueDecryptedEnvelope(envelope, plaintext) {
|
||||
const id = this.getEnvelopeId(envelope);
|
||||
const id = getEnvelopeId(envelope);
|
||||
window.log.info('queueing decrypted envelope', id);
|
||||
|
||||
const task = this.handleDecryptedEnvelope.bind(this, envelope, plaintext);
|
||||
|
@ -550,8 +549,8 @@ MessageReceiver.prototype.extend({
|
|||
);
|
||||
});
|
||||
},
|
||||
queueEnvelope(envelope, onSuccess = null, onFailure = null) {
|
||||
const id = this.getEnvelopeId(envelope);
|
||||
queueEnvelope(envelope) {
|
||||
const id = getEnvelopeId(envelope);
|
||||
window.log.info('queueing envelope', id);
|
||||
|
||||
const task = this.handleEnvelope.bind(this, envelope);
|
||||
|
@ -560,11 +559,6 @@ MessageReceiver.prototype.extend({
|
|||
`queueEnvelope ${id}`
|
||||
);
|
||||
const promise = this.addToQueue(taskWithTimeout);
|
||||
promise.then(() => {
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
});
|
||||
|
||||
return promise.catch(error => {
|
||||
window.log.error(
|
||||
|
@ -573,9 +567,6 @@ MessageReceiver.prototype.extend({
|
|||
':',
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
if (onFailure) {
|
||||
onFailure();
|
||||
}
|
||||
});
|
||||
},
|
||||
// Same as handleEnvelope, just without the decryption step. Necessary for handling
|
||||
|
@ -608,9 +599,7 @@ MessageReceiver.prototype.extend({
|
|||
if (envelope.content) {
|
||||
return this.handleContentMessage(envelope);
|
||||
}
|
||||
if (envelope.legacyMessage) {
|
||||
return this.handleLegacyMessage(envelope);
|
||||
}
|
||||
|
||||
this.removeFromCache(envelope);
|
||||
throw new Error('Received message with no content and no legacyMessage');
|
||||
},
|
||||
|
@ -736,7 +725,7 @@ MessageReceiver.prototype.extend({
|
|||
|
||||
switch (envelope.type) {
|
||||
case textsecure.protobuf.Envelope.Type.CIPHERTEXT:
|
||||
window.log.info('message from', this.getEnvelopeId(envelope));
|
||||
window.log.info('message from', getEnvelopeId(envelope));
|
||||
promise = lokiSessionCipher
|
||||
.decryptWhisperMessage(ciphertext)
|
||||
.then(this.unpad);
|
||||
|
@ -757,7 +746,7 @@ MessageReceiver.prototype.extend({
|
|||
break;
|
||||
}
|
||||
case textsecure.protobuf.Envelope.Type.PREKEY_BUNDLE:
|
||||
window.log.info('prekey message from', this.getEnvelopeId(envelope));
|
||||
window.log.info('prekey message from', getEnvelopeId(envelope));
|
||||
promise = this.decryptPreKeyWhisperMessage(
|
||||
ciphertext,
|
||||
lokiSessionCipher,
|
||||
|
@ -927,37 +916,19 @@ MessageReceiver.prototype.extend({
|
|||
|
||||
// eslint-disable-next-line no-bitwise
|
||||
if (msg.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) {
|
||||
await this.handleEndSession(destination);
|
||||
await window.NewReceiver.handleEndSession(destination);
|
||||
}
|
||||
|
||||
// if (msg.mediumGroupUpdate) {
|
||||
// await this.handleMediumGroupUpdate(envelope, msg.mediumGroupUpdate);
|
||||
// return;
|
||||
// }
|
||||
if (msg.mediumGroupUpdate) {
|
||||
await window.NewReceiver.handleMediumGroupUpdate(
|
||||
envelope,
|
||||
msg.mediumGroupUpdate
|
||||
);
|
||||
}
|
||||
|
||||
const message = await this.processDecrypted(envelope, msg);
|
||||
const message = await window.NewReceiver.processDecrypted(envelope, msg);
|
||||
|
||||
const primaryDevicePubKey = window.storage.get('primaryDevicePubKey');
|
||||
// const groupId = message.group && message.group.id;
|
||||
// const isBlocked = this.isGroupBlocked(groupId);
|
||||
//
|
||||
// const isMe =
|
||||
// envelope.source === textsecure.storage.user.getNumber() ||
|
||||
// envelope.source === primaryDevicePubKey;
|
||||
// const isLeavingGroup = Boolean(
|
||||
// message.group &&
|
||||
// message.group.type === textsecure.protobuf.GroupContext.Type.QUIT
|
||||
// );
|
||||
|
||||
// if (groupId && isBlocked && !(isMe && isLeavingGroup)) {
|
||||
// window.log.warn(
|
||||
// `Message ${this.getEnvelopeId(
|
||||
// envelope
|
||||
// )} ignored; destined for blocked group`
|
||||
// );
|
||||
// this.removeFromCache(envelope);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// handle profileKey and avatar updates
|
||||
if (envelope.source === primaryDevicePubKey) {
|
||||
|
@ -966,7 +937,11 @@ MessageReceiver.prototype.extend({
|
|||
primaryDevicePubKey
|
||||
);
|
||||
if (profile) {
|
||||
this.updateProfile(primaryConversation, profile, profileKey);
|
||||
window.NewReceiver.updateProfile(
|
||||
primaryConversation,
|
||||
profile,
|
||||
profileKey
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1052,7 +1027,7 @@ MessageReceiver.prototype.extend({
|
|||
const ourNumber = window.storage.get('primaryDevicePubKey');
|
||||
const me = window.ConversationController.get(ourNumber);
|
||||
if (me) {
|
||||
this.updateProfile(me, profile, profileKey);
|
||||
window.NewReceiver.updateProfile(me, profile, profileKey);
|
||||
}
|
||||
}
|
||||
// Update contact list
|
||||
|
@ -1084,432 +1059,6 @@ MessageReceiver.prototype.extend({
|
|||
}
|
||||
return this.handlePairingRequest(envelope, pairingAuthorisation);
|
||||
},
|
||||
|
||||
async updateProfile(conversation, profile, profileKey) {
|
||||
// Retain old values unless changed:
|
||||
const newProfile = conversation.get('profile') || {};
|
||||
|
||||
newProfile.displayName = profile.displayName;
|
||||
|
||||
// TODO: may need to allow users to reset their avatars to null
|
||||
if (profile.avatar) {
|
||||
const prevPointer = conversation.get('avatarPointer');
|
||||
const needsUpdate =
|
||||
!prevPointer || !_.isEqual(prevPointer, profile.avatar);
|
||||
|
||||
if (needsUpdate) {
|
||||
conversation.set('avatarPointer', profile.avatar);
|
||||
conversation.set('profileKey', profileKey);
|
||||
|
||||
const downloaded = await this.downloadAttachment({
|
||||
url: profile.avatar,
|
||||
isRaw: true,
|
||||
});
|
||||
|
||||
// null => use jazzicon
|
||||
let path = null;
|
||||
if (profileKey) {
|
||||
// Convert profileKey to ArrayBuffer, if needed
|
||||
const encoding = typeof profileKey === 'string' ? 'base64' : null;
|
||||
try {
|
||||
const profileKeyArrayBuffer = dcodeIO.ByteBuffer.wrap(
|
||||
profileKey,
|
||||
encoding
|
||||
).toArrayBuffer();
|
||||
const decryptedData = await textsecure.crypto.decryptProfile(
|
||||
downloaded.data,
|
||||
profileKeyArrayBuffer
|
||||
);
|
||||
const upgraded = await Signal.Migrations.processNewAttachment({
|
||||
...downloaded,
|
||||
data: decryptedData,
|
||||
});
|
||||
({ path } = upgraded);
|
||||
} catch (e) {
|
||||
window.log.error(`Could not decrypt profile image: ${e}`);
|
||||
}
|
||||
}
|
||||
newProfile.avatar = path;
|
||||
}
|
||||
} else {
|
||||
newProfile.avatar = null;
|
||||
}
|
||||
|
||||
await conversation.setLokiProfile(newProfile);
|
||||
},
|
||||
async unpairingRequestIsLegit(source, ourPubKey) {
|
||||
const isSecondary = textsecure.storage.get('isSecondaryDevice');
|
||||
if (!isSecondary) {
|
||||
return false;
|
||||
}
|
||||
const primaryPubKey = window.storage.get('primaryDevicePubKey');
|
||||
// TODO: allow unpairing from any paired device?
|
||||
if (source !== primaryPubKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const primaryMapping = await lokiFileServerAPI.getUserDeviceMapping(
|
||||
primaryPubKey
|
||||
);
|
||||
|
||||
// If we don't have a mapping on the primary then we have been unlinked
|
||||
if (!primaryMapping) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// We expect the primary device to have updated its mapping
|
||||
// before sending the unpairing request
|
||||
const found = primaryMapping.authorisations.find(
|
||||
authorisation => authorisation.secondaryDevicePubKey === ourPubKey
|
||||
);
|
||||
|
||||
// our pubkey should NOT be in the primary device mapping
|
||||
return !found;
|
||||
},
|
||||
|
||||
async clearAppAndRestart() {
|
||||
// remove our device mapping annotations from file server
|
||||
await lokiFileServerAPI.clearOurDeviceMappingAnnotations();
|
||||
// Delete the account and restart
|
||||
try {
|
||||
await window.Signal.Logs.deleteAll();
|
||||
await window.Signal.Data.removeAll();
|
||||
await window.Signal.Data.close();
|
||||
await window.Signal.Data.removeDB();
|
||||
await window.Signal.Data.removeOtherData();
|
||||
// TODO generate an empty db with a flag
|
||||
// to display a message about the unpairing
|
||||
// after the app restarts
|
||||
} catch (error) {
|
||||
window.log.error(
|
||||
'Something went wrong deleting all data:',
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
}
|
||||
window.restart();
|
||||
},
|
||||
|
||||
async handleUnpairRequest(envelope, ourPubKey) {
|
||||
// TODO: move high-level pairing logic to libloki.multidevice.xx
|
||||
|
||||
const legit = await this.unpairingRequestIsLegit(
|
||||
envelope.source,
|
||||
ourPubKey
|
||||
);
|
||||
this.removeFromCache(envelope);
|
||||
if (legit) {
|
||||
await this.clearAppAndRestart();
|
||||
}
|
||||
},
|
||||
|
||||
async handleMediumGroupUpdate(envelope, groupUpdate) {
|
||||
const { type, groupId } = groupUpdate;
|
||||
|
||||
const ourIdentity = await textsecure.storage.user.getNumber();
|
||||
const senderIdentity = envelope.source;
|
||||
|
||||
if (
|
||||
type === textsecure.protobuf.MediumGroupUpdate.Type.SENDER_KEY_REQUEST
|
||||
) {
|
||||
log.debug('[sender key] sender key request from:', senderIdentity);
|
||||
|
||||
const proto = new textsecure.protobuf.DataMessage();
|
||||
|
||||
// We reuse the same message type for sender keys
|
||||
const update = new textsecure.protobuf.MediumGroupUpdate();
|
||||
|
||||
const { chainKey, keyIdx } = await window.SenderKeyAPI.getSenderKeys(
|
||||
groupId,
|
||||
ourIdentity
|
||||
);
|
||||
|
||||
update.type = textsecure.protobuf.MediumGroupUpdate.Type.SENDER_KEY;
|
||||
update.groupId = groupId;
|
||||
update.senderKey = new textsecure.protobuf.SenderKey({
|
||||
chainKey: StringView.arrayBufferToHex(chainKey),
|
||||
keyIdx,
|
||||
});
|
||||
|
||||
proto.mediumGroupUpdate = update;
|
||||
|
||||
textsecure.messaging.updateMediumGroup([senderIdentity], proto);
|
||||
|
||||
this.removeFromCache(envelope);
|
||||
} else if (type === textsecure.protobuf.MediumGroupUpdate.Type.SENDER_KEY) {
|
||||
const { senderKey } = groupUpdate;
|
||||
|
||||
log.debug('[sender key] got a new sender key from:', senderIdentity);
|
||||
|
||||
await window.SenderKeyAPI.saveSenderKeys(
|
||||
groupId,
|
||||
senderIdentity,
|
||||
senderKey.chainKey,
|
||||
senderKey.keyIdx
|
||||
);
|
||||
|
||||
this.removeFromCache(envelope);
|
||||
} else if (type === textsecure.protobuf.MediumGroupUpdate.Type.NEW_GROUP) {
|
||||
const maybeConvo = await window.ConversationController.get(groupId);
|
||||
const groupExists = !!maybeConvo;
|
||||
|
||||
const {
|
||||
members: membersBinary,
|
||||
groupSecretKey,
|
||||
groupName,
|
||||
senderKey,
|
||||
admins,
|
||||
} = groupUpdate;
|
||||
|
||||
const members = membersBinary.map(pk =>
|
||||
StringView.arrayBufferToHex(pk.toArrayBuffer())
|
||||
);
|
||||
|
||||
const convo = groupExists
|
||||
? maybeConvo
|
||||
: await window.ConversationController.getOrCreateAndWait(
|
||||
groupId,
|
||||
'group'
|
||||
);
|
||||
|
||||
{
|
||||
// Add group update message
|
||||
const now = Date.now();
|
||||
const message = convo.messageCollection.add({
|
||||
conversationId: convo.id,
|
||||
type: 'incoming',
|
||||
sent_at: now,
|
||||
received_at: now,
|
||||
group_update: {
|
||||
name: groupName,
|
||||
members,
|
||||
},
|
||||
});
|
||||
|
||||
const messageId = await window.Signal.Data.saveMessage(
|
||||
message.attributes,
|
||||
{
|
||||
Message: Whisper.Message,
|
||||
}
|
||||
);
|
||||
message.set({ id: messageId });
|
||||
}
|
||||
|
||||
if (groupExists) {
|
||||
// ***** Updating the group *****
|
||||
log.info('Received a group update for medium group:', groupId);
|
||||
|
||||
// Check that the sender is admin (make sure it words with multidevice)
|
||||
const isAdmin = convo.get('groupAdmins').includes(senderIdentity);
|
||||
|
||||
if (!isAdmin) {
|
||||
log.warn('Rejected attempt to update a group by non-admin');
|
||||
this.removeFromCache(envelope);
|
||||
return;
|
||||
}
|
||||
|
||||
convo.set('name', groupName);
|
||||
convo.set('members', members);
|
||||
|
||||
// TODO: check that we are still in the group (when we enable deleting members)
|
||||
convo.saveChangesToDB();
|
||||
|
||||
// Update other fields. Add a corresponding "update" message to the conversation
|
||||
} else {
|
||||
// ***** Creating a new group *****
|
||||
log.info('Received a new medium group:', groupId);
|
||||
|
||||
// TODO: Check that we are even a part of this group?
|
||||
|
||||
convo.set('is_medium_group', true);
|
||||
convo.set('active_at', Date.now());
|
||||
convo.set('name', groupName);
|
||||
convo.set('groupAdmins', admins);
|
||||
|
||||
convo.setFriendRequestStatus(
|
||||
window.friends.friendRequestStatusEnum.friends
|
||||
);
|
||||
|
||||
const secretKeyHex = StringView.arrayBufferToHex(
|
||||
groupSecretKey.toArrayBuffer()
|
||||
);
|
||||
|
||||
await window.Signal.Data.createOrUpdateIdentityKey({
|
||||
id: groupId,
|
||||
secretKey: secretKeyHex,
|
||||
});
|
||||
|
||||
// Save sender's key
|
||||
await window.SenderKeyAPI.saveSenderKeys(
|
||||
groupId,
|
||||
envelope.source,
|
||||
senderKey.chainKey,
|
||||
senderKey.keyIdx
|
||||
);
|
||||
|
||||
const ownSenderKey = await window.SenderKeyAPI.createSenderKeyForGroup(
|
||||
groupId,
|
||||
ourIdentity
|
||||
);
|
||||
|
||||
{
|
||||
// Send own key to every member
|
||||
const otherMembers = _.without(members, ourIdentity);
|
||||
|
||||
const proto = new textsecure.protobuf.DataMessage();
|
||||
|
||||
// We reuse the same message type for sender keys
|
||||
const update = new textsecure.protobuf.MediumGroupUpdate();
|
||||
update.type = textsecure.protobuf.MediumGroupUpdate.Type.SENDER_KEY;
|
||||
update.groupId = groupId;
|
||||
update.senderKey = new textsecure.protobuf.SenderKey({
|
||||
chainKey: ownSenderKey,
|
||||
keyIdx: 0,
|
||||
});
|
||||
|
||||
proto.mediumGroupUpdate = update;
|
||||
|
||||
textsecure.messaging.updateMediumGroup(otherMembers, proto);
|
||||
}
|
||||
|
||||
// Subscribe to this group
|
||||
this.pollForAdditionalId(groupId);
|
||||
}
|
||||
|
||||
this.removeFromCache(envelope);
|
||||
}
|
||||
},
|
||||
async handleDataMessage(envelope, msg) {
|
||||
window.log.info('data message from', this.getEnvelopeId(envelope));
|
||||
|
||||
if (msg.mediumGroupUpdate) {
|
||||
this.handleMediumGroupUpdate(envelope, msg.mediumGroupUpdate);
|
||||
// TODO: investigate the meaning of this return value
|
||||
return true;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-bitwise
|
||||
if (msg.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) {
|
||||
await this.handleEndSession(envelope.source);
|
||||
}
|
||||
const message = await this.processDecrypted(envelope, msg);
|
||||
const ourPubKey = textsecure.storage.user.getNumber();
|
||||
const senderPubKey = envelope.source;
|
||||
const isMe = senderPubKey === ourPubKey;
|
||||
const conversation = window.ConversationController.get(senderPubKey);
|
||||
|
||||
const { UNPAIRING_REQUEST } = textsecure.protobuf.DataMessage.Flags;
|
||||
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const isUnpairingRequest = Boolean(message.flags & UNPAIRING_REQUEST);
|
||||
|
||||
if (isUnpairingRequest) {
|
||||
return this.handleUnpairRequest(envelope, ourPubKey);
|
||||
}
|
||||
|
||||
// Check if we need to update any profile names
|
||||
if (!isMe && conversation && message.profile) {
|
||||
await this.updateProfile(
|
||||
conversation,
|
||||
message.profile,
|
||||
message.profileKey
|
||||
);
|
||||
}
|
||||
if (this.isMessageEmpty(message)) {
|
||||
window.log.warn(
|
||||
`Message ${this.getEnvelopeId(envelope)} ignored; it was empty`
|
||||
);
|
||||
return this.removeFromCache(envelope);
|
||||
}
|
||||
|
||||
// Loki - Temp hack until new protocol
|
||||
// A friend request is a non-group text message which we haven't processed yet
|
||||
const isGroupMessage = Boolean(message.group || message.mediumGroupUpdate);
|
||||
const friendRequestStatusNoneOrExpired = conversation
|
||||
? conversation.isFriendRequestStatusNoneOrExpired()
|
||||
: true;
|
||||
const isFriendRequest =
|
||||
!isGroupMessage &&
|
||||
!_.isEmpty(message.body) &&
|
||||
friendRequestStatusNoneOrExpired;
|
||||
|
||||
const source = envelope.senderIdentity || senderPubKey;
|
||||
|
||||
const isOwnDevice = async pubkey => {
|
||||
const primaryDevice = window.storage.get('primaryDevicePubKey');
|
||||
const secondaryDevices = await window.libloki.storage.getPairedDevicesFor(
|
||||
primaryDevice
|
||||
);
|
||||
|
||||
const allDevices = [primaryDevice, ...secondaryDevices];
|
||||
return allDevices.includes(pubkey);
|
||||
};
|
||||
|
||||
const ownDevice = await isOwnDevice(source);
|
||||
|
||||
let ev;
|
||||
if (conversation.isMediumGroup() && ownDevice) {
|
||||
// Data messages for medium groups don't arrive as sync messages. Instead,
|
||||
// linked devices poll for group messages independently, thus they need
|
||||
// to recognise some of those messages at their own.
|
||||
ev = new Event('sent');
|
||||
} else {
|
||||
ev = new Event('message');
|
||||
}
|
||||
|
||||
if (envelope.senderIdentity) {
|
||||
message.group = {
|
||||
id: envelope.source,
|
||||
};
|
||||
}
|
||||
|
||||
ev.confirm = this.removeFromCache.bind(this, envelope);
|
||||
ev.data = {
|
||||
friendRequest: isFriendRequest,
|
||||
source,
|
||||
sourceDevice: envelope.sourceDevice,
|
||||
timestamp: envelope.timestamp.toNumber(),
|
||||
receivedAt: envelope.receivedAt,
|
||||
unidentifiedDeliveryReceived: envelope.unidentifiedDeliveryReceived,
|
||||
message,
|
||||
};
|
||||
return this.dispatchAndWait(ev);
|
||||
},
|
||||
isMessageEmpty({
|
||||
body,
|
||||
attachments,
|
||||
group,
|
||||
flags,
|
||||
quote,
|
||||
contact,
|
||||
preview,
|
||||
groupInvitation,
|
||||
mediumGroupUpdate,
|
||||
}) {
|
||||
return (
|
||||
!flags &&
|
||||
_.isEmpty(body) &&
|
||||
_.isEmpty(attachments) &&
|
||||
_.isEmpty(group) &&
|
||||
_.isEmpty(quote) &&
|
||||
_.isEmpty(contact) &&
|
||||
_.isEmpty(preview) &&
|
||||
_.isEmpty(groupInvitation) &&
|
||||
_.isEmpty(mediumGroupUpdate)
|
||||
);
|
||||
},
|
||||
handleLegacyMessage(envelope) {
|
||||
return this.decrypt(envelope, envelope.legacyMessage).then(plaintext => {
|
||||
if (!plaintext) {
|
||||
window.log.warn('handleLegacyMessage: plaintext was falsey');
|
||||
return null;
|
||||
}
|
||||
return this.innerHandleLegacyMessage(envelope, plaintext);
|
||||
});
|
||||
},
|
||||
innerHandleLegacyMessage(envelope, plaintext) {
|
||||
const message = textsecure.protobuf.DataMessage.decode(plaintext);
|
||||
return this.handleDataMessage(envelope, message);
|
||||
},
|
||||
async handleContentMessage(envelope) {
|
||||
const plaintext = await this.decrypt(envelope, envelope.content);
|
||||
|
||||
|
@ -1624,7 +1173,8 @@ MessageReceiver.prototype.extend({
|
|||
return this.handleSyncMessage(envelope, content.syncMessage);
|
||||
}
|
||||
if (content.dataMessage) {
|
||||
return this.handleDataMessage(envelope, content.dataMessage);
|
||||
window.NewReceiver.handleDataMessage(envelope, content.dataMessage);
|
||||
return undefined;
|
||||
}
|
||||
if (content.nullMessage) {
|
||||
return this.handleNullMessage(envelope, content.nullMessage);
|
||||
|
@ -1642,7 +1192,7 @@ MessageReceiver.prototype.extend({
|
|||
return null;
|
||||
},
|
||||
handleCallMessage(envelope) {
|
||||
window.log.info('call message from', this.getEnvelopeId(envelope));
|
||||
window.log.info('call message from', getEnvelopeId(envelope));
|
||||
this.removeFromCache(envelope);
|
||||
},
|
||||
handleReceiptMessage(envelope, receiptMessage) {
|
||||
|
@ -1714,7 +1264,7 @@ MessageReceiver.prototype.extend({
|
|||
return this.dispatchEvent(ev);
|
||||
},
|
||||
handleNullMessage(envelope) {
|
||||
window.log.info('null message from', this.getEnvelopeId(envelope));
|
||||
window.log.info('null message from', getEnvelopeId(envelope));
|
||||
this.removeFromCache(envelope);
|
||||
},
|
||||
async handleSyncMessage(envelope, syncMessage) {
|
||||
|
@ -1747,7 +1297,7 @@ MessageReceiver.prototype.extend({
|
|||
to,
|
||||
sentMessage.timestamp.toNumber(),
|
||||
'from',
|
||||
this.getEnvelopeId(envelope)
|
||||
getEnvelopeId(envelope)
|
||||
);
|
||||
return this.handleSentMessage(envelope, sentMessage, sentMessage.message);
|
||||
} else if (syncMessage.contacts) {
|
||||
|
@ -1762,7 +1312,7 @@ MessageReceiver.prototype.extend({
|
|||
window.log.info('Got SyncMessage Request');
|
||||
return this.removeFromCache(envelope);
|
||||
} else if (syncMessage.read && syncMessage.read.length) {
|
||||
window.log.info('read messages from', this.getEnvelopeId(envelope));
|
||||
window.log.info('read messages from', getEnvelopeId(envelope));
|
||||
return this.handleRead(envelope, syncMessage.read);
|
||||
} else if (syncMessage.verified) {
|
||||
return this.handleVerified(envelope, syncMessage.verified);
|
||||
|
@ -1908,39 +1458,6 @@ MessageReceiver.prototype.extend({
|
|||
isBlocked(number) {
|
||||
return textsecure.storage.get('blocked', []).indexOf(number) >= 0;
|
||||
},
|
||||
|
||||
cleanAttachment(attachment) {
|
||||
return {
|
||||
..._.omit(attachment, 'thumbnail'),
|
||||
id: attachment.id.toString(),
|
||||
key: attachment.key ? attachment.key.toString('base64') : null,
|
||||
digest: attachment.digest ? attachment.digest.toString('base64') : null,
|
||||
};
|
||||
},
|
||||
async downloadAttachment(attachment) {
|
||||
// The attachment id is actually just the absolute url of the attachment
|
||||
let data = await this.server.getAttachment(attachment.url);
|
||||
if (!attachment.isRaw) {
|
||||
const { key, digest, size } = attachment;
|
||||
|
||||
data = await textsecure.crypto.decryptAttachment(
|
||||
data,
|
||||
window.Signal.Crypto.base64ToArrayBuffer(key),
|
||||
window.Signal.Crypto.base64ToArrayBuffer(digest)
|
||||
);
|
||||
|
||||
if (!size || size !== data.byteLength) {
|
||||
throw new Error(
|
||||
`downloadAttachment: Size ${size} did not match downloaded attachment size ${data.byteLength}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
..._.omit(attachment, 'digest', 'key'),
|
||||
data,
|
||||
};
|
||||
},
|
||||
handleAttachment(attachment) {
|
||||
// window.log.info('Not handling attachments.');
|
||||
return Promise.resolve({
|
||||
|
@ -1948,160 +1465,6 @@ MessageReceiver.prototype.extend({
|
|||
data: dcodeIO.ByteBuffer.wrap(attachment.data).toArrayBuffer(), // ByteBuffer to ArrayBuffer
|
||||
});
|
||||
},
|
||||
async handleEndSession(number) {
|
||||
window.log.info('got end session');
|
||||
let conversation;
|
||||
try {
|
||||
conversation = window.ConversationController.get(number);
|
||||
if (conversation) {
|
||||
await conversation.onSessionResetReceived();
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
} catch (e) {
|
||||
window.log.error('Error getting conversation: ', number);
|
||||
}
|
||||
},
|
||||
processDecrypted(envelope, decrypted) {
|
||||
/* eslint-disable no-bitwise, no-param-reassign */
|
||||
const FLAGS = textsecure.protobuf.DataMessage.Flags;
|
||||
|
||||
// Now that its decrypted, validate the message and clean it up for consumer
|
||||
// processing
|
||||
// Note that messages may (generally) only perform one action and we ignore remaining
|
||||
// fields after the first action.
|
||||
|
||||
if (decrypted.flags == null) {
|
||||
decrypted.flags = 0;
|
||||
}
|
||||
if (decrypted.expireTimer == null) {
|
||||
decrypted.expireTimer = 0;
|
||||
}
|
||||
|
||||
if (decrypted.flags & FLAGS.END_SESSION) {
|
||||
decrypted.body = null;
|
||||
decrypted.attachments = [];
|
||||
decrypted.group = null;
|
||||
return Promise.resolve(decrypted);
|
||||
} else if (decrypted.flags & FLAGS.EXPIRATION_TIMER_UPDATE) {
|
||||
decrypted.body = null;
|
||||
decrypted.attachments = [];
|
||||
} else if (decrypted.flags & FLAGS.PROFILE_KEY_UPDATE) {
|
||||
decrypted.body = null;
|
||||
decrypted.attachments = [];
|
||||
} else if (decrypted.flags & FLAGS.SESSION_REQUEST) {
|
||||
// do nothing
|
||||
} else if (decrypted.flags & FLAGS.SESSION_RESTORE) {
|
||||
// do nothing
|
||||
} else if (decrypted.flags & FLAGS.UNPAIRING_REQUEST) {
|
||||
// do nothing
|
||||
} else if (decrypted.flags !== 0) {
|
||||
throw new Error('Unknown flags in message');
|
||||
}
|
||||
|
||||
const promises = [];
|
||||
|
||||
if (decrypted.group !== null) {
|
||||
decrypted.group.id = decrypted.group.id.toBinary();
|
||||
|
||||
switch (decrypted.group.type) {
|
||||
case textsecure.protobuf.GroupContext.Type.UPDATE:
|
||||
decrypted.body = null;
|
||||
decrypted.attachments = [];
|
||||
break;
|
||||
case textsecure.protobuf.GroupContext.Type.QUIT:
|
||||
decrypted.body = null;
|
||||
decrypted.attachments = [];
|
||||
break;
|
||||
case textsecure.protobuf.GroupContext.Type.DELIVER:
|
||||
decrypted.group.name = null;
|
||||
decrypted.group.members = [];
|
||||
decrypted.group.avatar = null;
|
||||
break;
|
||||
case textsecure.protobuf.GroupContext.Type.REQUEST_INFO:
|
||||
decrypted.body = null;
|
||||
decrypted.attachments = [];
|
||||
break;
|
||||
default:
|
||||
this.removeFromCache(envelope);
|
||||
throw new Error('Unknown group message type');
|
||||
}
|
||||
}
|
||||
|
||||
const attachmentCount = decrypted.attachments.length;
|
||||
const ATTACHMENT_MAX = 32;
|
||||
if (attachmentCount > ATTACHMENT_MAX) {
|
||||
throw new Error(
|
||||
`Too many attachments: ${attachmentCount} included in one message, max is ${ATTACHMENT_MAX}`
|
||||
);
|
||||
}
|
||||
|
||||
// Here we go from binary to string/base64 in all AttachmentPointer digest/key fields
|
||||
|
||||
if (
|
||||
decrypted.group &&
|
||||
decrypted.group.type === textsecure.protobuf.GroupContext.Type.UPDATE
|
||||
) {
|
||||
if (decrypted.group.avatar !== null) {
|
||||
decrypted.group.avatar = this.cleanAttachment(decrypted.group.avatar);
|
||||
}
|
||||
}
|
||||
|
||||
decrypted.attachments = (decrypted.attachments || []).map(
|
||||
this.cleanAttachment.bind(this)
|
||||
);
|
||||
decrypted.preview = (decrypted.preview || []).map(item => {
|
||||
const { image } = item;
|
||||
|
||||
if (!image) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
image: this.cleanAttachment(image),
|
||||
};
|
||||
});
|
||||
decrypted.contact = (decrypted.contact || []).map(item => {
|
||||
const { avatar } = item;
|
||||
|
||||
if (!avatar || !avatar.avatar) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
avatar: {
|
||||
...item.avatar,
|
||||
avatar: this.cleanAttachment(item.avatar.avatar),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (decrypted.quote && decrypted.quote.id) {
|
||||
decrypted.quote.id = decrypted.quote.id.toNumber();
|
||||
}
|
||||
|
||||
if (decrypted.quote) {
|
||||
decrypted.quote.attachments = (decrypted.quote.attachments || []).map(
|
||||
item => {
|
||||
const { thumbnail } = item;
|
||||
|
||||
if (!thumbnail) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
thumbnail: this.cleanAttachment(item.thumbnail),
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(promises).then(() => decrypted);
|
||||
/* eslint-enable no-bitwise, no-param-reassign */
|
||||
},
|
||||
});
|
||||
|
||||
window.textsecure = window.textsecure || {};
|
||||
|
@ -2125,18 +1488,11 @@ textsecure.MessageReceiver = function MessageReceiverWrapper(
|
|||
messageReceiver
|
||||
);
|
||||
this.getStatus = messageReceiver.getStatus.bind(messageReceiver);
|
||||
this.handleEndSession = messageReceiver.handleEndSession.bind(
|
||||
messageReceiver
|
||||
);
|
||||
this.close = messageReceiver.close.bind(messageReceiver);
|
||||
this.savePreKeyBundleMessage = messageReceiver.savePreKeyBundleMessage.bind(
|
||||
messageReceiver
|
||||
);
|
||||
|
||||
this.downloadAttachment = messageReceiver.downloadAttachment.bind(
|
||||
messageReceiver
|
||||
);
|
||||
|
||||
this.pollForAdditionalId = messageReceiver.pollForAdditionalId.bind(
|
||||
messageReceiver
|
||||
);
|
||||
|
|
|
@ -26,7 +26,7 @@ const getTTLForType = type => {
|
|||
case 'pairing-request':
|
||||
return 2 * 60 * 1000; // 2 minutes for pairing requests
|
||||
default:
|
||||
return (window.getMessageTTL() || 24) * 60 * 60 * 1000; // 1 day default for any other message
|
||||
return 24 * 60 * 60 * 1000; // 1 day default for any other message
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
"postinstall": "electron-builder install-app-deps && rimraf node_modules/dtrace-provider",
|
||||
"start": "cross-env NODE_APP_INSTANCE=$MULTI electron .",
|
||||
"start-prod": "cross-env NODE_ENV=production NODE_APP_INSTANCE=devprod$MULTI electron .",
|
||||
"start-swarm-test": "cross-env NODE_ENV=production NODE_APP_INSTANCE=$MULTI electron .",
|
||||
"start-swarm-test": "cross-env NODE_ENV=swarm-testing NODE_APP_INSTANCE=$MULTI electron .",
|
||||
"grunt": "grunt",
|
||||
"icon-gen": "electron-icon-maker --input=images/icon_1024.png --output=./build",
|
||||
"generate": "yarn icon-gen && yarn grunt",
|
||||
|
@ -115,6 +115,7 @@
|
|||
"uuid": "3.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/backbone": "^1.4.2",
|
||||
"@types/chai": "4.1.2",
|
||||
"@types/chai-as-promised": "^7.1.2",
|
||||
"@types/classnames": "2.2.3",
|
||||
|
|
|
@ -435,6 +435,8 @@ window.addEventListener('contextmenu', e => {
|
|||
}
|
||||
});
|
||||
|
||||
window.NewReceiver = require('./ts/receiver/receiver');
|
||||
|
||||
window.shortenPubkey = pubkey => `(...${pubkey.substring(pubkey.length - 6)})`;
|
||||
|
||||
window.pubkeyPattern = /@[a-fA-F0-9]{64,66}\b/g;
|
||||
|
|
|
@ -25,6 +25,9 @@ interface Window {
|
|||
Signal: any;
|
||||
Whisper: any;
|
||||
ConversationController: any;
|
||||
MessageController: any;
|
||||
|
||||
StringView: any;
|
||||
|
||||
// Following function needs to be written in background.js
|
||||
// getMemberList: any;
|
||||
|
@ -59,6 +62,11 @@ interface Window {
|
|||
lokiFeatureFlags: any;
|
||||
|
||||
resetDatabase: any;
|
||||
restart: () => void;
|
||||
|
||||
lokiFileServerAPI: any;
|
||||
WebAPI: any;
|
||||
SenderKeyAPI: any;
|
||||
}
|
||||
|
||||
interface Promise<T> {
|
||||
|
|
|
@ -0,0 +1,241 @@
|
|||
import { MessageModel } from './message';
|
||||
|
||||
// TODO: Might convert it to a class later
|
||||
let webAPI: any;
|
||||
|
||||
export async function downloadAttachment(attachment: any) {
|
||||
const _ = window.Lodash;
|
||||
|
||||
if (!webAPI) {
|
||||
webAPI = window.WebAPI.connect();
|
||||
}
|
||||
|
||||
// The attachment id is actually just the absolute url of the attachment
|
||||
let data = await webAPI.getAttachment(attachment.url);
|
||||
if (!attachment.isRaw) {
|
||||
const { key, digest, size } = attachment;
|
||||
|
||||
data = await window.textsecure.crypto.decryptAttachment(
|
||||
data,
|
||||
window.Signal.Crypto.base64ToArrayBuffer(key),
|
||||
window.Signal.Crypto.base64ToArrayBuffer(digest)
|
||||
);
|
||||
|
||||
if (!size || size !== data.byteLength) {
|
||||
throw new Error(
|
||||
`downloadAttachment: Size ${size} did not match downloaded attachment size ${data.byteLength}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
..._.omit(attachment, 'digest', 'key'),
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
async function processLongAttachments(
|
||||
message: MessageModel,
|
||||
attachments: Array<any>
|
||||
): Promise<boolean> {
|
||||
if (attachments.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (attachments.length > 1) {
|
||||
window.log.error(
|
||||
`Received more than one long message attachment in message ${message.idForLogging()}`
|
||||
);
|
||||
}
|
||||
|
||||
const attachment = attachments[0];
|
||||
|
||||
message.set({ bodyPending: true });
|
||||
await window.Signal.AttachmentDownloads.addJob(attachment, {
|
||||
messageId: message.id,
|
||||
type: 'long-message',
|
||||
index: 0,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function processNormalAttachments(
|
||||
message: MessageModel,
|
||||
normalAttachments: Array<any>
|
||||
): Promise<number> {
|
||||
const attachments = await Promise.all(
|
||||
normalAttachments.map((attachment: any, index: any) => {
|
||||
return window.Signal.AttachmentDownloads.addJob(attachment, {
|
||||
messageId: message.id,
|
||||
type: 'attachment',
|
||||
index,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
message.set({ attachments });
|
||||
|
||||
return attachments.length;
|
||||
}
|
||||
|
||||
async function processPreviews(message: MessageModel): Promise<number> {
|
||||
let addedCount = 0;
|
||||
|
||||
const preview = await Promise.all(
|
||||
(message.get('preview') || []).map(async (item: any, index: any) => {
|
||||
if (!item.image) {
|
||||
return item;
|
||||
}
|
||||
addedCount += 1;
|
||||
|
||||
const image = await window.Signal.AttachmentDownloads.addJob(item.image, {
|
||||
messageId: message.id,
|
||||
type: 'preview',
|
||||
index,
|
||||
});
|
||||
|
||||
return { ...item, image };
|
||||
})
|
||||
);
|
||||
|
||||
message.set({ preview });
|
||||
|
||||
return addedCount;
|
||||
}
|
||||
|
||||
async function processAvatars(message: MessageModel): Promise<number> {
|
||||
let addedCount = 0;
|
||||
|
||||
const contacts = message.get('contact') || [];
|
||||
|
||||
const contact = await Promise.all(
|
||||
contacts.map(async (item: any, index: any) => {
|
||||
if (!item.avatar || !item.avatar.avatar) {
|
||||
return item;
|
||||
}
|
||||
|
||||
addedCount += 1;
|
||||
|
||||
const avatarJob = await window.Signal.AttachmentDownloads.addJob(
|
||||
item.avatar.avatar,
|
||||
{
|
||||
messaeId: message.id,
|
||||
type: 'contact',
|
||||
index,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
...item,
|
||||
avatar: {
|
||||
...item.avatar,
|
||||
avatar: avatarJob,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
message.set({ contact });
|
||||
|
||||
return addedCount;
|
||||
}
|
||||
|
||||
async function processQuoteAttachments(message: MessageModel): Promise<number> {
|
||||
let addedCount = 0;
|
||||
|
||||
const quote = message.get('quote');
|
||||
|
||||
if (!quote || !quote.attachments || !quote.attachments.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
quote.attachments = await Promise.all(
|
||||
quote.attachments.map(async (item: any, index: any) => {
|
||||
// If we already have a path, then we copied this image from the quoted
|
||||
// message and we don't need to download the attachment.
|
||||
if (!item.thumbnail || item.thumbnail.path) {
|
||||
return item;
|
||||
}
|
||||
|
||||
addedCount += 1;
|
||||
|
||||
const thumbnail = await window.Signal.AttachmentDownloads.addJob(
|
||||
item.thumbnail,
|
||||
{
|
||||
messageId: message.id,
|
||||
type: 'quote',
|
||||
index,
|
||||
}
|
||||
);
|
||||
|
||||
return { ...item, thumbnail };
|
||||
})
|
||||
);
|
||||
|
||||
message.set({ quote });
|
||||
|
||||
return addedCount;
|
||||
}
|
||||
|
||||
async function processGroupAvatar(message: MessageModel): Promise<boolean> {
|
||||
let group = message.get('group');
|
||||
|
||||
if (!group || !group.avatar) {
|
||||
return false;
|
||||
}
|
||||
|
||||
group = {
|
||||
...group,
|
||||
avatar: await window.Signal.AttachmentDownloads.addJob(group.avatar, {
|
||||
messageId: message.id,
|
||||
type: 'group-avatar',
|
||||
index: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
message.set({ group });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function queueAttachmentDownloads(
|
||||
message: MessageModel
|
||||
): Promise<boolean> {
|
||||
const _ = window.Lodash;
|
||||
const { Whisper } = window;
|
||||
|
||||
let count = 0;
|
||||
|
||||
const [longMessageAttachments, normalAttachments] = _.partition(
|
||||
message.get('attachments') || [],
|
||||
(attachment: any) =>
|
||||
attachment.contentType === Whisper.Message.LONG_MESSAGE_CONTENT_TYPE
|
||||
);
|
||||
|
||||
if (await processLongAttachments(message, longMessageAttachments)) {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
count += await processNormalAttachments(message, normalAttachments);
|
||||
|
||||
count += await processPreviews(message);
|
||||
|
||||
count += await processAvatars(message);
|
||||
|
||||
count += await processQuoteAttachments(message);
|
||||
|
||||
if (await processGroupAvatar(message)) {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
await window.Signal.Data.saveMessage(message.attributes, {
|
||||
Message: Whisper.Message,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import { EnvelopePlus } from './types';
|
||||
|
||||
export function removeFromCache(envelope: EnvelopePlus) {
|
||||
const { id } = envelope;
|
||||
|
||||
return window.textsecure.storage.unprocessed.remove(id);
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
interface ConversationAttributes {
|
||||
members: Array<string>;
|
||||
left: boolean;
|
||||
expireTimer: number;
|
||||
profileSharing: boolean;
|
||||
mentionedUs: boolean;
|
||||
unreadCount: number;
|
||||
isArchived: boolean;
|
||||
active_at: number;
|
||||
timestamp: number; // timestamp of what?
|
||||
}
|
||||
|
||||
export interface ConversationModel
|
||||
extends Backbone.Model<ConversationAttributes> {
|
||||
setFriendRequestStatus: (status: any) => Promise<void>;
|
||||
idForLogging: () => string;
|
||||
saveChangesToDB: () => Promise<void>;
|
||||
notifyFriendRequest: (source: string, type: string) => Promise<void>;
|
||||
notify: (message: MessageModel) => void;
|
||||
isSessionResetReceived: () => boolean;
|
||||
updateExpirationTimer: (
|
||||
expireTimer: number | null,
|
||||
source: string,
|
||||
receivedAt: number,
|
||||
options: object
|
||||
) => void;
|
||||
isPrivate: () => boolean;
|
||||
setProfileKey: (key: string) => void;
|
||||
isMe: () => boolean;
|
||||
getRecipients: () => Array<string>;
|
||||
onReadMessage: (message: MessageModel) => void;
|
||||
updateTextInputState: () => void;
|
||||
isFriend: () => boolean;
|
||||
hasSentFriendRequest: () => boolean;
|
||||
onFriendRequestAccepted: () => Promise<void>;
|
||||
onFriendRequestReceived: () => Promise<void>;
|
||||
|
||||
lastMessage: string;
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import { MessageModel } from './message';
|
||||
|
||||
interface ConversationAttributes {
|
||||
members: Array<string>;
|
||||
left: boolean;
|
||||
expireTimer: number;
|
||||
profileSharing: boolean;
|
||||
mentionedUs: boolean;
|
||||
unreadCount: number;
|
||||
isArchived: boolean;
|
||||
active_at: number;
|
||||
timestamp: number; // timestamp of what?
|
||||
}
|
||||
|
||||
export interface ConversationModel
|
||||
extends Backbone.Model<ConversationAttributes> {
|
||||
setFriendRequestStatus: (status: any) => Promise<void>;
|
||||
idForLogging: () => string;
|
||||
saveChangesToDB: () => Promise<void>;
|
||||
notifyFriendRequest: (source: string, type: string) => Promise<void>;
|
||||
notify: (message: MessageModel) => void;
|
||||
isSessionResetReceived: () => boolean;
|
||||
updateExpirationTimer: (
|
||||
expireTimer: number | null,
|
||||
source: string,
|
||||
receivedAt: number,
|
||||
options: object
|
||||
) => void;
|
||||
isPrivate: () => boolean;
|
||||
setProfileKey: (key: string) => void;
|
||||
isMe: () => boolean;
|
||||
getRecipients: () => Array<string>;
|
||||
onReadMessage: (message: MessageModel) => void;
|
||||
updateTextInputState: () => void;
|
||||
isFriend: () => boolean;
|
||||
hasSentFriendRequest: () => boolean;
|
||||
onFriendRequestAccepted: () => Promise<void>;
|
||||
onFriendRequestReceived: () => Promise<void>;
|
||||
|
||||
lastMessage: string;
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
import { SignalService } from '../protobuf';
|
||||
|
||||
const _ = window.Lodash;
|
||||
|
||||
function isGroupBlocked(groupId: string) {
|
||||
return (
|
||||
window.textsecure.storage.get('blocked-groups', []).indexOf(groupId) >= 0
|
||||
);
|
||||
}
|
||||
|
||||
function shouldIgnoreBlockedGroup(group: any, senderPubKey: string) {
|
||||
const groupId = group.id;
|
||||
const isBlocked = isGroupBlocked(groupId);
|
||||
const isLeavingGroup = Boolean(
|
||||
group.type === SignalService.GroupContext.Type.QUIT
|
||||
);
|
||||
|
||||
const primaryDevicePubKey = window.storage.get('primaryDevicePubKey');
|
||||
const isMe =
|
||||
senderPubKey === window.textsecure.storage.user.getNumber() ||
|
||||
senderPubKey === primaryDevicePubKey;
|
||||
|
||||
return isBlocked && !(isMe && isLeavingGroup);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the message is already completely handled and confirmed
|
||||
* and the processing of this message must stop.
|
||||
*/
|
||||
export async function preprocessGroupMessage(
|
||||
source: string,
|
||||
group: any,
|
||||
primarySource: string
|
||||
) {
|
||||
const conversationId = group.id;
|
||||
const conversation = await window.ConversationController.getOrCreateAndWait(
|
||||
conversationId,
|
||||
'group'
|
||||
);
|
||||
const GROUP_TYPES = SignalService.GroupContext.Type;
|
||||
|
||||
if (shouldIgnoreBlockedGroup(group, source)) {
|
||||
window.log.warn('Message ignored; destined for blocked group');
|
||||
return true;
|
||||
}
|
||||
|
||||
// NOTE: we use friends status to tell if this is
|
||||
// the creation of the group (initial update)
|
||||
const newGroup = !conversation.isFriend();
|
||||
const knownMembers = conversation.get('members');
|
||||
|
||||
if (!newGroup && knownMembers) {
|
||||
const fromMember = knownMembers.includes(primarySource);
|
||||
// if the group exists and we have its members,
|
||||
// we must drop a message from anyone else than the existing members.
|
||||
if (!fromMember) {
|
||||
window.log.warn(
|
||||
`Ignoring group message from non-member: ${primarySource}`
|
||||
);
|
||||
// returning true drops the message
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (group.type === GROUP_TYPES.REQUEST_INFO && !newGroup) {
|
||||
window.libloki.api.debug.logGroupRequestInfo(
|
||||
`Received GROUP_TYPES.REQUEST_INFO from source: ${source}, primarySource: ${primarySource}, sending back group info.`
|
||||
);
|
||||
conversation.sendGroupInfo([source]);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (group.members && group.type === GROUP_TYPES.UPDATE) {
|
||||
if (newGroup) {
|
||||
conversation.updateGroupAdmins(group.admins);
|
||||
|
||||
conversation.setFriendRequestStatus(
|
||||
window.friends.friendRequestStatusEnum.friends
|
||||
);
|
||||
} else {
|
||||
// be sure to drop a message from a non admin if it tries to change group members
|
||||
// or change the group name
|
||||
const fromAdmin = conversation.get('groupAdmins').includes(primarySource);
|
||||
|
||||
if (!fromAdmin) {
|
||||
// Make sure the message is not removing members / renaming the group
|
||||
const nameChanged = conversation.get('name') !== group.name;
|
||||
|
||||
if (nameChanged) {
|
||||
window.log.warn('Non-admin attempts to change the name of the group');
|
||||
}
|
||||
|
||||
const membersMissing =
|
||||
_.difference(conversation.get('members'), group.members).length > 0;
|
||||
|
||||
if (membersMissing) {
|
||||
window.log.warn('Non-admin attempts to remove group members');
|
||||
}
|
||||
|
||||
const messageAllowed = !nameChanged && !membersMissing;
|
||||
|
||||
// Returning true drops the message
|
||||
if (!messageAllowed) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// send a session request for all the members we do not have a session with
|
||||
window.libloki.api.sendSessionRequestsToMembers(group.members);
|
||||
} else if (newGroup) {
|
||||
// We have an unknown group, we should request info from the sender
|
||||
window.textsecure.messaging.requestGroupInfo(conversationId, [
|
||||
primarySource,
|
||||
]);
|
||||
}
|
||||
return false;
|
||||
}
|
|
@ -0,0 +1,211 @@
|
|||
import { SignalService } from '../protobuf';
|
||||
import { removeFromCache } from './cache';
|
||||
import { EnvelopePlus } from './types';
|
||||
|
||||
async function handleSenderKeyRequest(
|
||||
envelope: EnvelopePlus,
|
||||
groupUpdate: any
|
||||
) {
|
||||
const { SenderKeyAPI, StringView, textsecure, log } = window;
|
||||
|
||||
const senderIdentity = envelope.source;
|
||||
const ourIdentity = await textsecure.storage.user.getNumber();
|
||||
const { groupId } = groupUpdate;
|
||||
|
||||
log.debug('[sender key] sender key request from:', senderIdentity);
|
||||
|
||||
const proto = new SignalService.DataMessage();
|
||||
|
||||
// We reuse the same message type for sender keys
|
||||
const update = new SignalService.MediumGroupUpdate();
|
||||
|
||||
const { chainKey, keyIdx } = await SenderKeyAPI.getSenderKeys(
|
||||
groupId,
|
||||
ourIdentity
|
||||
);
|
||||
|
||||
update.type = SignalService.MediumGroupUpdate.Type.SENDER_KEY;
|
||||
update.groupId = groupId;
|
||||
update.senderKey = new SignalService.SenderKey({
|
||||
chainKey: StringView.arrayBufferToHex(chainKey),
|
||||
keyIdx,
|
||||
});
|
||||
|
||||
proto.mediumGroupUpdate = update;
|
||||
|
||||
textsecure.messaging.updateMediumGroup([senderIdentity], proto);
|
||||
|
||||
removeFromCache(envelope);
|
||||
}
|
||||
|
||||
async function handleSenderKey(envelope: EnvelopePlus, groupUpdate: any) {
|
||||
const { SenderKeyAPI, log } = window;
|
||||
const { groupId, senderKey } = groupUpdate;
|
||||
const senderIdentity = envelope.source;
|
||||
|
||||
log.debug('[sender key] got a new sender key from:', senderIdentity);
|
||||
|
||||
await SenderKeyAPI.saveSenderKeys(
|
||||
groupId,
|
||||
senderIdentity,
|
||||
senderKey.chainKey,
|
||||
senderKey.keyIdx
|
||||
);
|
||||
|
||||
removeFromCache(envelope);
|
||||
}
|
||||
|
||||
async function handleNewGroup(envelope: EnvelopePlus, groupUpdate: any) {
|
||||
const {
|
||||
SenderKeyAPI,
|
||||
StringView,
|
||||
Whisper,
|
||||
log,
|
||||
textsecure,
|
||||
Lodash: _,
|
||||
} = window;
|
||||
|
||||
const senderIdentity = envelope.source;
|
||||
|
||||
const ourIdentity = await textsecure.storage.user.getNumber();
|
||||
|
||||
const {
|
||||
groupId,
|
||||
members: membersBinary,
|
||||
groupSecretKey,
|
||||
groupName,
|
||||
senderKey,
|
||||
admins,
|
||||
} = groupUpdate;
|
||||
|
||||
const maybeConvo = await window.ConversationController.get(groupId);
|
||||
const groupExists = !!maybeConvo;
|
||||
|
||||
const members = membersBinary.map((pk: any) =>
|
||||
StringView.arrayBufferToHex(pk.toArrayBuffer())
|
||||
);
|
||||
|
||||
const convo = groupExists
|
||||
? maybeConvo
|
||||
: await window.ConversationController.getOrCreateAndWait(groupId, 'group');
|
||||
|
||||
{
|
||||
// Add group update message
|
||||
const now = Date.now();
|
||||
const message = convo.messageCollection.add({
|
||||
conversationId: convo.id,
|
||||
type: 'incoming',
|
||||
sent_at: now,
|
||||
received_at: now,
|
||||
group_update: {
|
||||
name: groupName,
|
||||
members,
|
||||
},
|
||||
});
|
||||
|
||||
const messageId = await window.Signal.Data.saveMessage(message.attributes, {
|
||||
Message: Whisper.Message,
|
||||
});
|
||||
message.set({ id: messageId });
|
||||
}
|
||||
|
||||
if (groupExists) {
|
||||
// ***** Updating the group *****
|
||||
log.info('Received a group update for medium group:', groupId);
|
||||
|
||||
// Check that the sender is admin (make sure it words with multidevice)
|
||||
const isAdmin = convo.get('groupAdmins').includes(senderIdentity);
|
||||
|
||||
if (!isAdmin) {
|
||||
log.warn('Rejected attempt to update a group by non-admin');
|
||||
removeFromCache(envelope);
|
||||
return;
|
||||
}
|
||||
|
||||
convo.set('name', groupName);
|
||||
convo.set('members', members);
|
||||
|
||||
// TODO: check that we are still in the group (when we enable deleting members)
|
||||
convo.saveChangesToDB();
|
||||
|
||||
// Update other fields. Add a corresponding "update" message to the conversation
|
||||
} else {
|
||||
// ***** Creating a new group *****
|
||||
log.info('Received a new medium group:', groupId);
|
||||
|
||||
// TODO: Check that we are even a part of this group?
|
||||
|
||||
convo.set('is_medium_group', true);
|
||||
convo.set('active_at', Date.now());
|
||||
convo.set('name', groupName);
|
||||
convo.set('groupAdmins', admins);
|
||||
|
||||
convo.setFriendRequestStatus(
|
||||
window.friends.friendRequestStatusEnum.friends
|
||||
);
|
||||
|
||||
const secretKeyHex = StringView.arrayBufferToHex(
|
||||
groupSecretKey.toArrayBuffer()
|
||||
);
|
||||
|
||||
await window.Signal.Data.createOrUpdateIdentityKey({
|
||||
id: groupId,
|
||||
secretKey: secretKeyHex,
|
||||
});
|
||||
|
||||
// Save sender's key
|
||||
await SenderKeyAPI.saveSenderKeys(
|
||||
groupId,
|
||||
envelope.source,
|
||||
senderKey.chainKey,
|
||||
senderKey.keyIdx
|
||||
);
|
||||
|
||||
const ownSenderKey = await SenderKeyAPI.createSenderKeyForGroup(
|
||||
groupId,
|
||||
ourIdentity
|
||||
);
|
||||
|
||||
{
|
||||
// Send own key to every member
|
||||
const otherMembers = _.without(members, ourIdentity);
|
||||
|
||||
const proto = new SignalService.DataMessage();
|
||||
|
||||
// We reuse the same message type for sender keys
|
||||
const update = new SignalService.MediumGroupUpdate();
|
||||
update.type = SignalService.MediumGroupUpdate.Type.SENDER_KEY;
|
||||
update.groupId = groupId;
|
||||
update.senderKey = new SignalService.SenderKey({
|
||||
chainKey: ownSenderKey,
|
||||
keyIdx: 0,
|
||||
});
|
||||
|
||||
proto.mediumGroupUpdate = update;
|
||||
|
||||
textsecure.messaging.updateMediumGroup(otherMembers, proto);
|
||||
}
|
||||
|
||||
// TODO: !!!! This will need to be re-enabled after message polling refactor !!!!!
|
||||
// Subscribe to this group
|
||||
// this.pollForAdditionalId(groupId);
|
||||
}
|
||||
|
||||
removeFromCache(envelope);
|
||||
}
|
||||
|
||||
export async function handleMediumGroupUpdate(
|
||||
envelope: EnvelopePlus,
|
||||
groupUpdate: any
|
||||
) {
|
||||
const { type } = groupUpdate;
|
||||
const { Type } = SignalService.MediumGroupUpdate;
|
||||
|
||||
if (type === Type.SENDER_KEY_REQUEST) {
|
||||
await handleSenderKeyRequest(envelope, groupUpdate);
|
||||
} else if (type === Type.SENDER_KEY) {
|
||||
await handleSenderKey(envelope, groupUpdate);
|
||||
} else if (type === Type.NEW_GROUP) {
|
||||
await handleNewGroup(envelope, groupUpdate);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
enum MessageModelType {
|
||||
INCOMING = 'incoming',
|
||||
OUTGOING = 'outgoing',
|
||||
FRIEND_REQUEST = 'friend-request',
|
||||
}
|
||||
|
||||
export enum EndSessionType {
|
||||
DONE = 'done',
|
||||
ONGOING = 'ongoing',
|
||||
}
|
||||
|
||||
interface MessageAttributes {
|
||||
id: number;
|
||||
source: string;
|
||||
endSessionType: EndSessionType;
|
||||
quote: any;
|
||||
expireTimer: number;
|
||||
received_at: number;
|
||||
sent_at: number;
|
||||
preview: any;
|
||||
body: string;
|
||||
expirationStartTimestamp: any;
|
||||
read_by: Array<string>;
|
||||
delivered_to: Array<string>;
|
||||
decrypted_at: number;
|
||||
recipients: Array<string>;
|
||||
delivered: number;
|
||||
friendStatus: any;
|
||||
type: MessageModelType;
|
||||
group_update: any;
|
||||
groupInvitation: any;
|
||||
attachments: any;
|
||||
contact: any;
|
||||
conversationId: any;
|
||||
errors: any;
|
||||
flags: number;
|
||||
hasAttachments: boolean;
|
||||
hasFileAttachments: boolean;
|
||||
hasVisualMediaAttachments: boolean;
|
||||
schemaVersion: number;
|
||||
expirationTimerUpdate: any;
|
||||
unread: boolean;
|
||||
group: any;
|
||||
bodyPending: boolean;
|
||||
}
|
||||
|
||||
export interface MessageModel extends Backbone.Model<MessageAttributes> {
|
||||
idForLogging: () => string;
|
||||
isGroupUpdate: () => boolean;
|
||||
isExpirationTimerUpdate: () => boolean;
|
||||
isFriendRequest: () => boolean;
|
||||
getNotificationText: () => string;
|
||||
isEndSession: () => boolean;
|
||||
markRead: () => void;
|
||||
merge: (other: MessageModel) => void;
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
enum MessageModelType {
|
||||
INCOMING = 'incoming',
|
||||
OUTGOING = 'outgoing',
|
||||
FRIEND_REQUEST = 'friend-request',
|
||||
}
|
||||
|
||||
export enum EndSessionType {
|
||||
DONE = 'done',
|
||||
ONGOING = 'ongoing',
|
||||
}
|
||||
|
||||
interface MessageAttributes {
|
||||
id: number;
|
||||
source: string;
|
||||
endSessionType: EndSessionType;
|
||||
quote: any;
|
||||
expireTimer: number;
|
||||
received_at: number;
|
||||
sent_at: number;
|
||||
preview: any;
|
||||
body: string;
|
||||
expirationStartTimestamp: any;
|
||||
read_by: Array<string>;
|
||||
delivered_to: Array<string>;
|
||||
decrypted_at: number;
|
||||
recipients: Array<string>;
|
||||
delivered: number;
|
||||
friendStatus: any;
|
||||
type: MessageModelType;
|
||||
group_update: any;
|
||||
groupInvitation: any;
|
||||
attachments: any;
|
||||
contact: any;
|
||||
conversationId: any;
|
||||
errors: any;
|
||||
flags: number;
|
||||
hasAttachments: boolean;
|
||||
hasFileAttachments: boolean;
|
||||
hasVisualMediaAttachments: boolean;
|
||||
schemaVersion: number;
|
||||
expirationTimerUpdate: any;
|
||||
unread: boolean;
|
||||
group: any;
|
||||
bodyPending: boolean;
|
||||
}
|
||||
|
||||
export interface MessageModel extends Backbone.Model<MessageAttributes> {
|
||||
idForLogging: () => string;
|
||||
isGroupUpdate: () => boolean;
|
||||
isExpirationTimerUpdate: () => boolean;
|
||||
isFriendRequest: () => boolean;
|
||||
getNotificationText: () => string;
|
||||
isEndSession: () => boolean;
|
||||
markRead: () => void;
|
||||
merge: (other: MessageModel) => void;
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
import { removeFromCache } from './cache';
|
||||
import { EnvelopePlus } from './types';
|
||||
|
||||
async function unpairingRequestIsLegit(source: string, ourPubKey: string) {
|
||||
const { textsecure, storage, lokiFileServerAPI } = window;
|
||||
|
||||
const isSecondary = textsecure.storage.get('isSecondaryDevice');
|
||||
if (!isSecondary) {
|
||||
return false;
|
||||
}
|
||||
const primaryPubKey = storage.get('primaryDevicePubKey');
|
||||
// TODO: allow unpairing from any paired device?
|
||||
if (source !== primaryPubKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const primaryMapping = await lokiFileServerAPI.getUserDeviceMapping(
|
||||
primaryPubKey
|
||||
);
|
||||
|
||||
// If we don't have a mapping on the primary then we have been unlinked
|
||||
if (!primaryMapping) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// We expect the primary device to have updated its mapping
|
||||
// before sending the unpairing request
|
||||
const found = primaryMapping.authorisations.find(
|
||||
(authorisation: any) => authorisation.secondaryDevicePubKey === ourPubKey
|
||||
);
|
||||
|
||||
// our pubkey should NOT be in the primary device mapping
|
||||
return !found;
|
||||
}
|
||||
|
||||
async function clearAppAndRestart() {
|
||||
// remove our device mapping annotations from file server
|
||||
await window.lokiFileServerAPI.clearOurDeviceMappingAnnotations();
|
||||
// Delete the account and restart
|
||||
try {
|
||||
await window.Signal.Logs.deleteAll();
|
||||
await window.Signal.Data.removeAll();
|
||||
await window.Signal.Data.close();
|
||||
await window.Signal.Data.removeDB();
|
||||
await window.Signal.Data.removeOtherData();
|
||||
// TODO generate an empty db with a flag
|
||||
// to display a message about the unpairing
|
||||
// after the app restarts
|
||||
} catch (error) {
|
||||
window.log.error(
|
||||
'Something went wrong deleting all data:',
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
}
|
||||
window.restart();
|
||||
}
|
||||
|
||||
export async function handleUnpairRequest(
|
||||
envelope: EnvelopePlus,
|
||||
ourPubKey: string
|
||||
) {
|
||||
// TODO: move high-level pairing logic to libloki.multidevice.xx
|
||||
|
||||
const legit = await unpairingRequestIsLegit(envelope.source, ourPubKey);
|
||||
|
||||
removeFromCache(envelope);
|
||||
if (legit) {
|
||||
await clearAppAndRestart();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,685 @@
|
|||
import { queueAttachmentDownloads } from './attachments';
|
||||
|
||||
import { Quote } from './types';
|
||||
import { ConversationModel } from './conversation';
|
||||
import { EndSessionType, MessageModel } from './message';
|
||||
|
||||
async function handleGroups(
|
||||
conversation: ConversationModel,
|
||||
group: any,
|
||||
source: any
|
||||
): Promise<any> {
|
||||
const _ = window.Lodash;
|
||||
const textsecure = window.textsecure;
|
||||
const GROUP_TYPES = textsecure.protobuf.GroupContext.Type;
|
||||
|
||||
// TODO: this should be primary device id!
|
||||
const ourNumber = textsecure.storage.user.getNumber();
|
||||
|
||||
let groupUpdate = null;
|
||||
|
||||
// conversation attributes
|
||||
const attributes: any = {
|
||||
type: 'group',
|
||||
groupId: group.id,
|
||||
};
|
||||
|
||||
const oldMembers = conversation.get('members');
|
||||
|
||||
if (group.type === GROUP_TYPES.UPDATE) {
|
||||
attributes.name = group.name;
|
||||
attributes.members = group.members;
|
||||
|
||||
groupUpdate =
|
||||
conversation.changedAttributes(_.pick(group, 'name', 'avatar')) || {};
|
||||
|
||||
const addedMembers = _.difference(attributes.members, oldMembers);
|
||||
if (addedMembers.length > 0) {
|
||||
groupUpdate.joined = addedMembers;
|
||||
}
|
||||
if (conversation.get('left')) {
|
||||
// TODO: Maybe we shouldn't assume this message adds us:
|
||||
// we could maybe still get this message by mistake
|
||||
window.log.warn('re-added to a left group');
|
||||
attributes.left = false;
|
||||
}
|
||||
|
||||
if (attributes.isKickedFromGroup) {
|
||||
// Assume somebody re-invited us since we received this update
|
||||
attributes.isKickedFromGroup = false;
|
||||
}
|
||||
|
||||
// Check if anyone got kicked:
|
||||
const removedMembers = _.difference(oldMembers, attributes.members);
|
||||
|
||||
if (removedMembers.includes(ourNumber)) {
|
||||
groupUpdate.kicked = 'You';
|
||||
attributes.isKickedFromGroup = true;
|
||||
} else {
|
||||
groupUpdate.kicked = removedMembers;
|
||||
}
|
||||
} else if (group.type === GROUP_TYPES.QUIT) {
|
||||
if (source === ourNumber) {
|
||||
attributes.left = true;
|
||||
groupUpdate = { left: 'You' };
|
||||
} else {
|
||||
groupUpdate = { left: source };
|
||||
}
|
||||
attributes.members = _.without(oldMembers, source);
|
||||
}
|
||||
|
||||
conversation.set(attributes);
|
||||
|
||||
return groupUpdate;
|
||||
}
|
||||
|
||||
function handleSessionReset(
|
||||
conversation: ConversationModel,
|
||||
message: MessageModel
|
||||
) {
|
||||
const endSessionType = conversation.isSessionResetReceived()
|
||||
? EndSessionType.ONGOING
|
||||
: EndSessionType.DONE;
|
||||
message.set({ endSessionType });
|
||||
}
|
||||
|
||||
function contentTypeSupported(type: any): boolean {
|
||||
const Chrome = window.Signal.Util.GoogleChrome;
|
||||
return Chrome.isImageTypeSupported(type) || Chrome.isVideoTypeSupported(type);
|
||||
}
|
||||
|
||||
async function copyFromQuotedMessage(
|
||||
msg: MessageModel,
|
||||
quote: Quote,
|
||||
attemptCount: number = 1
|
||||
): Promise<void> {
|
||||
const _ = window.Lodash;
|
||||
const { Whisper, MessageController } = window;
|
||||
const { upgradeMessageSchema } = window.Signal.Migrations;
|
||||
const { Message: TypedMessage, Errors } = window.Signal.Types;
|
||||
|
||||
if (!quote) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { attachments, id, author } = quote;
|
||||
const firstAttachment = attachments[0];
|
||||
|
||||
const collection = await window.Signal.Data.getMessagesBySentAt(id, {
|
||||
MessageCollection: Whisper.MessageCollection,
|
||||
});
|
||||
const found = collection.find((item: any) => {
|
||||
const messageAuthor = item.getContact();
|
||||
|
||||
return messageAuthor && author === messageAuthor.id;
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
// Exponential backoff, giving up after 5 attempts:
|
||||
if (attemptCount < 5) {
|
||||
setTimeout(() => {
|
||||
window.log.info(
|
||||
`Looking for the message id : ${id}, attempt: ${attemptCount + 1}`
|
||||
);
|
||||
copyFromQuotedMessage(msg, quote, attemptCount + 1).ignore();
|
||||
}, attemptCount * attemptCount * 500);
|
||||
}
|
||||
|
||||
quote.referencedMessageNotFound = true;
|
||||
return;
|
||||
}
|
||||
|
||||
window.log.info(`Found quoted message id: ${id}`);
|
||||
quote.referencedMessageNotFound = false;
|
||||
|
||||
const queryMessage = MessageController.register(found.id, found);
|
||||
quote.text = queryMessage.get('body');
|
||||
|
||||
if (attemptCount > 1) {
|
||||
// Normally the caller would save the message, but in case we are
|
||||
// called by a timer, we need to update the message manually
|
||||
msg.set({ quote });
|
||||
await window.Signal.Data.saveMessage(msg.attributes, {
|
||||
Message: Whisper.Message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!firstAttachment || !contentTypeSupported(firstAttachment)) {
|
||||
return;
|
||||
}
|
||||
|
||||
firstAttachment.thumbnail = null;
|
||||
|
||||
try {
|
||||
if (
|
||||
queryMessage.get('schemaVersion') <
|
||||
TypedMessage.VERSION_NEEDED_FOR_DISPLAY
|
||||
) {
|
||||
const upgradedMessage = await upgradeMessageSchema(
|
||||
queryMessage.attributes
|
||||
);
|
||||
queryMessage.set(upgradedMessage);
|
||||
await window.Signal.Data.saveMessage(upgradedMessage, {
|
||||
Message: Whisper.Message,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
window.log.error(
|
||||
'Problem upgrading message quoted message from database',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const queryAttachments = queryMessage.get('attachments') || [];
|
||||
|
||||
if (queryAttachments.length > 0) {
|
||||
const queryFirst = queryAttachments[0];
|
||||
const { thumbnail } = queryFirst;
|
||||
|
||||
if (thumbnail && thumbnail.path) {
|
||||
firstAttachment.thumbnail = {
|
||||
...thumbnail,
|
||||
copied: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const queryPreview = queryMessage.get('preview') || [];
|
||||
if (queryPreview.length > 0) {
|
||||
const queryFirst = queryPreview[0];
|
||||
const { image } = queryFirst;
|
||||
|
||||
if (image && image.path) {
|
||||
firstAttachment.thumbnail = {
|
||||
...image,
|
||||
copied: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle expiration timer as part of a regular message
|
||||
function handleExpireTimer(
|
||||
source: string,
|
||||
message: MessageModel,
|
||||
expireTimer: number,
|
||||
conversation: ConversationModel
|
||||
) {
|
||||
const oldValue = conversation.get('expireTimer');
|
||||
|
||||
if (expireTimer) {
|
||||
message.set({ expireTimer });
|
||||
|
||||
if (expireTimer !== oldValue) {
|
||||
conversation.updateExpirationTimer(
|
||||
expireTimer,
|
||||
source,
|
||||
message.get('received_at'),
|
||||
{
|
||||
fromGroupUpdate: message.isGroupUpdate(), // WHAT DOES GROUP UPDATE HAVE TO DO WITH THIS???
|
||||
}
|
||||
);
|
||||
}
|
||||
} else if (oldValue && !message.isGroupUpdate()) {
|
||||
// We only turn off timers if it's not a group update
|
||||
conversation.updateExpirationTimer(
|
||||
null,
|
||||
source,
|
||||
message.get('received_at'),
|
||||
{}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLinkPreviews(
|
||||
messageBody: string,
|
||||
messagePreview: any,
|
||||
message: MessageModel
|
||||
) {
|
||||
const urls = window.Signal.LinkPreviews.findLinks(messageBody);
|
||||
const incomingPreview = messagePreview || [];
|
||||
const preview = incomingPreview.filter(
|
||||
(item: any) =>
|
||||
(item.image || item.title) &&
|
||||
urls.includes(item.url) &&
|
||||
window.Signal.LinkPreviews.isLinkInWhitelist(item.url)
|
||||
);
|
||||
if (preview.length < incomingPreview.length) {
|
||||
window.log.info(
|
||||
`${message.idForLogging()}: Eliminated ${preview.length -
|
||||
incomingPreview.length} previews with invalid urls'`
|
||||
);
|
||||
}
|
||||
|
||||
message.set({ preview });
|
||||
}
|
||||
|
||||
function processProfileKey(
|
||||
source: string,
|
||||
conversation: ConversationModel,
|
||||
sendingDeviceConversation: ConversationModel,
|
||||
profileKeyBuffer: any
|
||||
) {
|
||||
const ourNumber = window.textsecure.storage.user.getNumber();
|
||||
|
||||
const profileKey = profileKeyBuffer.toString('base64');
|
||||
if (source === ourNumber) {
|
||||
conversation.set({ profileSharing: true });
|
||||
} else if (conversation.isPrivate()) {
|
||||
conversation.setProfileKey(profileKey);
|
||||
} else {
|
||||
sendingDeviceConversation.setProfileKey(profileKey);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMentions(
|
||||
message: MessageModel,
|
||||
conversation: ConversationModel,
|
||||
ourPrimaryNumber: string
|
||||
) {
|
||||
const body = message.get('body');
|
||||
if (body && body.indexOf(`@${ourPrimaryNumber}`) !== -1) {
|
||||
conversation.set({ mentionedUs: true });
|
||||
}
|
||||
}
|
||||
|
||||
function updateReadStatus(
|
||||
message: MessageModel,
|
||||
conversation: ConversationModel
|
||||
) {
|
||||
const readSync = window.Whisper.ReadSyncs.forMessage(message);
|
||||
if (readSync) {
|
||||
const shouldExpire = message.get('expireTimer');
|
||||
const alreadyStarted = message.get('expirationStartTimestamp');
|
||||
if (shouldExpire && !alreadyStarted) {
|
||||
// Start message expiration timer
|
||||
const start = Math.min(readSync.get('read_at'), Date.now());
|
||||
message.set('expirationStartTimestamp', start);
|
||||
}
|
||||
}
|
||||
if (readSync || message.isExpirationTimerUpdate()) {
|
||||
message.unset('unread');
|
||||
// This is primarily to allow the conversation to mark all older
|
||||
// messages as read, as is done when we receive a read sync for
|
||||
// a message we already know about.
|
||||
conversation.onReadMessage(message);
|
||||
} else {
|
||||
conversation.set({
|
||||
unreadCount: conversation.get('unreadCount') + 1,
|
||||
isArchived: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleSyncedReceipts(
|
||||
message: MessageModel,
|
||||
conversation: ConversationModel
|
||||
) {
|
||||
const _ = window.Lodash;
|
||||
|
||||
const readReceipts = window.Whisper.ReadReceipts.forMessage(
|
||||
conversation,
|
||||
message
|
||||
);
|
||||
if (readReceipts.length) {
|
||||
const readBy = readReceipts.map((receipt: any) => receipt.get('reader'));
|
||||
message.set({
|
||||
read_by: _.union(message.get('read_by'), readBy),
|
||||
});
|
||||
}
|
||||
|
||||
const deliveryReceipts = window.Whisper.DeliveryReceipts.forMessage(
|
||||
conversation,
|
||||
message
|
||||
);
|
||||
|
||||
if (deliveryReceipts.length) {
|
||||
handleSyncDeliveryReceipts(message, deliveryReceipts);
|
||||
}
|
||||
|
||||
// A sync'd message to ourself is automatically considered read and delivered
|
||||
const recipients = conversation.getRecipients();
|
||||
if (conversation.isMe()) {
|
||||
message.set({
|
||||
read_by: recipients,
|
||||
delivered_to: recipients,
|
||||
});
|
||||
}
|
||||
|
||||
message.set({ recipients });
|
||||
}
|
||||
|
||||
function handleSyncDeliveryReceipts(message: MessageModel, receipts: any) {
|
||||
const _ = window.Lodash;
|
||||
|
||||
const sources = receipts.map((receipt: any) => receipt.get('source'));
|
||||
|
||||
const deliveredTo = _.union(message.get('delivered_to') || [], sources);
|
||||
|
||||
const deliveredCount = deliveredTo.length;
|
||||
|
||||
message.set({
|
||||
delivered: deliveredCount,
|
||||
delivered_to: deliveredTo,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleFriendRequest(
|
||||
message: MessageModel,
|
||||
source: string,
|
||||
primarySource: string,
|
||||
sendingDeviceConversation: ConversationModel
|
||||
) {
|
||||
if (message.isFriendRequest()) {
|
||||
/*
|
||||
Here is the before and after state diagram for the operation before.
|
||||
|
||||
None -> RequestReceived
|
||||
PendingSend -> RequestReceived
|
||||
RequestReceived -> RequestReceived
|
||||
Sent -> Friends
|
||||
Expired -> Friends
|
||||
Friends -> Friends
|
||||
|
||||
The cases where we auto accept are the following:
|
||||
- We sent the user a friend request,
|
||||
and that user sent us a friend request.
|
||||
- We are friends with the user,
|
||||
and that user just sent us a friend request.
|
||||
*/
|
||||
|
||||
const isFriend = sendingDeviceConversation.isFriend();
|
||||
const hasSentFriendRequest = sendingDeviceConversation.hasSentFriendRequest();
|
||||
const autoAccept = isFriend || hasSentFriendRequest;
|
||||
|
||||
if (autoAccept) {
|
||||
message.set({ friendStatus: 'accepted' });
|
||||
}
|
||||
|
||||
window.libloki.api.debug.logNormalFriendRequest(
|
||||
`Received a NORMAL_FRIEND_REQUEST from source: ${source}, primarySource: ${primarySource}, isAlreadyFriend: ${isFriend}, didWeAlreadySentFR: ${hasSentFriendRequest}`
|
||||
);
|
||||
|
||||
if (isFriend) {
|
||||
// Why end session here?
|
||||
window.Whisper.events.trigger('endSession', source);
|
||||
} else if (hasSentFriendRequest) {
|
||||
await sendingDeviceConversation.onFriendRequestAccepted();
|
||||
} else {
|
||||
await sendingDeviceConversation.onFriendRequestReceived();
|
||||
}
|
||||
} else if (message.get('type') === 'incoming') {
|
||||
// Responses to a FR will be handled here:
|
||||
await sendingDeviceConversation.onFriendRequestAccepted();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegularMessage(
|
||||
conversation: ConversationModel,
|
||||
message: MessageModel,
|
||||
initialMessage: any,
|
||||
source: string,
|
||||
isGroupMessage: boolean,
|
||||
ourNumber: any,
|
||||
primarySource: any
|
||||
) {
|
||||
const _ = window.Lodash;
|
||||
const {
|
||||
textsecure,
|
||||
Whisper,
|
||||
ConversationController,
|
||||
MessageController,
|
||||
libloki,
|
||||
} = window;
|
||||
const { upgradeMessageSchema } = window.Signal.Migrations;
|
||||
|
||||
const type = message.get('type');
|
||||
|
||||
await copyFromQuotedMessage(message, initialMessage.quote);
|
||||
|
||||
// `upgradeMessageSchema` only seems to add `schemaVersion: 10` to the message
|
||||
const dataMessage = await upgradeMessageSchema(initialMessage);
|
||||
|
||||
const now = new Date().getTime();
|
||||
|
||||
if (dataMessage.group) {
|
||||
// This is not necessarily a group update message, it could also be a regular group message
|
||||
const groupUpdate = await handleGroups(
|
||||
conversation,
|
||||
dataMessage.group,
|
||||
source
|
||||
);
|
||||
if (groupUpdate !== null) {
|
||||
message.set({ group_update: groupUpdate });
|
||||
}
|
||||
}
|
||||
|
||||
if (dataMessage.groupInvitation) {
|
||||
message.set({ groupInvitation: dataMessage.groupInvitation });
|
||||
}
|
||||
|
||||
handleLinkPreviews(dataMessage.body, dataMessage.preview, message);
|
||||
|
||||
message.set({
|
||||
flags: dataMessage.flags,
|
||||
hasAttachments: dataMessage.hasAttachments,
|
||||
hasFileAttachments: dataMessage.hasFileAttachments,
|
||||
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
|
||||
quote: dataMessage.quote,
|
||||
schemaVersion: dataMessage.schemaVersion,
|
||||
attachments: dataMessage.attachments,
|
||||
body: dataMessage.body,
|
||||
contact: dataMessage.contact,
|
||||
conversationId: conversation.id,
|
||||
decrypted_at: now,
|
||||
errors: [],
|
||||
});
|
||||
|
||||
conversation.set({ active_at: now });
|
||||
|
||||
// Re-enable typing if re-joined the group
|
||||
conversation.updateTextInputState();
|
||||
|
||||
// Handle expireTimer found directly as part of a regular message
|
||||
handleExpireTimer(source, message, dataMessage.expireTimer, conversation);
|
||||
|
||||
handleMentions(message, conversation, ourNumber);
|
||||
|
||||
if (type === 'incoming' || type === 'friend-request') {
|
||||
updateReadStatus(message, conversation);
|
||||
}
|
||||
|
||||
if (type === 'outgoing') {
|
||||
handleSyncedReceipts(message, conversation);
|
||||
}
|
||||
|
||||
const conversationTimestamp = conversation.get('timestamp');
|
||||
if (
|
||||
!conversationTimestamp ||
|
||||
message.get('sent_at') > conversationTimestamp
|
||||
) {
|
||||
conversation.lastMessage = message.getNotificationText();
|
||||
conversation.set({
|
||||
timestamp: message.get('sent_at'),
|
||||
});
|
||||
}
|
||||
|
||||
const sendingDeviceConversation = await ConversationController.getOrCreateAndWait(
|
||||
source,
|
||||
'private'
|
||||
);
|
||||
|
||||
if (dataMessage.profileKey) {
|
||||
processProfileKey(
|
||||
source,
|
||||
conversation,
|
||||
sendingDeviceConversation,
|
||||
dataMessage.profileKey
|
||||
);
|
||||
}
|
||||
|
||||
// Make sure friend request logic doesn't trigger on messages aimed at groups
|
||||
if (!isGroupMessage) {
|
||||
// We already handled (and returned) session request and auto Friend Request before,
|
||||
// so that can only be a normal Friend Request
|
||||
await handleFriendRequest(
|
||||
message,
|
||||
source,
|
||||
primarySource,
|
||||
sendingDeviceConversation
|
||||
);
|
||||
}
|
||||
|
||||
if (source !== ourNumber) {
|
||||
message.set({ source: primarySource });
|
||||
}
|
||||
}
|
||||
|
||||
function handleExpirationTimerUpdate(
|
||||
conversation: ConversationModel,
|
||||
message: MessageModel,
|
||||
source: string,
|
||||
expireTimer: number
|
||||
) {
|
||||
// TODO: if the message is an expiration timer update, it
|
||||
// shouldn't be responsible for anything else!!!
|
||||
message.set({
|
||||
expirationTimerUpdate: {
|
||||
source,
|
||||
expireTimer,
|
||||
},
|
||||
});
|
||||
conversation.set({ expireTimer });
|
||||
|
||||
window.log.info("Update conversation 'expireTimer'", {
|
||||
id: conversation.idForLogging(),
|
||||
expireTimer,
|
||||
source: 'handleDataMessage',
|
||||
});
|
||||
|
||||
conversation.updateExpirationTimer(
|
||||
expireTimer,
|
||||
source,
|
||||
message.get('received_at'),
|
||||
{
|
||||
fromGroupUpdate: message.isGroupUpdate(), // WHAT DOES GROUP UPDATE HAVE TO DO WITH THIS???
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleMessageJob(
|
||||
message: MessageModel,
|
||||
conversation: ConversationModel,
|
||||
initialMessage: any,
|
||||
ourNumber: string,
|
||||
confirm: any,
|
||||
source: string,
|
||||
isGroupMessage: any,
|
||||
primarySource: any
|
||||
) {
|
||||
window.log.info(
|
||||
`Starting handleDataMessage for message ${message.idForLogging()} in conversation ${conversation.idForLogging()}`
|
||||
);
|
||||
|
||||
try {
|
||||
message.set({ flags: initialMessage.flags });
|
||||
|
||||
if (message.isEndSession()) {
|
||||
handleSessionReset(conversation, message);
|
||||
} else if (message.isExpirationTimerUpdate()) {
|
||||
const { expireTimer } = initialMessage;
|
||||
handleExpirationTimerUpdate(conversation, message, source, expireTimer);
|
||||
} else {
|
||||
await handleRegularMessage(
|
||||
conversation,
|
||||
message,
|
||||
initialMessage,
|
||||
source,
|
||||
isGroupMessage,
|
||||
ourNumber,
|
||||
primarySource
|
||||
);
|
||||
}
|
||||
|
||||
const { Whisper, MessageController, ConversationController } = window;
|
||||
|
||||
const id = await window.Signal.Data.saveMessage(message.attributes, {
|
||||
Message: Whisper.Message,
|
||||
});
|
||||
message.set({ id });
|
||||
MessageController.register(message.id, message);
|
||||
|
||||
// Note that this can save the message again, if jobs were queued. We need to
|
||||
// call it after we have an id for this message, because the jobs refer back
|
||||
// to their source message.
|
||||
await queueAttachmentDownloads(message);
|
||||
await conversation.saveChangesToDB();
|
||||
|
||||
conversation.trigger('newmessage', message);
|
||||
|
||||
try {
|
||||
// We go to the database here because, between the message save above and
|
||||
// the previous line's trigger() call, we might have marked all messages
|
||||
// unread in the database. This message might already be read!
|
||||
const fetched = await window.Signal.Data.getMessageById(
|
||||
message.get('id'),
|
||||
{
|
||||
Message: Whisper.Message,
|
||||
}
|
||||
);
|
||||
const previousUnread = message.get('unread');
|
||||
|
||||
// Important to update message with latest read state from database
|
||||
message.merge(fetched);
|
||||
|
||||
if (previousUnread !== message.get('unread')) {
|
||||
window.log.warn(
|
||||
'Caught race condition on new message read state! ' +
|
||||
'Manually starting timers.'
|
||||
);
|
||||
// We call markRead() even though the message is already
|
||||
// marked read because we need to start expiration
|
||||
// timers, etc.
|
||||
message.markRead();
|
||||
}
|
||||
} catch (error) {
|
||||
window.log.warn(
|
||||
'handleDataMessage: Message',
|
||||
message.idForLogging(),
|
||||
'was deleted'
|
||||
);
|
||||
}
|
||||
|
||||
const sendingDeviceConversation = await ConversationController.getOrCreateAndWait(
|
||||
source,
|
||||
'private'
|
||||
);
|
||||
|
||||
const isFriend = sendingDeviceConversation.isFriend();
|
||||
const hasSentFriendRequest = sendingDeviceConversation.hasSentFriendRequest();
|
||||
const autoAccept =
|
||||
message.isFriendRequest() && (isFriend || hasSentFriendRequest);
|
||||
|
||||
if (message.get('unread')) {
|
||||
// Need to do this here because the conversation has already changed states
|
||||
if (autoAccept) {
|
||||
await conversation.notifyFriendRequest(source, 'accepted');
|
||||
} else {
|
||||
conversation.notify(message);
|
||||
}
|
||||
}
|
||||
|
||||
confirm();
|
||||
} catch (error) {
|
||||
const errorForLog = error && error.stack ? error.stack : error;
|
||||
window.log.error(
|
||||
'handleDataMessage',
|
||||
message.idForLogging(),
|
||||
'error:',
|
||||
errorForLog
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,824 @@
|
|||
// TODO: fix libloki and textsecure not being available here yet
|
||||
|
||||
import { preprocessGroupMessage } from './groups';
|
||||
import { handleMessageJob } from './queuedJob';
|
||||
import { handleEndSession } from './sessionHandling';
|
||||
import { handleUnpairRequest } from './multidevice';
|
||||
import { EnvelopePlus } from './types';
|
||||
import { ConversationModel } from './conversation';
|
||||
import { EndSessionType, MessageModel } from './message';
|
||||
import { downloadAttachment } from './attachments';
|
||||
import { handleMediumGroupUpdate } from './mediumGroups';
|
||||
|
||||
import { SignalService } from './../protobuf';
|
||||
|
||||
import { removeFromCache } from './cache';
|
||||
import { toNumber } from 'lodash';
|
||||
import { DataMessage } from '../session/messages/outgoing';
|
||||
|
||||
export { handleEndSession, handleMediumGroupUpdate };
|
||||
|
||||
const _ = window.Lodash;
|
||||
|
||||
interface MessageCreationData {
|
||||
timestamp: number;
|
||||
isPublic: boolean;
|
||||
receivedAt: number;
|
||||
sourceDevice: number; // always 1 isn't it?
|
||||
unidentifiedDeliveryReceived: any; // ???
|
||||
friendRequest: boolean;
|
||||
isRss: boolean;
|
||||
source: boolean;
|
||||
serverId: string;
|
||||
|
||||
// Needed for synced outgoing messages
|
||||
unidentifiedStatus: any; // ???
|
||||
expirationStartTimestamp: any; // ???
|
||||
destination: string;
|
||||
}
|
||||
|
||||
function initIncomingMessage(data: MessageCreationData): MessageModel {
|
||||
const {
|
||||
timestamp,
|
||||
isPublic,
|
||||
receivedAt,
|
||||
sourceDevice,
|
||||
unidentifiedDeliveryReceived,
|
||||
friendRequest,
|
||||
isRss,
|
||||
source,
|
||||
serverId,
|
||||
} = data;
|
||||
|
||||
const type = friendRequest ? 'friend-request' : 'incoming';
|
||||
|
||||
const messageData: any = {
|
||||
source,
|
||||
sourceDevice,
|
||||
serverId, // + (not present below in `createSentMessage`)
|
||||
sent_at: timestamp,
|
||||
received_at: receivedAt || Date.now(),
|
||||
conversationId: source,
|
||||
unidentifiedDeliveryReceived, // +
|
||||
type,
|
||||
direction: 'incoming', // +
|
||||
unread: 1, // +
|
||||
isPublic, // +
|
||||
isRss, // +
|
||||
};
|
||||
|
||||
if (friendRequest) {
|
||||
messageData.friendStatus = 'pending';
|
||||
}
|
||||
|
||||
return new window.Whisper.Message(messageData);
|
||||
}
|
||||
|
||||
function createSentMessage(data: MessageCreationData): MessageModel {
|
||||
const now = Date.now();
|
||||
let sentTo = [];
|
||||
|
||||
const {
|
||||
timestamp,
|
||||
isPublic,
|
||||
receivedAt,
|
||||
sourceDevice,
|
||||
unidentifiedStatus,
|
||||
expirationStartTimestamp,
|
||||
destination,
|
||||
} = data;
|
||||
|
||||
let unidentifiedDeliveries;
|
||||
|
||||
if (unidentifiedStatus && unidentifiedStatus.length) {
|
||||
sentTo = unidentifiedStatus.map((item: any) => item.destination);
|
||||
const unidentified = _.filter(unidentifiedStatus, (item: any) =>
|
||||
Boolean(item.unidentified)
|
||||
);
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
unidentifiedDeliveries = unidentified.map((item: any) => item.destination);
|
||||
}
|
||||
|
||||
const sentSpecificFields = {
|
||||
sent_to: sentTo,
|
||||
sent: true,
|
||||
unidentifiedDeliveries: unidentifiedDeliveries || [],
|
||||
expirationStartTimestamp: Math.min(
|
||||
expirationStartTimestamp || data.timestamp || now,
|
||||
now
|
||||
),
|
||||
};
|
||||
|
||||
const messageData: any = {
|
||||
source: window.textsecure.storage.user.getNumber(),
|
||||
sourceDevice,
|
||||
sent_at: timestamp,
|
||||
received_at: isPublic ? receivedAt : now,
|
||||
conversationId: destination, // conversation ID will might change later (if it is a group)
|
||||
type: 'outgoing',
|
||||
...sentSpecificFields,
|
||||
};
|
||||
|
||||
return new window.Whisper.Message(messageData);
|
||||
}
|
||||
|
||||
function createMessage(
|
||||
data: MessageCreationData,
|
||||
isIncoming: boolean
|
||||
): MessageModel {
|
||||
if (isIncoming) {
|
||||
return initIncomingMessage(data);
|
||||
} else {
|
||||
return createSentMessage(data);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProfileUpdate(
|
||||
profileKeyBuffer: any,
|
||||
convoId: string,
|
||||
convoType: ConversationType,
|
||||
isIncoming: boolean
|
||||
) {
|
||||
const profileKey = profileKeyBuffer.toString('base64');
|
||||
|
||||
if (!isIncoming) {
|
||||
const receiver = await window.ConversationController.getOrCreateAndWait(
|
||||
convoId,
|
||||
convoType
|
||||
);
|
||||
// First set profileSharing = true for the conversation we sent to
|
||||
receiver.set({ profileSharing: true });
|
||||
await receiver.saveChangesToDB();
|
||||
|
||||
// Then we update our own profileKey if it's different from what we have
|
||||
const ourNumber = window.textsecure.storage.user.getNumber();
|
||||
const me = await window.ConversationController.getOrCreate(
|
||||
ourNumber,
|
||||
'private'
|
||||
);
|
||||
|
||||
// Will do the save for us if needed
|
||||
await me.setProfileKey(profileKey);
|
||||
} else {
|
||||
const sender = await window.ConversationController.getOrCreateAndWait(
|
||||
convoId,
|
||||
'private'
|
||||
);
|
||||
|
||||
// Will do the save for us
|
||||
await sender.setProfileKey(profileKey);
|
||||
}
|
||||
}
|
||||
|
||||
enum ConversationType {
|
||||
GROUP = 'group',
|
||||
PRIVATE = 'private',
|
||||
}
|
||||
|
||||
function sendDeliveryReceipt(source: string, timestamp: any) {
|
||||
const { wrap, sendOptions } = window.ConversationController.prepareForSend(
|
||||
source
|
||||
);
|
||||
wrap(
|
||||
window.textsecure.messaging.sendDeliveryReceipt(
|
||||
source,
|
||||
timestamp,
|
||||
sendOptions
|
||||
)
|
||||
).catch((error: any) => {
|
||||
window.log.error(
|
||||
`Failed to send delivery receipt to ${source} for message ${timestamp}:`,
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
interface MessageId {
|
||||
source: any;
|
||||
sourceDevice: any;
|
||||
timestamp: any;
|
||||
}
|
||||
|
||||
async function isMessageDuplicate({
|
||||
source,
|
||||
sourceDevice,
|
||||
timestamp,
|
||||
}: MessageId) {
|
||||
const { Errors } = window.Signal.Types;
|
||||
|
||||
try {
|
||||
const result = await window.Signal.Data.getMessageBySender(
|
||||
{ source, sourceDevice, sent_at: timestamp },
|
||||
{
|
||||
Message: window.Whisper.Message,
|
||||
}
|
||||
);
|
||||
|
||||
return Boolean(result);
|
||||
} catch (error) {
|
||||
window.log.error('isMessageDuplicate error:', Errors.toLogFormat(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSessionRequest(source: string) {
|
||||
await window.libloki.api.sendSessionEstablishedMessage(source);
|
||||
}
|
||||
|
||||
async function handleSecondaryDeviceFriendRequest(pubKey: string) {
|
||||
// fetch the device mapping from the server
|
||||
const deviceMapping = await window.lokiFileServerAPI.getUserDeviceMapping(
|
||||
pubKey
|
||||
);
|
||||
if (!deviceMapping) {
|
||||
return false;
|
||||
}
|
||||
// Only handle secondary pubkeys
|
||||
if (deviceMapping.isPrimary === '1' || !deviceMapping.authorisations) {
|
||||
return false;
|
||||
}
|
||||
const { authorisations } = deviceMapping;
|
||||
// Secondary devices should only have 1 authorisation from a primary device
|
||||
if (authorisations.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
const authorisation = authorisations[0];
|
||||
if (!authorisation) {
|
||||
return false;
|
||||
}
|
||||
if (!authorisation.grantSignature) {
|
||||
return false;
|
||||
}
|
||||
const isValid = await window.libloki.crypto.validateAuthorisation(
|
||||
authorisation
|
||||
);
|
||||
if (!isValid) {
|
||||
return false;
|
||||
}
|
||||
const correctSender = pubKey === authorisation.secondaryDevicePubKey;
|
||||
if (!correctSender) {
|
||||
return false;
|
||||
}
|
||||
const { primaryDevicePubKey } = authorisation;
|
||||
// ensure the primary device is a friend
|
||||
const c = window.ConversationController.get(primaryDevicePubKey);
|
||||
if (!c || !(await c.isFriendWithAnyDevice())) {
|
||||
return false;
|
||||
}
|
||||
await window.libloki.storage.savePairingAuthorisation(authorisation);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleAutoFriendRequestMessage(
|
||||
source: string,
|
||||
ourPubKey: string,
|
||||
conversation: ConversationModel
|
||||
) {
|
||||
const isMe = source === ourPubKey;
|
||||
// If we got a friend request message (session request excluded) or
|
||||
// if we're not friends with the current user that sent this private message
|
||||
// Check to see if we need to auto accept their friend request
|
||||
if (isMe) {
|
||||
window.log.info('refusing to add a friend request to ourselves');
|
||||
throw new Error('Cannot add a friend request for ourselves!');
|
||||
}
|
||||
// auto-accept friend request if the device is paired to one of our friend's primary device
|
||||
const shouldAutoAcceptFR = await handleSecondaryDeviceFriendRequest(source);
|
||||
if (shouldAutoAcceptFR) {
|
||||
window.libloki.api.debug.logAutoFriendRequest(
|
||||
`Received AUTO_FRIEND_REQUEST from source: ${source}`
|
||||
);
|
||||
// Directly setting friend request status to skip the pending state
|
||||
await conversation.setFriendRequestStatus(
|
||||
window.friends.friendRequestStatusEnum.friends
|
||||
);
|
||||
// sending a message back = accepting friend request
|
||||
|
||||
window.libloki.api.sendBackgroundMessage(
|
||||
source,
|
||||
window.textsecure.OutgoingMessage.DebugMessageType.AUTO_FR_ACCEPT
|
||||
);
|
||||
// return true to notify the message is fully processed
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getEnvelopeId(envelope: EnvelopePlus) {
|
||||
if (envelope.source) {
|
||||
return `${envelope.source}.${envelope.sourceDevice} ${toNumber(
|
||||
envelope.timestamp
|
||||
)} (${envelope.id})`;
|
||||
}
|
||||
|
||||
return envelope.id;
|
||||
}
|
||||
|
||||
function isMessageEmpty(message: SignalService.DataMessage) {
|
||||
const {
|
||||
flags,
|
||||
body,
|
||||
attachments,
|
||||
group,
|
||||
quote,
|
||||
contact,
|
||||
preview,
|
||||
groupInvitation,
|
||||
mediumGroupUpdate,
|
||||
} = message;
|
||||
|
||||
return (
|
||||
!flags &&
|
||||
_.isEmpty(body) &&
|
||||
_.isEmpty(attachments) &&
|
||||
_.isEmpty(group) &&
|
||||
_.isEmpty(quote) &&
|
||||
_.isEmpty(contact) &&
|
||||
_.isEmpty(preview) &&
|
||||
_.isEmpty(groupInvitation) &&
|
||||
_.isEmpty(mediumGroupUpdate)
|
||||
);
|
||||
}
|
||||
|
||||
function cleanAttachment(attachment: any) {
|
||||
return {
|
||||
..._.omit(attachment, 'thumbnail'),
|
||||
id: attachment.id.toString(),
|
||||
key: attachment.key ? attachment.key.toString('base64') : null,
|
||||
digest: attachment.digest ? attachment.digest.toString('base64') : null,
|
||||
};
|
||||
}
|
||||
|
||||
function cleanAttachments(decrypted: any) {
|
||||
const { quote, group } = decrypted;
|
||||
|
||||
// Here we go from binary to string/base64 in all AttachmentPointer digest/key fields
|
||||
|
||||
if (group && group.type === SignalService.GroupContext.Type.UPDATE) {
|
||||
if (group.avatar !== null) {
|
||||
group.avatar = cleanAttachment(group.avatar);
|
||||
}
|
||||
}
|
||||
|
||||
decrypted.attachments = (decrypted.attachments || []).map(cleanAttachment);
|
||||
decrypted.preview = (decrypted.preview || []).map((item: any) => {
|
||||
const { image } = item;
|
||||
|
||||
if (!image) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
image: cleanAttachment(image),
|
||||
};
|
||||
});
|
||||
|
||||
decrypted.contact = (decrypted.contact || []).map((item: any) => {
|
||||
const { avatar } = item;
|
||||
|
||||
if (!avatar || !avatar.avatar) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
avatar: {
|
||||
...item.avatar,
|
||||
avatar: cleanAttachment(item.avatar.avatar),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (quote) {
|
||||
if (quote.id) {
|
||||
quote.id = toNumber(quote.id);
|
||||
}
|
||||
|
||||
quote.attachments = (quote.attachments || []).map((item: any) => {
|
||||
const { thumbnail } = item;
|
||||
|
||||
if (!thumbnail) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
thumbnail: cleanAttachment(item.thumbnail),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function processDecrypted(envelope: EnvelopePlus, decrypted: any) {
|
||||
/* tslint:disable:no-bitwise */
|
||||
const FLAGS = SignalService.DataMessage.Flags;
|
||||
|
||||
// Now that its decrypted, validate the message and clean it up for consumer
|
||||
// processing
|
||||
// Note that messages may (generally) only perform one action and we ignore remaining
|
||||
// fields after the first action.
|
||||
|
||||
if (decrypted.flags == null) {
|
||||
decrypted.flags = 0;
|
||||
}
|
||||
if (decrypted.expireTimer == null) {
|
||||
decrypted.expireTimer = 0;
|
||||
}
|
||||
|
||||
if (decrypted.flags & FLAGS.END_SESSION) {
|
||||
decrypted.body = '';
|
||||
decrypted.attachments = [];
|
||||
decrypted.group = null;
|
||||
return Promise.resolve(decrypted);
|
||||
} else if (decrypted.flags & FLAGS.EXPIRATION_TIMER_UPDATE) {
|
||||
decrypted.body = '';
|
||||
decrypted.attachments = [];
|
||||
} else if (decrypted.flags & FLAGS.PROFILE_KEY_UPDATE) {
|
||||
decrypted.body = '';
|
||||
decrypted.attachments = [];
|
||||
} else if (decrypted.flags & FLAGS.SESSION_REQUEST) {
|
||||
// do nothing
|
||||
} else if (decrypted.flags & FLAGS.SESSION_RESTORE) {
|
||||
// do nothing
|
||||
} else if (decrypted.flags & FLAGS.UNPAIRING_REQUEST) {
|
||||
// do nothing
|
||||
} else if (decrypted.flags !== 0) {
|
||||
throw new Error('Unknown flags in message');
|
||||
}
|
||||
|
||||
if (decrypted.group) {
|
||||
decrypted.group.id = decrypted.group.id?.toBinary();
|
||||
|
||||
switch (decrypted.group.type) {
|
||||
case SignalService.GroupContext.Type.UPDATE:
|
||||
decrypted.body = '';
|
||||
decrypted.attachments = [];
|
||||
break;
|
||||
case SignalService.GroupContext.Type.QUIT:
|
||||
decrypted.body = '';
|
||||
decrypted.attachments = [];
|
||||
break;
|
||||
case SignalService.GroupContext.Type.DELIVER:
|
||||
decrypted.group.name = null;
|
||||
decrypted.group.members = [];
|
||||
decrypted.group.avatar = null;
|
||||
break;
|
||||
case SignalService.GroupContext.Type.REQUEST_INFO:
|
||||
decrypted.body = '';
|
||||
decrypted.attachments = [];
|
||||
break;
|
||||
default:
|
||||
removeFromCache(envelope);
|
||||
throw new Error('Unknown group message type');
|
||||
}
|
||||
}
|
||||
|
||||
const attachmentCount = decrypted.attachments.length;
|
||||
const ATTACHMENT_MAX = 32;
|
||||
if (attachmentCount > ATTACHMENT_MAX) {
|
||||
removeFromCache(envelope);
|
||||
throw new Error(
|
||||
`Too many attachments: ${attachmentCount} included in one message, max is ${ATTACHMENT_MAX}`
|
||||
);
|
||||
}
|
||||
|
||||
cleanAttachments(decrypted);
|
||||
|
||||
return decrypted;
|
||||
/* tslint:disable:no-bitwise */
|
||||
}
|
||||
|
||||
export async function updateProfile(
|
||||
conversation: any,
|
||||
profile: SignalService.DataMessage.ILokiProfile,
|
||||
profileKey: any
|
||||
) {
|
||||
const { dcodeIO, textsecure, Signal } = window;
|
||||
|
||||
// Retain old values unless changed:
|
||||
const newProfile = conversation.get('profile') || {};
|
||||
|
||||
newProfile.displayName = profile.displayName;
|
||||
|
||||
// TODO: may need to allow users to reset their avatars to null
|
||||
if (profile.avatar) {
|
||||
const prevPointer = conversation.get('avatarPointer');
|
||||
const needsUpdate = !prevPointer || !_.isEqual(prevPointer, profile.avatar);
|
||||
|
||||
if (needsUpdate) {
|
||||
conversation.set('avatarPointer', profile.avatar);
|
||||
conversation.set('profileKey', profileKey);
|
||||
|
||||
const downloaded = await downloadAttachment({
|
||||
url: profile.avatar,
|
||||
isRaw: true,
|
||||
});
|
||||
|
||||
// null => use jazzicon
|
||||
let path = null;
|
||||
if (profileKey) {
|
||||
// Convert profileKey to ArrayBuffer, if needed
|
||||
const encoding = typeof profileKey === 'string' ? 'base64' : null;
|
||||
try {
|
||||
const profileKeyArrayBuffer = dcodeIO.ByteBuffer.wrap(
|
||||
profileKey,
|
||||
encoding
|
||||
).toArrayBuffer();
|
||||
const decryptedData = await textsecure.crypto.decryptProfile(
|
||||
downloaded.data,
|
||||
profileKeyArrayBuffer
|
||||
);
|
||||
const upgraded = await Signal.Migrations.processNewAttachment({
|
||||
...downloaded,
|
||||
data: decryptedData,
|
||||
});
|
||||
({ path } = upgraded);
|
||||
} catch (e) {
|
||||
window.log.error(`Could not decrypt profile image: ${e}`);
|
||||
}
|
||||
}
|
||||
newProfile.avatar = path;
|
||||
}
|
||||
} else {
|
||||
newProfile.avatar = null;
|
||||
}
|
||||
|
||||
await conversation.setLokiProfile(newProfile);
|
||||
}
|
||||
|
||||
export async function handleDataMessage(
|
||||
envelope: EnvelopePlus,
|
||||
dataMessage: SignalService.DataMessage
|
||||
): Promise<void> {
|
||||
window.log.info('data message from', getEnvelopeId(envelope));
|
||||
|
||||
if (dataMessage.mediumGroupUpdate) {
|
||||
handleMediumGroupUpdate(envelope, dataMessage.mediumGroupUpdate).ignore();
|
||||
// TODO: investigate the meaning of this return value
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-bitwise
|
||||
if (dataMessage.flags & SignalService.DataMessage.Flags.END_SESSION) {
|
||||
await handleEndSession(envelope.source);
|
||||
}
|
||||
const message = await processDecrypted(envelope, dataMessage);
|
||||
const ourPubKey = window.textsecure.storage.user.getNumber();
|
||||
const senderPubKey = envelope.source;
|
||||
const isMe = senderPubKey === ourPubKey;
|
||||
const conversation = window.ConversationController.get(senderPubKey);
|
||||
|
||||
const { UNPAIRING_REQUEST } = SignalService.DataMessage.Flags;
|
||||
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const isUnpairingRequest = Boolean(message.flags & UNPAIRING_REQUEST);
|
||||
|
||||
if (isUnpairingRequest) {
|
||||
return handleUnpairRequest(envelope, ourPubKey);
|
||||
}
|
||||
|
||||
// Check if we need to update any profile names
|
||||
if (!isMe && conversation && message.profile) {
|
||||
await updateProfile(conversation, message.profile, message.profileKey);
|
||||
}
|
||||
if (isMessageEmpty(message)) {
|
||||
window.log.warn(`Message ${getEnvelopeId(envelope)} ignored; it was empty`);
|
||||
return removeFromCache(envelope);
|
||||
}
|
||||
|
||||
// Loki - Temp hack until new protocol
|
||||
// A friend request is a non-group text message which we haven't processed yet
|
||||
const isGroupMessage = Boolean(message.group || message.mediumGroupUpdate);
|
||||
const friendRequestStatusNoneOrExpired = conversation
|
||||
? conversation.isFriendRequestStatusNoneOrExpired()
|
||||
: true;
|
||||
const isFriendRequest =
|
||||
!isGroupMessage &&
|
||||
!_.isEmpty(message.body) &&
|
||||
friendRequestStatusNoneOrExpired;
|
||||
|
||||
const source = envelope.senderIdentity || senderPubKey;
|
||||
|
||||
const isOwnDevice = async (pubkey: string) => {
|
||||
const primaryDevice = window.storage.get('primaryDevicePubKey');
|
||||
const secondaryDevices = await window.libloki.storage.getPairedDevicesFor(
|
||||
primaryDevice
|
||||
);
|
||||
|
||||
const allDevices = [primaryDevice, ...secondaryDevices];
|
||||
return allDevices.includes(pubkey);
|
||||
};
|
||||
|
||||
const ownDevice = await isOwnDevice(source);
|
||||
|
||||
const ownMessage = conversation.isMediumGroup() && ownDevice;
|
||||
|
||||
const ev: any = {};
|
||||
if (ownMessage) {
|
||||
// Data messages for medium groups don't arrive as sync messages. Instead,
|
||||
// linked devices poll for group messages independently, thus they need
|
||||
// to recognise some of those messages at their own.
|
||||
ev.type = 'sent';
|
||||
} else {
|
||||
ev.type = 'message';
|
||||
}
|
||||
|
||||
if (envelope.senderIdentity) {
|
||||
message.group = {
|
||||
id: envelope.source,
|
||||
};
|
||||
}
|
||||
|
||||
ev.confirm = () => removeFromCache(envelope);
|
||||
ev.data = {
|
||||
friendRequest: isFriendRequest,
|
||||
source,
|
||||
sourceDevice: envelope.sourceDevice,
|
||||
timestamp: toNumber(envelope.timestamp),
|
||||
receivedAt: envelope.receivedAt,
|
||||
unidentifiedDeliveryReceived: envelope.unidentifiedDeliveryReceived,
|
||||
message,
|
||||
};
|
||||
|
||||
await handleMessageEvent(ev);
|
||||
}
|
||||
|
||||
// tslint:disable:cyclomatic-complexity max-func-body-length */
|
||||
export async function handleMessageEvent(event: any): Promise<void> {
|
||||
const { data, confirm } = event;
|
||||
|
||||
const isIncoming = event.type === 'message';
|
||||
|
||||
if (!data || !data.message) {
|
||||
window.log.warn('Invalid data passed to handleMessageEvent.', event);
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
|
||||
const { message, destination } = data;
|
||||
|
||||
let { source } = data;
|
||||
|
||||
const isGroupMessage = message.group;
|
||||
|
||||
const type = isGroupMessage
|
||||
? ConversationType.GROUP
|
||||
: ConversationType.PRIVATE;
|
||||
|
||||
// MAXIM: So id is actually conversationId
|
||||
const id = isIncoming ? source : destination;
|
||||
|
||||
const {
|
||||
PROFILE_KEY_UPDATE,
|
||||
SESSION_REQUEST,
|
||||
SESSION_RESTORE,
|
||||
} = SignalService.DataMessage.Flags;
|
||||
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const isProfileUpdate = Boolean(message.flags & PROFILE_KEY_UPDATE);
|
||||
|
||||
if (isProfileUpdate) {
|
||||
await handleProfileUpdate(message.profileKey, id, type, isIncoming);
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = createMessage(data, isIncoming);
|
||||
|
||||
// if the message is `sent` (from secondary device) we have to set the sender manually... (at least for now)
|
||||
source = source || msg.get('source');
|
||||
|
||||
const isDuplicate = await isMessageDuplicate(data);
|
||||
|
||||
if (isDuplicate) {
|
||||
// RSS expects duplicates, so squelch log
|
||||
if (!source.match(/^rss:/)) {
|
||||
window.log.warn('Received duplicate message', msg.idForLogging());
|
||||
}
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
|
||||
// Note(LOKI): don't send receipt for FR as we don't have a session yet
|
||||
const shouldSendReceipt =
|
||||
isIncoming &&
|
||||
data.unidentifiedDeliveryReceived &&
|
||||
!data.friendRequest &&
|
||||
!isGroupMessage;
|
||||
|
||||
if (shouldSendReceipt) {
|
||||
sendDeliveryReceipt(source, data.timestamp);
|
||||
}
|
||||
|
||||
await window.ConversationController.getOrCreateAndWait(id, type);
|
||||
|
||||
// =========== Process flags =============
|
||||
|
||||
/**
|
||||
* A session request message is a friend-request message with the flag
|
||||
* SESSION_REQUEST set to true.
|
||||
*/
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const sessionRequest =
|
||||
data.friendRequest && !!(message.flags & SESSION_REQUEST);
|
||||
|
||||
// NOTE: I don't need to create/use msg if it is a session request!
|
||||
|
||||
if (sessionRequest) {
|
||||
await handleSessionRequest(source);
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-bitwise
|
||||
if (message.flags & SESSION_RESTORE) {
|
||||
// Show that the session reset is "in progress" even though we had a valid session
|
||||
msg.set({ endSessionType: EndSessionType.ONGOING });
|
||||
}
|
||||
|
||||
const ourNumber = window.textsecure.storage.user.getNumber();
|
||||
|
||||
// AFR are only relevant for incoming messages
|
||||
if (isIncoming) {
|
||||
// the conversation with this real device
|
||||
const conversationOrigin = window.ConversationController.get(source);
|
||||
|
||||
// Session request have been dealt with before, so a friend request here is
|
||||
// not a session request message. Also, handleAutoFriendRequestMessage() only
|
||||
// handles the autoAccept logic of an auto friend request.
|
||||
if (
|
||||
data.friendRequest ||
|
||||
(!isGroupMessage && !conversationOrigin.isFriend())
|
||||
) {
|
||||
const accepted = await handleAutoFriendRequestMessage(
|
||||
source,
|
||||
ourNumber,
|
||||
conversationOrigin
|
||||
);
|
||||
|
||||
if (accepted) {
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================
|
||||
|
||||
// Conversation Id is:
|
||||
// - primarySource if it is an incoming DM message,
|
||||
// - destination if it is an outgoing message,
|
||||
// - group.id if it is a group message
|
||||
let conversationId = id;
|
||||
|
||||
const authorisation = await window.libloki.storage.getGrantAuthorisationForSecondaryPubKey(
|
||||
source
|
||||
);
|
||||
|
||||
const primarySource =
|
||||
(authorisation && authorisation.primaryDevicePubKey) || source;
|
||||
|
||||
if (isGroupMessage) {
|
||||
/* handle one part of the group logic here:
|
||||
handle requesting info of a new group,
|
||||
dropping an admin only update from a non admin, ...
|
||||
*/
|
||||
conversationId = message.group.id;
|
||||
const shouldReturn = await preprocessGroupMessage(
|
||||
source,
|
||||
message.group,
|
||||
primarySource
|
||||
);
|
||||
|
||||
// handleGroupMessage() can process fully a message in some cases
|
||||
// so we need to return early if that's the case
|
||||
if (shouldReturn) {
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (source !== ourNumber && authorisation) {
|
||||
// Ignore auth from our devices
|
||||
conversationId = authorisation.primaryDevicePubKey;
|
||||
}
|
||||
|
||||
// the conversation with the primary device of that source (can be the same as conversationOrigin)
|
||||
const conversation = window.ConversationController.get(conversationId);
|
||||
|
||||
conversation.queueJob(() => {
|
||||
handleMessageJob(
|
||||
msg,
|
||||
conversation,
|
||||
message,
|
||||
ourNumber,
|
||||
confirm,
|
||||
source,
|
||||
isGroupMessage,
|
||||
primarySource
|
||||
).ignore();
|
||||
});
|
||||
}
|
||||
// tslint:enable:cyclomatic-complexity max-func-body-length */
|
|
@ -0,0 +1,16 @@
|
|||
export async function handleEndSession(number: string): Promise<void> {
|
||||
window.log.info('got end session');
|
||||
|
||||
const { ConversationController } = window;
|
||||
|
||||
try {
|
||||
const conversation = ConversationController.get(number);
|
||||
if (conversation) {
|
||||
await conversation.onSessionResetReceived();
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
} catch (e) {
|
||||
window.log.error('Error getting conversation: ', number);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import { SignalService } from '../protobuf';
|
||||
|
||||
export interface Quote {
|
||||
id: any;
|
||||
author: any;
|
||||
attachments: Array<any>;
|
||||
text: string;
|
||||
referencedMessageNotFound: boolean;
|
||||
}
|
||||
|
||||
export interface EnvelopePlus extends SignalService.Envelope {
|
||||
senderIdentity: string; // Sender's pubkey after it's been decrypted (for medium groups)
|
||||
receivedAt: number; // We only seem to set this for public messages?
|
||||
unidentifiedDeliveryReceived: boolean;
|
||||
id: string;
|
||||
}
|
|
@ -12,7 +12,7 @@
|
|||
// "checkJs": true, // Report errors in .js files.
|
||||
"jsx": "react", // Specify JSX code generation: 'preserve', 'react-native', or 'react'.
|
||||
// "declaration": true, // Generates corresponding '.d.ts' file.
|
||||
// "sourceMap": true, // Generates corresponding '.map' file.
|
||||
"sourceMap": true, // Generates corresponding '.map' file.
|
||||
// "outFile": "./", // Concatenate and emit output to single file.
|
||||
// "outDir": "./", // Redirect output structure to the directory.
|
||||
"rootDir": "./ts", // Specify the root directory of input files. Use to control the output directory structure with --outDir.
|
||||
|
|
|
@ -146,6 +146,9 @@
|
|||
// We use || and && shortcutting because we're javascript programmers
|
||||
"strict-boolean-expressions": false,
|
||||
"no-suspicious-comment": false,
|
||||
"no-backbone-get-set-outside-model": false,
|
||||
"underscore-consistent-invocation": false,
|
||||
"newline-before-return": false,
|
||||
"react-no-dangerous-html": [
|
||||
true,
|
||||
{
|
||||
|
|
20
yarn.lock
20
yarn.lock
|
@ -163,6 +163,14 @@
|
|||
dependencies:
|
||||
defer-to-connect "^1.0.1"
|
||||
|
||||
"@types/backbone@^1.4.2":
|
||||
version "1.4.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/backbone/-/backbone-1.4.2.tgz#2af5ca6536d4cd510842eea6eeea11a42fa704b9"
|
||||
integrity sha512-+yfi5cLeIPU3JuCrFP4Bodpv8oLrE5sbiqQIMPvHIKaVCz0JCBt9GEQKZsz2haibrTV4Axks6ovoHc2yUbpWzg==
|
||||
dependencies:
|
||||
"@types/jquery" "*"
|
||||
"@types/underscore" "*"
|
||||
|
||||
"@types/chai-as-promised@^7.1.2":
|
||||
version "7.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/chai-as-promised/-/chai-as-promised-7.1.2.tgz#2f564420e81eaf8650169e5a3a6b93e096e5068b"
|
||||
|
@ -261,6 +269,13 @@
|
|||
"@types/minimatch" "*"
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/jquery@*":
|
||||
version "3.3.38"
|
||||
resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.3.38.tgz#6385f1e1b30bd2bff55ae8ee75ea42a999cc3608"
|
||||
integrity sha512-nkDvmx7x/6kDM5guu/YpXkGZ/Xj/IwGiLDdKM99YA5Vag7pjGyTJ8BNUh/6hxEn/sEu5DKtyRgnONJ7EmOoKrA==
|
||||
dependencies:
|
||||
"@types/sizzle" "*"
|
||||
|
||||
"@types/jquery@3.3.29":
|
||||
version "3.3.29"
|
||||
resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.3.29.tgz#680a2219ce3c9250483722fccf5570d1e2d08abd"
|
||||
|
@ -445,6 +460,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-1.0.4.tgz#922d092c84a776a59acb0bd6785fd82b59b9bad5"
|
||||
integrity sha512-6jtHrHpmiXOXoJ31Cg9R+iEVwuEKPf0XHwFUI93eEPXx492/J2JHyafkleKE2EYzZprayk9FSjTyK1GDqcwDng==
|
||||
|
||||
"@types/underscore@*":
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/underscore/-/underscore-1.10.0.tgz#5cb0dff2a5f616fc8e0c61b482bf01fa20a03cec"
|
||||
integrity sha512-ZAbqul7QAKpM2h1PFGa5ETN27ulmqtj0QviYHasw9LffvXZvVHuraOx/FOsIPPDNGZN0Qo1nASxxSfMYOtSoCw==
|
||||
|
||||
"@types/uuid@3.4.4":
|
||||
version "3.4.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.4.tgz#7af69360fa65ef0decb41fd150bf4ca5c0cefdf5"
|
||||
|
|
Loading…
Reference in New Issue