From dbd427396cf48ac77fbf84d3d04206d176a8e90e Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Thu, 23 Sep 2021 07:26:25 -0700 Subject: [PATCH] Convert js/modules/types/contact.js to TypeScript --- js/modules/types/contact.js | 154 ------- js/modules/types/message.js | 2 +- test/modules/types/contact_test.js | 404 ------------------ ts/test-node/types/EmbeddedContact_test.ts | 460 ++++++++++++++++++++- ts/types/EmbeddedContact.ts | 185 ++++++++- 5 files changed, 641 insertions(+), 564 deletions(-) delete mode 100644 js/modules/types/contact.js delete mode 100644 test/modules/types/contact_test.js diff --git a/js/modules/types/contact.js b/js/modules/types/contact.js deleted file mode 100644 index 22e6af4bd..000000000 --- a/js/modules/types/contact.js +++ /dev/null @@ -1,154 +0,0 @@ -// Copyright 2018-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -const { omit, compact, map } = require('lodash'); - -const { toLogFormat } = require('../../../ts/types/errors'); -const { SignalService } = require('../../../ts/protobuf'); -const { parse: parsePhoneNumber } = require('../../../ts/types/PhoneNumber'); - -const DEFAULT_PHONE_TYPE = SignalService.DataMessage.Contact.Phone.Type.HOME; -const DEFAULT_EMAIL_TYPE = SignalService.DataMessage.Contact.Email.Type.HOME; -const DEFAULT_ADDRESS_TYPE = - SignalService.DataMessage.Contact.PostalAddress.Type.HOME; - -exports.parseAndWriteAvatar = upgradeAttachment => async ( - contact, - context = {} -) => { - const { message, regionCode, logger } = context; - const { avatar } = contact; - - // This is to ensure that an omit() call doesn't pull in prototype props/methods - const contactShallowCopy = { ...contact }; - - const contactWithUpdatedAvatar = - avatar && avatar.avatar - ? { - ...contactShallowCopy, - avatar: { - ...avatar, - avatar: await upgradeAttachment(avatar.avatar, context), - }, - } - : omit(contactShallowCopy, ['avatar']); - - // eliminates empty numbers, emails, and addresses; adds type if not provided - const parsedContact = parseContact(contactWithUpdatedAvatar, { regionCode }); - - const error = exports._validate(parsedContact, { - messageId: idForLogging(message), - }); - if (error) { - logger.error( - 'Contact.parseAndWriteAvatar: contact was malformed.', - toLogFormat(error) - ); - } - - return parsedContact; -}; - -function parseContact(contact, options = {}) { - const { regionCode } = options; - - const boundParsePhone = phoneNumber => - parsePhoneItem(phoneNumber, { regionCode }); - - return { - ...omit(contact, ['avatar', 'number', 'email', 'address']), - ...parseAvatar(contact.avatar), - ...createArrayKey('number', compact(map(contact.number, boundParsePhone))), - ...createArrayKey('email', compact(map(contact.email, parseEmailItem))), - ...createArrayKey('address', compact(map(contact.address, parseAddress))), - }; -} - -function idForLogging(message) { - return `${message.source}.${message.sourceDevice} ${message.sent_at}`; -} - -exports._validate = (contact, options = {}) => { - const { messageId } = options; - const { name, number, email, address, organization } = contact; - - if ((!name || !name.displayName) && !organization) { - return new Error( - `Message ${messageId}: Contact had neither 'displayName' nor 'organization'` - ); - } - - if ( - (!number || !number.length) && - (!email || !email.length) && - (!address || !address.length) - ) { - return new Error( - `Message ${messageId}: Contact had no included numbers, email or addresses` - ); - } - - return null; -}; - -function parsePhoneItem(item, options = {}) { - const { regionCode } = options; - - if (!item.value) { - return null; - } - - return { - ...item, - type: item.type || DEFAULT_PHONE_TYPE, - value: parsePhoneNumber(item.value, { regionCode }), - }; -} - -function parseEmailItem(item) { - if (!item.value) { - return null; - } - - return { ...item, type: item.type || DEFAULT_EMAIL_TYPE }; -} - -function parseAddress(address) { - if (!address) { - return null; - } - - if ( - !address.street && - !address.pobox && - !address.neighborhood && - !address.city && - !address.region && - !address.postcode && - !address.country - ) { - return null; - } - - return { ...address, type: address.type || DEFAULT_ADDRESS_TYPE }; -} - -function parseAvatar(avatar) { - if (!avatar) { - return null; - } - - return { - avatar: { ...avatar, isProfile: avatar.isProfile || false }, - }; -} - -function createArrayKey(key, array) { - if (!array || !array.length) { - return null; - } - - return { - [key]: array, - }; -} diff --git a/js/modules/types/message.js b/js/modules/types/message.js index 9499b208b..334baf43b 100644 --- a/js/modules/types/message.js +++ b/js/modules/types/message.js @@ -3,7 +3,7 @@ const { isFunction, isObject, isString, omit } = require('lodash'); -const Contact = require('./contact'); +const Contact = require('../../../ts/types/EmbeddedContact'); const Attachment = require('../../../ts/types/Attachment'); const Errors = require('../../../ts/types/errors'); const SchemaVersion = require('./schema_version'); diff --git a/test/modules/types/contact_test.js b/test/modules/types/contact_test.js deleted file mode 100644 index f51cb725a..000000000 --- a/test/modules/types/contact_test.js +++ /dev/null @@ -1,404 +0,0 @@ -// Copyright 2018-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -const { assert } = require('chai'); -const sinon = require('sinon'); - -const Contact = require('../../../js/modules/types/contact'); -const { stringToArrayBuffer } = require('../../../ts/util/stringToArrayBuffer'); - -describe('Contact', () => { - const NUMBER = '+12025550099'; - const logger = { - error: () => null, - }; - - describe('parseAndWriteAvatar', () => { - it('handles message with no avatar in contact', async () => { - const upgradeAttachment = sinon - .stub() - .throws(new Error("Shouldn't be called")); - const upgradeVersion = Contact.parseAndWriteAvatar(upgradeAttachment); - - const message = { - body: 'hey there!', - contact: [ - { - name: { - displayName: 'Someone Somewhere', - }, - number: [ - { - type: 1, - value: NUMBER, - }, - ], - }, - ], - }; - const result = await upgradeVersion(message.contact[0], { - message, - logger, - }); - assert.deepEqual(result, message.contact[0]); - }); - - it('turns phone numbers to e164 format', async () => { - const upgradeAttachment = sinon - .stub() - .throws(new Error("Shouldn't be called")); - const upgradeVersion = Contact.parseAndWriteAvatar(upgradeAttachment); - - const message = { - body: 'hey there!', - contact: [ - { - name: { - displayName: 'Someone Somewhere', - }, - number: [ - { - type: 1, - value: '(202) 555-0099', - }, - ], - }, - ], - }; - const expected = { - name: { - displayName: 'Someone Somewhere', - }, - number: [ - { - type: 1, - value: '+12025550099', - }, - ], - }; - const result = await upgradeVersion(message.contact[0], { - message, - regionCode: 'US', - logger, - }); - assert.deepEqual(result, expected); - }); - - it('removes contact avatar if it has no sub-avatar', async () => { - const upgradeAttachment = sinon - .stub() - .throws(new Error("Shouldn't be called")); - const upgradeVersion = Contact.parseAndWriteAvatar(upgradeAttachment); - - const message = { - body: 'hey there!', - contact: [ - { - name: { - displayName: 'Someone Somewhere', - }, - number: [ - { - type: 1, - value: NUMBER, - }, - ], - avatar: { - isProfile: true, - }, - }, - ], - }; - const expected = { - name: { - displayName: 'Someone Somewhere', - }, - number: [ - { - type: 1, - value: NUMBER, - }, - ], - }; - const result = await upgradeVersion(message.contact[0], { - message, - logger, - }); - assert.deepEqual(result, expected); - }); - - it('writes avatar to disk', async () => { - const upgradeAttachment = async () => { - return { - path: 'abc/abcdefg', - }; - }; - const upgradeVersion = Contact.parseAndWriteAvatar(upgradeAttachment); - - const message = { - body: 'hey there!', - contact: [ - { - name: { - displayName: 'Someone Somewhere', - }, - number: [ - { - type: 1, - value: NUMBER, - }, - ], - email: [ - { - type: 2, - value: 'someone@somewhere.com', - }, - ], - address: [ - { - type: 1, - street: '5 Somewhere Ave.', - }, - ], - avatar: { - otherKey: 'otherValue', - avatar: { - contentType: 'image/png', - data: stringToArrayBuffer('It’s easy if you try'), - }, - }, - }, - ], - }; - const expected = { - name: { - displayName: 'Someone Somewhere', - }, - number: [ - { - type: 1, - value: NUMBER, - }, - ], - email: [ - { - type: 2, - value: 'someone@somewhere.com', - }, - ], - address: [ - { - type: 1, - street: '5 Somewhere Ave.', - }, - ], - avatar: { - otherKey: 'otherValue', - isProfile: false, - avatar: { - path: 'abc/abcdefg', - }, - }, - }; - - const result = await upgradeVersion(message.contact[0], { - message, - logger, - }); - assert.deepEqual(result, expected); - }); - - it('removes number element if it ends up with no value', async () => { - const upgradeAttachment = sinon - .stub() - .throws(new Error("Shouldn't be called")); - const upgradeVersion = Contact.parseAndWriteAvatar(upgradeAttachment); - - const message = { - body: 'hey there!', - contact: [ - { - name: { - displayName: 'Someone Somewhere', - }, - number: [ - { - type: 1, - }, - ], - email: [ - { - value: 'someone@somewhere.com', - }, - ], - }, - ], - }; - const expected = { - name: { - displayName: 'Someone Somewhere', - }, - email: [ - { - type: 1, - value: 'someone@somewhere.com', - }, - ], - }; - const result = await upgradeVersion(message.contact[0], { - message, - logger, - }); - assert.deepEqual(result, expected); - }); - - it('drops address if it has no real values', async () => { - const upgradeAttachment = sinon - .stub() - .throws(new Error("Shouldn't be called")); - const upgradeVersion = Contact.parseAndWriteAvatar(upgradeAttachment); - - const message = { - body: 'hey there!', - contact: [ - { - name: { - displayName: 'Someone Somewhere', - }, - number: [ - { - value: NUMBER, - }, - ], - address: [ - { - type: 1, - }, - ], - }, - ], - }; - const expected = { - name: { - displayName: 'Someone Somewhere', - }, - number: [ - { - value: NUMBER, - type: 1, - }, - ], - }; - const result = await upgradeVersion(message.contact[0], { - message, - logger, - }); - assert.deepEqual(result, expected); - }); - - it('removes invalid elements if no values remain in contact', async () => { - const upgradeAttachment = sinon - .stub() - .throws(new Error("Shouldn't be called")); - const upgradeVersion = Contact.parseAndWriteAvatar(upgradeAttachment); - - const message = { - body: 'hey there!', - source: NUMBER, - sourceDevice: '1', - sent_at: 1232132, - contact: [ - { - name: { - displayName: 'Someone Somewhere', - }, - number: [ - { - type: 1, - }, - ], - email: [ - { - type: 1, - }, - ], - }, - ], - }; - const expected = { - name: { - displayName: 'Someone Somewhere', - }, - }; - const result = await upgradeVersion(message.contact[0], { - message, - logger, - }); - assert.deepEqual(result, expected); - }); - - it('handles a contact with just organization', async () => { - const upgradeAttachment = sinon - .stub() - .throws(new Error("Shouldn't be called")); - const upgradeVersion = Contact.parseAndWriteAvatar(upgradeAttachment); - - const message = { - contact: [ - { - organization: 'Somewhere Consulting', - number: [ - { - type: 1, - value: NUMBER, - }, - ], - }, - ], - }; - const result = await upgradeVersion(message.contact[0], { - message, - logger, - }); - assert.deepEqual(result, message.contact[0]); - }); - }); - - describe('_validate', () => { - it('returns error if contact has no name.displayName or organization', () => { - const messageId = 'the-message-id'; - const contact = { - name: { - name: 'Someone', - }, - number: [ - { - type: 1, - value: NUMBER, - }, - ], - }; - const expected = - "Message the-message-id: Contact had neither 'displayName' nor 'organization'"; - - const result = Contact._validate(contact, { messageId }); - assert.deepEqual(result.message, expected); - }); - - it('logs if no values remain in contact', async () => { - const messageId = 'the-message-id'; - const contact = { - name: { - displayName: 'Someone Somewhere', - }, - number: [], - email: [], - }; - const expected = - 'Message the-message-id: Contact had no included numbers, email or addresses'; - - const result = Contact._validate(contact, { messageId }); - assert.deepEqual(result.message, expected); - }); - }); -}); diff --git a/ts/test-node/types/EmbeddedContact_test.ts b/ts/test-node/types/EmbeddedContact_test.ts index 75c67aafc..85188f239 100644 --- a/ts/test-node/types/EmbeddedContact_test.ts +++ b/ts/test-node/types/EmbeddedContact_test.ts @@ -2,11 +2,53 @@ // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; +import * as sinon from 'sinon'; -import { IMAGE_GIF } from '../../types/MIME'; -import { embeddedContactSelector, getName } from '../../types/EmbeddedContact'; +import { IMAGE_GIF, IMAGE_PNG } from '../../types/MIME'; +import { MessageAttributesType } from '../../model-types.d'; +import { stringToArrayBuffer } from '../../util/stringToArrayBuffer'; +import { + Avatar, + Email, + Phone, + _validate, + embeddedContactSelector, + getName, + parseAndWriteAvatar, +} from '../../types/EmbeddedContact'; describe('Contact', () => { + const NUMBER = '+12025550099'; + const logger = { + error: () => undefined, + }; + + const writeNewAttachmentData = sinon + .stub() + .throws(new Error("Shouldn't be called")); + + const getDefaultMessageAttrs = (): Pick< + MessageAttributesType, + | 'id' + | 'conversationId' + | 'type' + | 'sent_at' + | 'received_at' + | 'timestamp' + | 'body' + > => { + return { + id: 'id', + conversationId: 'convo-id', + type: 'incoming', + sent_at: 1, + received_at: 2, + timestamp: 1, + + body: 'hey there', + }; + }; + describe('getName', () => { it('returns displayName if provided', () => { const contact = { @@ -21,6 +63,7 @@ describe('Contact', () => { const actual = getName(contact); assert.strictEqual(actual, expected); }); + it('returns organization if no displayName', () => { const contact = { name: { @@ -33,6 +76,7 @@ describe('Contact', () => { const actual = getName(contact); assert.strictEqual(actual, expected); }); + it('returns givenName + familyName if no displayName or organization', () => { const contact = { name: { @@ -44,6 +88,7 @@ describe('Contact', () => { const actual = getName(contact); assert.strictEqual(actual, expected); }); + it('returns just givenName', () => { const contact = { name: { @@ -54,6 +99,7 @@ describe('Contact', () => { const actual = getName(contact); assert.strictEqual(actual, expected); }); + it('returns just familyName', () => { const contact = { name: { @@ -65,6 +111,7 @@ describe('Contact', () => { assert.strictEqual(actual, expected); }); }); + describe('embeddedContactSelector', () => { const regionCode = '1'; const firstNumber = '+1202555000'; @@ -195,4 +242,413 @@ describe('Contact', () => { assert.deepEqual(actual, expected); }); }); + + describe('parseAndWriteAvatar', () => { + it('handles message with no avatar in contact', async () => { + const upgradeAttachment = sinon + .stub() + .throws(new Error("Shouldn't be called")); + const upgradeVersion = parseAndWriteAvatar(upgradeAttachment); + + const message = { + ...getDefaultMessageAttrs(), + contact: [ + { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + type: 1, + value: NUMBER, + }, + ], + }, + ], + }; + const result = await upgradeVersion(message.contact[0], { + message, + logger, + regionCode: '1', + writeNewAttachmentData, + }); + assert.deepEqual(result, message.contact[0]); + }); + + it('turns phone numbers to e164 format', async () => { + const upgradeAttachment = sinon + .stub() + .throws(new Error("Shouldn't be called")); + const upgradeVersion = parseAndWriteAvatar(upgradeAttachment); + + const message = { + ...getDefaultMessageAttrs(), + contact: [ + { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + type: 1, + value: '(202) 555-0099', + }, + ], + }, + ], + }; + const expected = { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + type: 1, + value: '+12025550099', + }, + ], + }; + const result = await upgradeVersion(message.contact[0], { + message, + regionCode: 'US', + logger, + writeNewAttachmentData, + }); + assert.deepEqual(result, expected); + }); + + it('removes contact avatar if it has no sub-avatar', async () => { + const upgradeAttachment = sinon + .stub() + .throws(new Error("Shouldn't be called")); + const upgradeVersion = parseAndWriteAvatar(upgradeAttachment); + + const message = { + ...getDefaultMessageAttrs(), + contact: [ + { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + type: 1, + value: NUMBER, + }, + ], + avatar: ({ + isProfile: true, + } as unknown) as Avatar, + }, + ], + }; + const expected = { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + type: 1, + value: NUMBER, + }, + ], + }; + const result = await upgradeVersion(message.contact[0], { + regionCode: '1', + writeNewAttachmentData, + message, + logger, + }); + assert.deepEqual(result, expected); + }); + + it('writes avatar to disk', async () => { + const upgradeAttachment = async () => { + return { + path: 'abc/abcdefg', + contentType: IMAGE_PNG, + }; + }; + const upgradeVersion = parseAndWriteAvatar(upgradeAttachment); + + const message = { + ...getDefaultMessageAttrs(), + contact: [ + { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + type: 1, + value: NUMBER, + }, + ], + email: [ + { + type: 2, + value: 'someone@somewhere.com', + }, + ], + address: [ + { + type: 1, + street: '5 Somewhere Ave.', + }, + ], + avatar: ({ + otherKey: 'otherValue', + avatar: { + contentType: 'image/png', + data: stringToArrayBuffer('It’s easy if you try'), + }, + } as unknown) as Avatar, + }, + ], + }; + const expected = { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + type: 1, + value: NUMBER, + }, + ], + email: [ + { + type: 2, + value: 'someone@somewhere.com', + }, + ], + address: [ + { + type: 1, + street: '5 Somewhere Ave.', + }, + ], + avatar: { + otherKey: 'otherValue', + isProfile: false, + avatar: { + contentType: IMAGE_PNG, + path: 'abc/abcdefg', + }, + }, + }; + + const result = await upgradeVersion(message.contact[0], { + regionCode: '1', + writeNewAttachmentData, + message, + logger, + }); + assert.deepEqual(result, expected); + }); + + it('removes number element if it ends up with no value', async () => { + const upgradeAttachment = sinon + .stub() + .throws(new Error("Shouldn't be called")); + const upgradeVersion = parseAndWriteAvatar(upgradeAttachment); + + const message = { + ...getDefaultMessageAttrs(), + contact: [ + { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + ({ + type: 1, + } as unknown) as Phone, + ], + email: [ + { + type: 0, + value: 'someone@somewhere.com', + }, + ], + }, + ], + }; + const expected = { + name: { + displayName: 'Someone Somewhere', + }, + email: [ + { + type: 1, + value: 'someone@somewhere.com', + }, + ], + }; + const result = await upgradeVersion(message.contact[0], { + regionCode: '1', + writeNewAttachmentData, + message, + logger, + }); + assert.deepEqual(result, expected); + }); + + it('drops address if it has no real values', async () => { + const upgradeAttachment = sinon + .stub() + .throws(new Error("Shouldn't be called")); + const upgradeVersion = parseAndWriteAvatar(upgradeAttachment); + + const message = { + ...getDefaultMessageAttrs(), + contact: [ + { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + type: 1, + value: NUMBER, + }, + ], + address: [ + { + type: 1, + }, + ], + }, + ], + }; + const expected = { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + value: NUMBER, + type: 1, + }, + ], + }; + const result = await upgradeVersion(message.contact[0], { + regionCode: '1', + writeNewAttachmentData, + message, + logger, + }); + assert.deepEqual(result, expected); + }); + + it('removes invalid elements if no values remain in contact', async () => { + const upgradeAttachment = sinon + .stub() + .throws(new Error("Shouldn't be called")); + const upgradeVersion = parseAndWriteAvatar(upgradeAttachment); + + const message = { + ...getDefaultMessageAttrs(), + source: NUMBER, + sourceDevice: 1, + sent_at: 1232132, + contact: [ + { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + ({ + type: 1, + } as unknown) as Phone, + ], + email: [ + ({ + type: 1, + } as unknown) as Email, + ], + }, + ], + }; + const expected = { + name: { + displayName: 'Someone Somewhere', + }, + }; + const result = await upgradeVersion(message.contact[0], { + regionCode: '1', + writeNewAttachmentData, + message, + logger, + }); + assert.deepEqual(result, expected); + }); + + it('handles a contact with just organization', async () => { + const upgradeAttachment = sinon + .stub() + .throws(new Error("Shouldn't be called")); + const upgradeVersion = parseAndWriteAvatar(upgradeAttachment); + + const message = { + ...getDefaultMessageAttrs(), + contact: [ + { + organization: 'Somewhere Consulting', + number: [ + { + type: 1, + value: NUMBER, + }, + ], + }, + ], + }; + const result = await upgradeVersion(message.contact[0], { + regionCode: '1', + writeNewAttachmentData, + message, + logger, + }); + assert.deepEqual(result, message.contact[0]); + }); + }); + + describe('_validate', () => { + it('returns error if contact has no name.displayName or organization', () => { + const messageId = 'the-message-id'; + const contact = { + name: { + givenName: 'Someone', + }, + number: [ + { + type: 1, + value: NUMBER, + }, + ], + }; + const expected = + "Message the-message-id: Contact had neither 'displayName' nor 'organization'"; + + const result = _validate(contact, { messageId }); + assert.deepEqual(result?.message, expected); + }); + + it('logs if no values remain in contact', async () => { + const messageId = 'the-message-id'; + const contact = { + name: { + displayName: 'Someone Somewhere', + }, + number: [], + email: [], + }; + const expected = + 'Message the-message-id: Contact had no included numbers, email or addresses'; + + const result = _validate(contact, { messageId }); + assert.deepEqual(result?.message, expected); + }); + }); }); diff --git a/ts/types/EmbeddedContact.ts b/ts/types/EmbeddedContact.ts index 2679bdb13..b082242f6 100644 --- a/ts/types/EmbeddedContact.ts +++ b/ts/types/EmbeddedContact.ts @@ -1,8 +1,19 @@ // Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { format as formatPhoneNumber } from './PhoneNumber'; -import { AttachmentType } from './Attachment'; +import { omit } from 'lodash'; + +import { SignalService as Proto } from '../protobuf'; +import { MessageAttributesType } from '../model-types.d'; + +import { isNotNil } from '../util/isNotNil'; +import { + format as formatPhoneNumber, + parse as parsePhoneNumber, +} from './PhoneNumber'; +import { AttachmentType, migrateDataToFileSystem } from './Attachment'; +import { toLogFormat } from './errors'; +import { LoggerType } from './Logging'; export type EmbeddedContactType = { name?: Name; @@ -63,11 +74,15 @@ export type PostalAddress = { country?: string; }; -type Avatar = { +export type Avatar = { avatar: AttachmentType; isProfile: boolean; }; +const DEFAULT_PHONE_TYPE = Proto.DataMessage.Contact.Phone.Type.HOME; +const DEFAULT_EMAIL_TYPE = Proto.DataMessage.Contact.Email.Type.HOME; +const DEFAULT_ADDRESS_TYPE = Proto.DataMessage.Contact.PostalAddress.Type.HOME; + export function embeddedContactSelector( contact: EmbeddedContactType, options: { @@ -127,3 +142,167 @@ export function getName(contact: EmbeddedContactType): string | undefined { return displayName || organization || backupName || givenName || familyName; } + +export function parseAndWriteAvatar( + upgradeAttachment: typeof migrateDataToFileSystem +) { + return async ( + contact: EmbeddedContactType, + context: { + message: MessageAttributesType; + regionCode: string; + logger: Pick; + writeNewAttachmentData: (data: ArrayBuffer) => Promise; + } + ): Promise => { + const { message, regionCode, logger } = context; + const { avatar } = contact; + + const contactWithUpdatedAvatar = + avatar && avatar.avatar + ? { + ...contact, + avatar: { + ...avatar, + avatar: await upgradeAttachment(avatar.avatar, context), + }, + } + : omit(contact, ['avatar']); + + // eliminates empty numbers, emails, and addresses; adds type if not provided + const parsedContact = parseContact(contactWithUpdatedAvatar, { + regionCode, + }); + + const error = _validate(parsedContact, { + messageId: idForLogging(message), + }); + if (error) { + logger.error( + 'parseAndWriteAvatar: contact was malformed.', + toLogFormat(error) + ); + } + + return parsedContact; + }; +} + +function parseContact( + contact: EmbeddedContactType, + { regionCode }: { regionCode: string } +): EmbeddedContactType { + const boundParsePhone = (phoneNumber: Phone): Phone | undefined => + parsePhoneItem(phoneNumber, { regionCode }); + + const skipEmpty = (arr: Array): Array | undefined => { + const filtered: Array = arr.filter(isNotNil); + return filtered.length ? filtered : undefined; + }; + + const number = skipEmpty((contact.number || []).map(boundParsePhone)); + const email = skipEmpty((contact.email || []).map(parseEmailItem)); + const address = skipEmpty((contact.address || []).map(parseAddress)); + + let result = { + ...omit(contact, ['avatar', 'number', 'email', 'address']), + ...parseAvatar(contact.avatar), + }; + + if (number) { + result = { ...result, number }; + } + if (email) { + result = { ...result, email }; + } + if (address) { + result = { ...result, address }; + } + return result; +} + +function idForLogging(message: MessageAttributesType): string { + return `${message.source}.${message.sourceDevice} ${message.sent_at}`; +} + +// Exported for testing +export function _validate( + contact: EmbeddedContactType, + { messageId }: { messageId: string } +): Error | undefined { + const { name, number, email, address, organization } = contact; + + if ((!name || !name.displayName) && !organization) { + return new Error( + `Message ${messageId}: Contact had neither 'displayName' nor 'organization'` + ); + } + + if ( + (!number || !number.length) && + (!email || !email.length) && + (!address || !address.length) + ) { + return new Error( + `Message ${messageId}: Contact had no included numbers, email or addresses` + ); + } + + return undefined; +} + +function parsePhoneItem( + item: Phone, + { regionCode }: { regionCode: string } +): Phone | undefined { + if (!item.value) { + return undefined; + } + + return { + ...item, + type: item.type || DEFAULT_PHONE_TYPE, + value: parsePhoneNumber(item.value, { regionCode }), + }; +} + +function parseEmailItem(item: Email): Email | undefined { + if (!item.value) { + return undefined; + } + + return { ...item, type: item.type || DEFAULT_EMAIL_TYPE }; +} + +function parseAddress(address: PostalAddress): PostalAddress | undefined { + if (!address) { + return undefined; + } + + if ( + !address.street && + !address.pobox && + !address.neighborhood && + !address.city && + !address.region && + !address.postcode && + !address.country + ) { + return undefined; + } + + return { ...address, type: address.type || DEFAULT_ADDRESS_TYPE }; +} + +function parseAvatar(avatar?: Avatar): { avatar: Avatar } | undefined { + if (!avatar) { + return undefined; + } + + return { + avatar: { + ...avatar, + isProfile: avatar.isProfile || false, + }, + }; +}