Move receipt queues into conversation queue to handle 428s

This commit is contained in:
Jamie Kyle 2023-02-06 09:24:34 -08:00 committed by GitHub
parent 3776a04c0b
commit 2bbcc4676e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 981 additions and 223 deletions

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { webFrame } from 'electron';
import { isNumber, debounce } from 'lodash';
import { isNumber, debounce, groupBy } from 'lodash';
import { bindActionCreators } from 'redux';
import { render } from 'react-dom';
import { batch as batchDispatch } from 'react-redux';
@ -29,6 +29,7 @@ import * as Timers from './Timers';
import * as indexedDb from './indexeddb';
import type { MenuOptionsType } from './types/menu';
import type { Receipt } from './types/Receipt';
import { ReceiptType } from './types/Receipt';
import { SocketStatus } from './types/SocketStatus';
import { DEFAULT_CONVERSATION_COLOR } from './types/Colors';
import { ThemeType } from './types/Util';
@ -145,11 +146,13 @@ import { ToastCaptchaSolved } from './components/ToastCaptchaSolved';
import { showToast } from './util/showToast';
import { startInteractionMode } from './windows/startInteractionMode';
import type { MainWindowStatsType } from './windows/context';
import { deliveryReceiptsJobQueue } from './jobs/deliveryReceiptsJobQueue';
import { ReactionSource } from './reactions/ReactionSource';
import { singleProtoJobQueue } from './jobs/singleProtoJobQueue';
import { getInitialState } from './state/getInitialState';
import { conversationJobQueue } from './jobs/conversationJobQueue';
import {
conversationJobQueue,
conversationQueueJobEnum,
} from './jobs/conversationJobQueue';
import { SeenStatus } from './MessageSeenStatus';
import MessageSender from './textsecure/SendMessage';
import type AccountManager from './textsecure/AccountManager';
@ -482,7 +485,17 @@ export async function startApp(): Promise<void> {
wait: 500,
maxSize: 100,
processBatch: async deliveryReceipts => {
await deliveryReceiptsJobQueue.add({ deliveryReceipts });
const groups = groupBy(deliveryReceipts, 'conversationId');
await Promise.all(
Object.keys(groups).map(async conversationId => {
await conversationJobQueue.add({
type: conversationQueueJobEnum.enum.Receipts,
conversationId,
receiptsType: ReceiptType.Delivery,
receipts: groups[conversationId],
});
})
);
},
});

View file

@ -20,6 +20,7 @@ import { sendDeleteStoryForEveryone } from './helpers/sendDeleteStoryForEveryone
import { sendProfileKey } from './helpers/sendProfileKey';
import { sendReaction } from './helpers/sendReaction';
import { sendStory } from './helpers/sendStory';
import { sendReceipts } from './helpers/sendReceipts';
import type { LoggerType } from '../types/Logging';
import { ConversationVerificationState } from '../state/ducks/conversationsEnums';
@ -37,6 +38,7 @@ import type { Job } from './Job';
import type { ParsedJob } from './types';
import type SendMessage from '../textsecure/SendMessage';
import type { UUIDStringType } from '../types/UUID';
import { receiptSchema, ReceiptType } from '../types/Receipt';
// Note: generally, we only want to add to this list. If you do need to change one of
// these values, you'll likely need to write a database migration.
@ -49,6 +51,7 @@ export const conversationQueueJobEnum = z.enum([
'ProfileKey',
'Reaction',
'Story',
'Receipts',
]);
const deleteForEveryoneJobDataSchema = z.object({
@ -139,6 +142,14 @@ const storyJobDataSchema = z.object({
});
export type StoryJobData = z.infer<typeof storyJobDataSchema>;
const receiptsJobDataSchema = z.object({
type: z.literal(conversationQueueJobEnum.enum.Receipts),
conversationId: z.string(),
receiptsType: z.nativeEnum(ReceiptType),
receipts: receiptSchema.array(),
});
export type ReceiptsJobData = z.infer<typeof receiptsJobDataSchema>;
export const conversationQueueJobDataSchema = z.union([
deleteForEveryoneJobDataSchema,
deleteStoryForEveryoneJobDataSchema,
@ -148,6 +159,7 @@ export const conversationQueueJobDataSchema = z.union([
profileKeyJobDataSchema,
reactionJobDataSchema,
storyJobDataSchema,
receiptsJobDataSchema,
]);
export type ConversationQueueJobData = z.infer<
typeof conversationQueueJobDataSchema
@ -384,6 +396,9 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
case jobSet.Story:
await sendStory(conversation, jobBundle, data);
break;
case jobSet.Receipts:
await sendReceipts(conversation, jobBundle, data);
break;
default: {
// Note: This should never happen, because the zod call in parseData wouldn't
// accept data that doesn't look like our type specification.

View file

@ -1,45 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { z } from 'zod';
import type { LoggerType } from '../types/Logging';
import { exponentialBackoffMaxAttempts } from '../util/exponentialBackoff';
import { receiptSchema, ReceiptType } from '../types/Receipt';
import { MAX_RETRY_TIME, runReceiptJob } from './helpers/receiptHelpers';
import { JobQueue } from './JobQueue';
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
const deliveryReceiptsJobDataSchema = z.object({
deliveryReceipts: receiptSchema.array(),
});
type DeliveryReceiptsJobData = z.infer<typeof deliveryReceiptsJobDataSchema>;
export class DeliveryReceiptsJobQueue extends JobQueue<DeliveryReceiptsJobData> {
protected parseData(data: unknown): DeliveryReceiptsJobData {
return deliveryReceiptsJobDataSchema.parse(data);
}
protected async run(
{
data,
timestamp,
}: Readonly<{ data: DeliveryReceiptsJobData; timestamp: number }>,
{ attempt, log }: Readonly<{ attempt: number; log: LoggerType }>
): Promise<void> {
await runReceiptJob({
attempt,
log,
timestamp,
receipts: data.deliveryReceipts,
type: ReceiptType.Delivery,
});
}
}
export const deliveryReceiptsJobQueue = new DeliveryReceiptsJobQueue({
store: jobQueueDatabaseStore,
queueType: 'delivery receipts',
maxAttempts: exponentialBackoffMaxAttempts(MAX_RETRY_TIME),
});

View file

@ -1,43 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as durations from '../../util/durations';
import type { LoggerType } from '../../types/Logging';
import type { Receipt, ReceiptType } from '../../types/Receipt';
import { sendReceipts } from '../../util/sendReceipts';
import { commonShouldJobContinue } from './commonShouldJobContinue';
import { handleCommonJobRequestError } from './handleCommonJobRequestError';
export const MAX_RETRY_TIME = durations.DAY;
export async function runReceiptJob({
attempt,
log,
timestamp,
receipts,
type,
}: Readonly<{
attempt: number;
log: LoggerType;
receipts: ReadonlyArray<Receipt>;
timestamp: number;
type: ReceiptType;
}>): Promise<void> {
const timeRemaining = timestamp + MAX_RETRY_TIME - Date.now();
const shouldContinue = await commonShouldJobContinue({
attempt,
log,
timeRemaining,
skipWait: false,
});
if (!shouldContinue) {
return;
}
try {
await sendReceipts({ log, receipts, type });
} catch (err: unknown) {
await handleCommonJobRequestError({ err, log, timeRemaining });
}
}

View file

@ -0,0 +1,21 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ConversationModel } from '../../models/conversations';
import { sendReceipts as sendReceiptsTask } from '../../util/sendReceipts';
import type {
ConversationQueueJobBundle,
ReceiptsJobData,
} from '../conversationJobQueue';
export async function sendReceipts(
_conversation: ConversationModel,
{ log }: ConversationQueueJobBundle,
data: ReceiptsJobData
): Promise<void> {
await sendReceiptsTask({
log,
receipts: data.receipts,
type: data.receiptsType,
});
}

View file

@ -5,15 +5,12 @@ import type { WebAPIType } from '../textsecure/WebAPI';
import { drop } from '../util/drop';
import { conversationJobQueue } from './conversationJobQueue';
import { deliveryReceiptsJobQueue } from './deliveryReceiptsJobQueue';
import { readReceiptsJobQueue } from './readReceiptsJobQueue';
import { readSyncJobQueue } from './readSyncJobQueue';
import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue';
import { reportSpamJobQueue } from './reportSpamJobQueue';
import { singleProtoJobQueue } from './singleProtoJobQueue';
import { viewOnceOpenJobQueue } from './viewOnceOpenJobQueue';
import { viewSyncJobQueue } from './viewSyncJobQueue';
import { viewedReceiptsJobQueue } from './viewedReceiptsJobQueue';
/**
* Start all of the job queues. Should be called when the database is ready.
@ -31,11 +28,6 @@ export function initializeAllJobQueues({
// Single proto send queue, used for a variety of one-off simple messages
drop(singleProtoJobQueue.streamJobs());
// Syncs to others
drop(deliveryReceiptsJobQueue.streamJobs());
drop(readReceiptsJobQueue.streamJobs());
drop(viewedReceiptsJobQueue.streamJobs());
// Syncs to ourselves
drop(readSyncJobQueue.streamJobs());
drop(viewSyncJobQueue.streamJobs());

View file

@ -1,56 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { z } from 'zod';
import type { LoggerType } from '../types/Logging';
import { exponentialBackoffMaxAttempts } from '../util/exponentialBackoff';
import type { StorageInterface } from '../types/Storage.d';
import type { Receipt } from '../types/Receipt';
import { receiptSchema, ReceiptType } from '../types/Receipt';
import { MAX_RETRY_TIME, runReceiptJob } from './helpers/receiptHelpers';
import { JobQueue } from './JobQueue';
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
const readReceiptsJobDataSchema = z.object({
readReceipts: receiptSchema.array(),
});
type ReadReceiptsJobData = z.infer<typeof readReceiptsJobDataSchema>;
export class ReadReceiptsJobQueue extends JobQueue<ReadReceiptsJobData> {
public async addIfAllowedByUser(
storage: Pick<StorageInterface, 'get'>,
readReceipts: Array<Receipt>
): Promise<void> {
if (storage.get('read-receipt-setting')) {
await this.add({ readReceipts });
}
}
protected parseData(data: unknown): ReadReceiptsJobData {
return readReceiptsJobDataSchema.parse(data);
}
protected async run(
{
data,
timestamp,
}: Readonly<{ data: ReadReceiptsJobData; timestamp: number }>,
{ attempt, log }: Readonly<{ attempt: number; log: LoggerType }>
): Promise<void> {
await runReceiptJob({
attempt,
log,
timestamp,
receipts: data.readReceipts,
type: ReceiptType.Read,
});
}
}
export const readReceiptsJobQueue = new ReadReceiptsJobQueue({
store: jobQueueDatabaseStore,
queueType: 'read receipts',
maxAttempts: exponentialBackoffMaxAttempts(MAX_RETRY_TIME),
});

View file

@ -1,43 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { z } from 'zod';
import type { LoggerType } from '../types/Logging';
import { exponentialBackoffMaxAttempts } from '../util/exponentialBackoff';
import { receiptSchema, ReceiptType } from '../types/Receipt';
import { MAX_RETRY_TIME, runReceiptJob } from './helpers/receiptHelpers';
import { JobQueue } from './JobQueue';
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
const viewedReceiptsJobDataSchema = z.object({ viewedReceipt: receiptSchema });
type ViewedReceiptsJobData = z.infer<typeof viewedReceiptsJobDataSchema>;
export class ViewedReceiptsJobQueue extends JobQueue<ViewedReceiptsJobData> {
protected parseData(data: unknown): ViewedReceiptsJobData {
return viewedReceiptsJobDataSchema.parse(data);
}
protected async run(
{
data,
timestamp,
}: Readonly<{ data: ViewedReceiptsJobData; timestamp: number }>,
{ attempt, log }: Readonly<{ attempt: number; log: LoggerType }>
): Promise<void> {
await runReceiptJob({
attempt,
log,
timestamp,
receipts: [data.viewedReceipt],
type: ReceiptType.Viewed,
});
}
}
export const viewedReceiptsJobQueue = new ViewedReceiptsJobQueue({
store: jobQueueDatabaseStore,
queueType: 'viewed receipts',
maxAttempts: exponentialBackoffMaxAttempts(MAX_RETRY_TIME),
});

View file

@ -137,7 +137,6 @@ import {
conversationJobQueue,
conversationQueueJobEnum,
} from '../jobs/conversationJobQueue';
import { readReceiptsJobQueue } from '../jobs/readReceiptsJobQueue';
import type { ReactionModel } from '../messageModifiers/Reactions';
import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady';
import { getProfile } from '../util/getProfile';
@ -160,6 +159,7 @@ import { isMemberRequestingToJoin } from '../util/isMemberRequestingToJoin';
import { removePendingMember } from '../util/removePendingMember';
import { isMemberPending } from '../util/isMemberPending';
import { imageToBlurHash } from '../util/imageToBlurHash';
import { ReceiptType } from '../types/Receipt';
const EMPTY_ARRAY: Readonly<[]> = [];
const EMPTY_GROUP_COLLISIONS: GroupNameCollisionsWithIdsByTitle = {};
@ -2182,17 +2182,22 @@ export class ConversationModel extends window.Backbone
const readMessages = messages.filter(m => !hasErrors(m) && isIncoming(m));
if (isLocalAction) {
const conversationId = this.get('id');
// eslint-disable-next-line no-await-in-loop
await readReceiptsJobQueue.addIfAllowedByUser(
window.storage,
readMessages.map(m => ({
await conversationJobQueue.add({
type: conversationQueueJobEnum.enum.Receipts,
conversationId: this.get('id'),
receiptsType: ReceiptType.Read,
receipts: readMessages.map(m => ({
messageId: m.id,
conversationId,
senderE164: m.source,
senderUuid: m.sourceUuid,
timestamp: m.sent_at,
isDirectConversation: isDirectConversation(this.attributes),
}))
);
})),
});
}
// eslint-disable-next-line no-await-in-loop

View file

@ -2488,6 +2488,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
window.Whisper.deliveryReceiptQueue.add(() => {
window.Whisper.deliveryReceiptBatcher.add({
messageId,
conversationId,
senderE164: source,
senderUuid: sourceUuid,
timestamp: this.get('sent_at'),

View file

@ -0,0 +1,132 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Database } from '@signalapp/better-sqlite3';
import type { LoggerType } from '../../types/Logging';
import { isRecord } from '../../util/isRecord';
import {
getJobsInQueueSync,
getMessageByIdSync,
insertJobSync,
} from '../Server';
export default function updateToSchemaVersion78(
currentVersion: number,
db: Database,
logger: LoggerType
): void {
if (currentVersion >= 78) {
return;
}
db.transaction(() => {
const deleteJobsInQueue = db.prepare(
'DELETE FROM jobs WHERE queueType = $queueType'
);
const queues = [
{
queueType: 'delivery receipts',
jobDataKey: 'deliveryReceipts',
jobDataIsArray: true,
newReceiptsType: 'deliveryReceipt',
},
{
queueType: 'read receipts',
jobDataKey: 'readReceipts',
jobDataIsArray: true,
newReceiptsType: 'readReceipt',
},
{
queueType: 'viewed receipts',
jobDataKey: 'viewedReceipt',
jobDataIsArray: false,
newReceiptsType: 'viewedReceipt',
},
];
for (const queue of queues) {
const prevJobs = getJobsInQueueSync(db, queue.queueType);
deleteJobsInQueue.run({ queueType: queue.queueType });
prevJobs.forEach(job => {
const { data, id } = job;
if (!isRecord(data)) {
logger.warn(
`updateToSchemaVersion78: ${queue.queueType} queue job ${id} was missing valid data`
);
return;
}
const { messageId } = data;
if (typeof messageId !== 'string') {
logger.warn(
`updateToSchemaVersion78: ${queue.queueType} queue job ${id} had a non-string messageId`
);
return;
}
const message = getMessageByIdSync(db, messageId);
if (!message) {
logger.warn(
`updateToSchemaVersion78: Unable to find message for ${queue.queueType} job ${id}`
);
return;
}
const { conversationId } = message;
if (typeof conversationId !== 'string') {
logger.warn(
`updateToSchemaVersion78: ${queue.queueType} queue job ${id} had a non-string conversationId`
);
return;
}
const oldReceipts = queue.jobDataIsArray
? data[queue.jobDataKey]
: [data[queue.jobDataKey]];
if (!Array.isArray(oldReceipts)) {
logger.warn(
`updateToSchemaVersion78: ${queue.queueType} queue job ${id} had a non-array ${queue.jobDataKey}`
);
return;
}
const newReceipts = [];
for (const receipt of oldReceipts) {
if (!isRecord(receipt)) {
logger.warn(
`updateToSchemaVersion78: ${queue.queueType} queue job ${id} had a non-record receipt`
);
continue;
}
newReceipts.push({
...receipt,
conversationId,
});
}
const newJob = {
...job,
queueType: 'conversation',
data: {
type: 'Receipts',
conversationId,
receiptsType: queue.newReceiptsType,
receipts: newReceipts,
},
};
insertJobSync(db, newJob);
});
}
db.pragma('user_version = 78');
})();
logger.info('updateToSchemaVersion78: success!');
}

View file

@ -53,6 +53,7 @@ import updateToSchemaVersion74 from './74-optimize-convo-open';
import updateToSchemaVersion75 from './75-noop';
import updateToSchemaVersion76 from './76-optimize-convo-open-2';
import updateToSchemaVersion77 from './77-signal-tokenizer';
import updateToSchemaVersion78 from './78-merge-receipt-jobs';
function updateToSchemaVersion1(
currentVersion: number,
@ -1975,6 +1976,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion75,
updateToSchemaVersion76,
updateToSchemaVersion77,
updateToSchemaVersion78,
];
export function updateSchema(db: Database, logger: LoggerType): void {

View file

@ -103,7 +103,6 @@ import {
isGroupV2,
} from '../../util/whatTypeOfConversation';
import { missingCaseError } from '../../util/missingCaseError';
import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue';
import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { isIncoming, isOutgoing } from '../selectors/message';
@ -147,6 +146,7 @@ import {
setQuoteByMessageId,
resetComposer,
} from './composer';
import { ReceiptType } from '../../types/Receipt';
// State
@ -1675,17 +1675,24 @@ export const markViewed = (messageId: string): void => {
if (isIncoming(message.attributes)) {
const convoAttributes = message.getConversation()?.attributes;
const conversationId = message.get('conversationId');
drop(
viewedReceiptsJobQueue.add({
viewedReceipt: {
messageId,
senderE164,
senderUuid,
timestamp,
isDirectConversation: convoAttributes
? isDirectConversation(convoAttributes)
: true,
},
conversationJobQueue.add({
type: conversationQueueJobEnum.enum.Receipts,
conversationId,
receiptsType: ReceiptType.Viewed,
receipts: [
{
messageId,
conversationId,
senderE164,
senderUuid,
timestamp,
isDirectConversation: convoAttributes
? isDirectConversation(convoAttributes)
: true,
},
],
})
);
}

View file

@ -55,11 +55,15 @@ import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import { useBoundActions } from '../../hooks/useBoundActions';
import { verifyStoryListMembers as doVerifyStoryListMembers } from '../../util/verifyStoryListMembers';
import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue';
import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue';
import { getOwn } from '../../util/getOwn';
import { SHOW_TOAST } from './toast';
import { ToastType } from '../../types/Toast';
import type { ShowToastActionType } from './toast';
import {
conversationJobQueue,
conversationQueueJobEnum,
} from '../../jobs/conversationJobQueue';
import { ReceiptType } from '../../types/Receipt';
export type StoryDataType = ReadonlyDeep<
{
@ -399,8 +403,11 @@ function markStoryRead(
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
});
const conversationId = message.get('conversationId');
const viewedReceipt = {
messageId,
conversationId,
senderE164: message.attributes.source,
senderUuid: message.attributes.sourceUuid,
timestamp: message.attributes.sent_at,
@ -413,7 +420,14 @@ function markStoryRead(
}
if (window.Events.getStoryViewReceiptsEnabled()) {
drop(viewedReceiptsJobQueue.add({ viewedReceipt }));
drop(
conversationJobQueue.add({
type: conversationQueueJobEnum.enum.Receipts,
conversationId,
receiptsType: ReceiptType.Viewed,
receipts: [viewedReceipt],
})
);
}
await dataInterface.addNewStoryRead({

View file

@ -8,6 +8,7 @@ import type {
IPCRequest as ChallengeRequestType,
IPCResponse as ChallengeResponseType,
} from '../challenge';
import type { ReceiptType } from '../types/Receipt';
export type AppLoadedInfoType = Readonly<{
loadTime: number;
@ -23,6 +24,11 @@ export type ConversationOpenInfoType = Readonly<{
delta: number;
}>;
export type ReceiptsInfoType = Readonly<{
type: ReceiptType;
timestamps: Array<number>;
}>;
export type StorageServiceInfoType = Readonly<{
manifestVersion: number;
}>;
@ -70,6 +76,10 @@ export class App {
return this.waitForEvent('challenge');
}
public async waitForReceipts(): Promise<ReceiptsInfoType> {
return this.waitForEvent('receipts');
}
public async waitForStorageService(): Promise<StorageServiceInfoType> {
return this.waitForEvent('storageServiceComplete');
}

View file

@ -0,0 +1,128 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import type { PrimaryDevice } from '@signalapp/mock-server';
import { StorageState, UUIDKind } from '@signalapp/mock-server';
import createDebug from 'debug';
import * as durations from '../../util/durations';
import { Bootstrap } from '../bootstrap';
import type { App } from '../bootstrap';
import { ReceiptType } from '../../types/Receipt';
export const debug = createDebug('mock:test:challenge:receipts');
describe('challenge/receipts', function challengeReceiptsTest() {
this.timeout(durations.MINUTE * 100);
let bootstrap: Bootstrap;
let app: App;
let contact: PrimaryDevice;
beforeEach(async () => {
bootstrap = new Bootstrap({
contactCount: 0,
contactsWithoutProfileKey: 40,
});
await bootstrap.init();
app = await bootstrap.link();
const { server, desktop, phone } = bootstrap;
contact = await server.createPrimaryDevice({
profileName: 'Jamie',
});
let state = StorageState.getEmpty();
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
e164: phone.device.number,
givenName: phone.profileName,
readReceipts: true,
});
state = state.addContact(
contact,
{
whitelisted: true,
serviceE164: contact.device.number,
identityKey: contact.getPublicKey(UUIDKind.PNI).serialize(),
pni: contact.device.getUUIDByKind(UUIDKind.PNI),
givenName: 'Jamie',
},
UUIDKind.PNI
);
// Just to make PNI Contact visible in the left pane
state = state.pin(contact, UUIDKind.PNI);
const ourKey = await desktop.popSingleUseKey();
await contact.addSingleUseKey(desktop, ourKey);
await phone.setStorageState(state);
});
afterEach(async function after() {
if (this.currentTest?.state !== 'passed') {
await bootstrap.saveLogs(app);
}
await app.close();
await bootstrap.teardown();
});
it('should wait for the challenge to be handled', async () => {
const { server, desktop } = bootstrap;
debug(
`Rate limiting (desktop: ${desktop.uuid}) -> (contact: ${contact.device.uuid})`
);
server.rateLimit({ source: desktop.uuid, target: contact.device.uuid });
const timestamp = bootstrap.getTimestamp();
debug('Sending a message from contact');
await contact.sendText(desktop, 'Hello there!', {
timestamp,
});
const window = await app.getWindow();
const leftPane = window.locator('.left-pane-wrapper');
const conversationStack = window.locator('.conversation-stack');
debug(`Opening conversation with contact (${contact.toContact().uuid})`);
await leftPane
.locator(`[data-testid="${contact.toContact().uuid}"]`)
.click();
debug('Accept conversation from contact');
await conversationStack
.locator('.module-message-request-actions button >> "Accept"')
.click();
debug('Waiting for challenge');
const request = await app.waitForChallenge();
debug('Solving challenge');
await app.solveChallenge({
seq: request.seq,
data: { captcha: 'anything' },
});
const requests = server.stopRateLimiting({
source: desktop.uuid,
target: contact.device.uuid,
});
debug(`rate limited requests: ${requests}`);
assert.strictEqual(requests, 1);
debug('Waiting for receipts');
const receipts = await app.waitForReceipts();
assert.strictEqual(receipts.type, ReceiptType.Read);
assert.strictEqual(receipts.timestamps.length, 1);
assert.strictEqual(receipts.timestamps[0], timestamp);
});
});

View file

@ -2494,4 +2494,596 @@ describe('SQL migrations test', () => {
);
});
});
describe('updateToSchemaVersion78', () => {
it('moves receipt jobs over to conversation queue', () => {
updateToVersion(77);
const MESSAGE_ID_1 = generateGuid();
const CONVERSATION_ID_1 = generateGuid();
db.exec(
`
INSERT INTO messages
(id, json)
VALUES ('${MESSAGE_ID_1}', '${JSON.stringify({
conversationId: CONVERSATION_ID_1,
})}')
`
);
insertJobSync(db, {
id: 'id-1',
timestamp: 1,
queueType: 'random job',
data: {},
});
insertJobSync(db, {
id: 'id-2',
timestamp: 2,
queueType: 'delivery receipts',
data: {
messageId: MESSAGE_ID_1,
deliveryReceipts: [],
},
});
insertJobSync(db, {
id: 'id-3',
timestamp: 3,
queueType: 'read receipts',
data: {
messageId: MESSAGE_ID_1,
readReceipts: [],
},
});
insertJobSync(db, {
id: 'id-4',
timestamp: 4,
queueType: 'viewed receipts',
data: {
messageId: MESSAGE_ID_1,
viewedReceipt: {},
},
});
insertJobSync(db, {
id: 'id-5',
timestamp: 5,
queueType: 'conversation',
data: {},
});
const totalJobs = db.prepare('SELECT COUNT(*) FROM jobs;').pluck();
const conversationJobs = db
.prepare("SELECT COUNT(*) FROM jobs WHERE queueType = 'conversation';")
.pluck();
const deliveryJobs = db
.prepare(
"SELECT COUNT(*) FROM jobs WHERE queueType = 'delivery receipts';"
)
.pluck();
const readJobs = db
.prepare("SELECT COUNT(*) FROM jobs WHERE queueType = 'read receipts';")
.pluck();
const viewedJobs = db
.prepare(
"SELECT COUNT(*) FROM jobs WHERE queueType = 'viewed receipts';"
)
.pluck();
assert.strictEqual(totalJobs.get(), 5, 'before total');
assert.strictEqual(conversationJobs.get(), 1, 'before conversation');
assert.strictEqual(deliveryJobs.get(), 1, 'before delivery');
assert.strictEqual(readJobs.get(), 1, 'before read');
assert.strictEqual(viewedJobs.get(), 1, 'before viewed');
updateToVersion(78);
assert.strictEqual(totalJobs.get(), 5, 'after total');
assert.strictEqual(conversationJobs.get(), 4, 'after conversation');
assert.strictEqual(deliveryJobs.get(), 0, 'after delivery');
assert.strictEqual(readJobs.get(), 0, 'after read');
assert.strictEqual(viewedJobs.get(), 0, 'after viewed');
});
it('updates delivery jobs with their conversationId', () => {
updateToVersion(77);
const MESSAGE_ID_1 = generateGuid();
const MESSAGE_ID_2 = generateGuid();
const MESSAGE_ID_3 = generateGuid();
const CONVERSATION_ID_1 = generateGuid();
const CONVERSATION_ID_2 = generateGuid();
insertJobSync(db, {
id: 'id-1',
timestamp: 1,
queueType: 'delivery receipts',
data: {
messageId: MESSAGE_ID_1,
deliveryReceipts: [
{
messageId: MESSAGE_ID_1,
timestamp: 1,
},
],
},
});
insertJobSync(db, {
id: 'id-2',
timestamp: 2,
queueType: 'delivery receipts',
data: {
messageId: MESSAGE_ID_2,
deliveryReceipts: [
{
messageId: MESSAGE_ID_1,
timestamp: 2,
},
],
},
});
insertJobSync(db, {
id: 'id-3-missing-data',
timestamp: 3,
queueType: 'delivery receipts',
});
insertJobSync(db, {
id: 'id-4-non-string-messageId',
timestamp: 4,
queueType: 'delivery receipts',
data: {
messageId: 4,
deliveryReceipts: [
{
messageId: MESSAGE_ID_1,
timestamp: 4,
},
],
},
});
insertJobSync(db, {
id: 'id-5-missing-message',
timestamp: 5,
queueType: 'delivery receipts',
data: {
messageId: 'missing',
deliveryReceipts: [
{
messageId: MESSAGE_ID_1,
timestamp: 5,
},
],
},
});
insertJobSync(db, {
id: 'id-6-missing-conversation',
timestamp: 6,
queueType: 'delivery receipts',
data: {
messageId: MESSAGE_ID_3,
deliveryReceipts: [
{
messageId: MESSAGE_ID_1,
timestamp: 6,
},
],
},
});
insertJobSync(db, {
id: 'id-7-missing-delivery-receipts',
timestamp: 7,
queueType: 'delivery receipts',
data: {
messageId: MESSAGE_ID_3,
},
});
const messageJson1 = JSON.stringify({
conversationId: CONVERSATION_ID_1,
});
const messageJson2 = JSON.stringify({
conversationId: CONVERSATION_ID_2,
});
db.exec(
`
INSERT INTO messages
(id, conversationId, json)
VALUES
('${MESSAGE_ID_1}', '${CONVERSATION_ID_1}', '${messageJson1}'),
('${MESSAGE_ID_2}', '${CONVERSATION_ID_2}', '${messageJson2}'),
('${MESSAGE_ID_3}', null, '{}');
`
);
const totalJobs = db.prepare('SELECT COUNT(*) FROM jobs;').pluck();
const conversationJobs = db
.prepare("SELECT COUNT(*) FROM jobs WHERE queueType = 'conversation';")
.pluck();
const deliveryJobs = db
.prepare(
"SELECT COUNT(*) FROM jobs WHERE queueType = 'delivery receipts';"
)
.pluck();
assert.strictEqual(totalJobs.get(), 7, 'total jobs before');
assert.strictEqual(conversationJobs.get(), 0, 'conversation jobs before');
assert.strictEqual(deliveryJobs.get(), 7, 'delivery jobs before');
updateToVersion(78);
assert.strictEqual(totalJobs.get(), 2, 'total jobs after');
assert.strictEqual(conversationJobs.get(), 2, 'conversation jobs after');
assert.strictEqual(deliveryJobs.get(), 0, 'delivery jobs after');
const jobs = getJobsInQueueSync(db, 'conversation');
assert.deepEqual(jobs, [
{
id: 'id-1',
timestamp: 1,
queueType: 'conversation',
data: {
type: 'Receipts',
conversationId: CONVERSATION_ID_1,
receiptsType: 'deliveryReceipt',
receipts: [
{
messageId: MESSAGE_ID_1,
conversationId: CONVERSATION_ID_1,
timestamp: 1,
},
],
},
},
{
id: 'id-2',
timestamp: 2,
queueType: 'conversation',
data: {
type: 'Receipts',
conversationId: CONVERSATION_ID_2,
receiptsType: 'deliveryReceipt',
receipts: [
{
messageId: MESSAGE_ID_1,
conversationId: CONVERSATION_ID_2,
timestamp: 2,
},
],
},
},
]);
});
it('updates read jobs with their conversationId', () => {
updateToVersion(77);
const MESSAGE_ID_1 = generateGuid();
const MESSAGE_ID_2 = generateGuid();
const MESSAGE_ID_3 = generateGuid();
const CONVERSATION_ID_1 = generateGuid();
const CONVERSATION_ID_2 = generateGuid();
insertJobSync(db, {
id: 'id-1',
timestamp: 1,
queueType: 'read receipts',
data: {
messageId: MESSAGE_ID_1,
readReceipts: [
{
messageId: MESSAGE_ID_1,
timestamp: 1,
},
],
},
});
insertJobSync(db, {
id: 'id-2',
timestamp: 2,
queueType: 'read receipts',
data: {
messageId: MESSAGE_ID_2,
readReceipts: [
{
messageId: MESSAGE_ID_1,
timestamp: 2,
},
],
},
});
insertJobSync(db, {
id: 'id-3-missing-data',
timestamp: 3,
queueType: 'read receipts',
});
insertJobSync(db, {
id: 'id-4-non-string-messageId',
timestamp: 4,
queueType: 'read receipts',
data: {
messageId: 4,
readReceipts: [
{
messageId: MESSAGE_ID_1,
timestamp: 4,
},
],
},
});
insertJobSync(db, {
id: 'id-5-missing-message',
timestamp: 5,
queueType: 'read receipts',
data: {
messageId: 'missing',
readReceipts: [
{
messageId: MESSAGE_ID_1,
timestamp: 5,
},
],
},
});
insertJobSync(db, {
id: 'id-6-missing-conversation',
timestamp: 6,
queueType: 'read receipts',
data: {
messageId: MESSAGE_ID_3,
readReceipts: [
{
messageId: MESSAGE_ID_1,
timestamp: 6,
},
],
},
});
insertJobSync(db, {
id: 'id-7-missing-read-receipts',
timestamp: 7,
queueType: 'read receipts',
data: {
messageId: MESSAGE_ID_3,
},
});
const messageJson1 = JSON.stringify({
conversationId: CONVERSATION_ID_1,
});
const messageJson2 = JSON.stringify({
conversationId: CONVERSATION_ID_2,
});
db.exec(
`
INSERT INTO messages
(id, conversationId, json)
VALUES
('${MESSAGE_ID_1}', '${CONVERSATION_ID_1}', '${messageJson1}'),
('${MESSAGE_ID_2}', '${CONVERSATION_ID_2}', '${messageJson2}'),
('${MESSAGE_ID_3}', null, '{}');
`
);
const totalJobs = db.prepare('SELECT COUNT(*) FROM jobs;').pluck();
const conversationJobs = db
.prepare("SELECT COUNT(*) FROM jobs WHERE queueType = 'conversation';")
.pluck();
const readJobs = db
.prepare("SELECT COUNT(*) FROM jobs WHERE queueType = 'read receipts';")
.pluck();
assert.strictEqual(totalJobs.get(), 7, 'total jobs before');
assert.strictEqual(conversationJobs.get(), 0, 'conversation jobs before');
assert.strictEqual(readJobs.get(), 7, 'delivery jobs before');
updateToVersion(78);
assert.strictEqual(totalJobs.get(), 2, 'total jobs after');
assert.strictEqual(conversationJobs.get(), 2, 'conversation jobs after');
assert.strictEqual(readJobs.get(), 0, 'read jobs after');
const jobs = getJobsInQueueSync(db, 'conversation');
assert.deepEqual(jobs, [
{
id: 'id-1',
timestamp: 1,
queueType: 'conversation',
data: {
type: 'Receipts',
conversationId: CONVERSATION_ID_1,
receiptsType: 'readReceipt',
receipts: [
{
messageId: MESSAGE_ID_1,
conversationId: CONVERSATION_ID_1,
timestamp: 1,
},
],
},
},
{
id: 'id-2',
timestamp: 2,
queueType: 'conversation',
data: {
type: 'Receipts',
conversationId: CONVERSATION_ID_2,
receiptsType: 'readReceipt',
receipts: [
{
messageId: MESSAGE_ID_1,
conversationId: CONVERSATION_ID_2,
timestamp: 2,
},
],
},
},
]);
});
it('updates viewed jobs with their conversationId', () => {
updateToVersion(77);
const MESSAGE_ID_1 = generateGuid();
const MESSAGE_ID_2 = generateGuid();
const MESSAGE_ID_3 = generateGuid();
const CONVERSATION_ID_1 = generateGuid();
const CONVERSATION_ID_2 = generateGuid();
insertJobSync(db, {
id: 'id-1',
timestamp: 1,
queueType: 'viewed receipts',
data: {
messageId: MESSAGE_ID_1,
viewedReceipt: {
messageId: MESSAGE_ID_1,
timestamp: 1,
},
},
});
insertJobSync(db, {
id: 'id-2',
timestamp: 2,
queueType: 'viewed receipts',
data: {
messageId: MESSAGE_ID_2,
viewedReceipt: {
messageId: MESSAGE_ID_1,
timestamp: 2,
},
},
});
insertJobSync(db, {
id: 'id-3-missing-data',
timestamp: 3,
queueType: 'viewed receipts',
});
insertJobSync(db, {
id: 'id-4-non-string-messageId',
timestamp: 4,
queueType: 'viewed receipts',
data: {
messageId: 4,
viewedReceipt: {
messageId: MESSAGE_ID_1,
timestamp: 4,
},
},
});
insertJobSync(db, {
id: 'id-5-missing-message',
timestamp: 5,
queueType: 'viewed receipts',
data: {
messageId: 'missing',
viewedReceipt: {
messageId: MESSAGE_ID_1,
timestamp: 5,
},
},
});
insertJobSync(db, {
id: 'id-6-missing-conversation',
timestamp: 6,
queueType: 'viewed receipts',
data: {
messageId: MESSAGE_ID_3,
viewedReceipt: {
messageId: MESSAGE_ID_1,
timestamp: 6,
},
},
});
insertJobSync(db, {
id: 'id-7-missing-viewed-receipt',
timestamp: 7,
queueType: 'viewed receipts',
data: {
messageId: MESSAGE_ID_3,
},
});
const messageJson1 = JSON.stringify({
conversationId: CONVERSATION_ID_1,
});
const messageJson2 = JSON.stringify({
conversationId: CONVERSATION_ID_2,
});
db.exec(
`
INSERT INTO messages
(id, conversationId, json)
VALUES
('${MESSAGE_ID_1}', '${CONVERSATION_ID_1}', '${messageJson1}'),
('${MESSAGE_ID_2}', '${CONVERSATION_ID_2}', '${messageJson2}'),
('${MESSAGE_ID_3}', null, '{}');
`
);
const totalJobs = db.prepare('SELECT COUNT(*) FROM jobs;').pluck();
const conversationJobs = db
.prepare("SELECT COUNT(*) FROM jobs WHERE queueType = 'conversation';")
.pluck();
const viewedJobs = db
.prepare(
"SELECT COUNT(*) FROM jobs WHERE queueType = 'viewed receipts';"
)
.pluck();
assert.strictEqual(totalJobs.get(), 7, 'total jobs before');
assert.strictEqual(conversationJobs.get(), 0, 'conversation jobs before');
assert.strictEqual(viewedJobs.get(), 7, 'delivery jobs before');
updateToVersion(78);
assert.strictEqual(totalJobs.get(), 2, 'total jobs after');
assert.strictEqual(conversationJobs.get(), 2, 'conversation jobs after');
assert.strictEqual(viewedJobs.get(), 0, 'viewed jobs after');
const jobs = getJobsInQueueSync(db, 'conversation');
assert.deepEqual(jobs, [
{
id: 'id-1',
timestamp: 1,
queueType: 'conversation',
data: {
type: 'Receipts',
conversationId: CONVERSATION_ID_1,
receiptsType: 'viewedReceipt',
receipts: [
{
messageId: MESSAGE_ID_1,
conversationId: CONVERSATION_ID_1,
timestamp: 1,
},
],
},
},
{
id: 'id-2',
timestamp: 2,
queueType: 'conversation',
data: {
type: 'Receipts',
conversationId: CONVERSATION_ID_2,
receiptsType: 'viewedReceipt',
receipts: [
{
messageId: MESSAGE_ID_1,
conversationId: CONVERSATION_ID_2,
timestamp: 2,
},
],
},
},
]);
});
});
});

View file

@ -5,6 +5,7 @@ import { z } from 'zod';
export const receiptSchema = z.object({
messageId: z.string(),
conversationId: z.string(),
senderE164: z.string().optional(),
senderUuid: z.string().optional(),
timestamp: z.number(),

View file

@ -5,7 +5,6 @@ import { omit } from 'lodash';
import type { ConversationAttributesType } from '../model-types.d';
import { hasErrors } from '../state/selectors/message';
import { readReceiptsJobQueue } from '../jobs/readReceiptsJobQueue';
import { readSyncJobQueue } from '../jobs/readSyncJobQueue';
import { notificationService } from '../services/notifications';
import { expiringMessagesDeletionService } from '../services/expiringMessagesDeletion';
@ -16,6 +15,11 @@ import { getConversationIdForLogging } from './idForLogging';
import { drop } from './drop';
import { isConversationAccepted } from './isConversationAccepted';
import { ReadStatus } from '../messages/MessageReadStatus';
import {
conversationJobQueue,
conversationQueueJobEnum,
} from '../jobs/conversationJobQueue';
import { ReceiptType } from '../types/Receipt';
export async function markConversationRead(
conversationAttrs: ConversationAttributesType,
@ -88,6 +92,7 @@ export async function markConversationRead(
return {
messageId: messageSyncData.id,
conversationId: conversationAttrs.id,
originalReadStatus: messageSyncData.originalReadStatus,
senderE164: messageSyncData.source,
senderUuid: messageSyncData.sourceUuid,
@ -138,10 +143,12 @@ export async function markConversationRead(
}
if (isConversationAccepted(conversationAttrs)) {
await readReceiptsJobQueue.addIfAllowedByUser(
window.storage,
allReadMessagesSync
);
await conversationJobQueue.add({
type: conversationQueueJobEnum.enum.Receipts,
conversationId,
receiptsType: ReceiptType.Read,
receipts: allReadMessagesSync,
});
}
}

View file

@ -137,6 +137,11 @@ export async function sendReceipts({
}),
{ messageIds, sendType: type }
);
window.SignalCI?.handleEvent('receipts', {
type,
timestamps,
});
})
);
})