signal-desktop/ts/jobs/helpers/sendStory.ts

704 lines
22 KiB
TypeScript
Raw Normal View History

2022-08-02 19:31:55 +00:00
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isEqual } from 'lodash';
import type { UploadedAttachmentType } from '../../types/Attachment';
2022-08-02 19:31:55 +00:00
import type { ConversationModel } from '../../models/conversations';
import type {
ConversationQueueJobBundle,
StoryJobData,
} from '../conversationJobQueue';
import type { LoggerType } from '../../types/Logging';
import type { MessageModel } from '../../models/messages';
import type {
SendState,
SendStateByConversationId,
} from '../../messages/MessageSendState';
import {
isSent,
SendActionType,
sendStateReducer,
} from '../../messages/MessageSendState';
import type { ServiceIdString } from '../../types/ServiceId';
import type { StoryDistributionIdString } from '../../types/StoryDistributionId';
2022-08-02 19:31:55 +00:00
import * as Errors from '../../types/errors';
2022-11-29 02:07:26 +00:00
import type { StoryMessageRecipientsType } from '../../types/Stories';
2022-08-02 19:31:55 +00:00
import dataInterface from '../../sql/Client';
import { SignalService as Proto } from '../../protobuf';
2022-11-29 01:02:01 +00:00
import { getMessagesById } from '../../messages/getMessagesById';
2022-08-02 19:31:55 +00:00
import {
getSendOptions,
getSendOptionsForRecipients,
} from '../../util/getSendOptions';
import { handleMessageSend } from '../../util/handleMessageSend';
import { handleMultipleSendErrors } from './handleMultipleSendErrors';
2022-08-09 03:26:21 +00:00
import { isGroupV2, isMe } from '../../util/whatTypeOfConversation';
2022-08-02 19:31:55 +00:00
import { ourProfileKeyService } from '../../services/ourProfileKey';
import { sendContentMessageToGroup } from '../../util/sendToGroup';
2022-11-29 02:07:26 +00:00
import { distributionListToSendTarget } from '../../util/distributionListToSendTarget';
import { uploadAttachment } from '../../util/uploadAttachment';
import { SendMessageChallengeError } from '../../textsecure/Errors';
import type { OutgoingTextAttachmentType } from '../../textsecure/SendMessage';
2022-08-02 19:31:55 +00:00
export async function sendStory(
conversation: ConversationModel,
{
isFinalAttempt,
messaging,
shouldContinue,
timeRemaining,
log,
}: ConversationQueueJobBundle,
data: StoryJobData
): Promise<void> {
2022-08-04 19:23:24 +00:00
const { messageIds, timestamp } = data;
2022-08-02 19:31:55 +00:00
const profileKey = await ourProfileKeyService.get();
if (!profileKey) {
log.info('stories.sendStory: no profile key cannot send');
return;
}
// We can send a story to either:
// 1) the current group, or
// 2) all selected distribution lists (in queue for our own conversationId)
if (!isGroupV2(conversation.attributes) && !isMe(conversation.attributes)) {
log.error(
'stories.sendStory: Conversation is neither groupV2 nor our own. Cannot send.'
);
return;
}
2022-11-29 01:02:01 +00:00
const notFound = new Set(messageIds);
const messages = (await getMessagesById(messageIds)).filter(message => {
notFound.delete(message.id);
const distributionId = message.get('storyDistributionListId');
const logId = `stories.sendStory(${timestamp}/${distributionId})`;
2022-08-04 19:23:24 +00:00
2022-08-10 18:37:19 +00:00
const messageConversation = message.getConversation();
if (messageConversation !== conversation) {
log.error(
2022-11-29 01:02:01 +00:00
`${logId}: Message conversation ` +
`'${messageConversation?.idForLogging()}' does not match job ` +
`conversation ${conversation.idForLogging()}`
);
return false;
}
if (message.get('timestamp') !== timestamp) {
log.error(
`${logId}: Message timestamp ${message.get(
'timestamp'
)} does not match job timestamp`
2022-08-10 18:37:19 +00:00
);
2022-11-29 01:02:01 +00:00
return false;
}
if (message.isErased() || message.get('deletedForEveryone')) {
log.info(`${logId}: message was erased. Giving up on sending it`);
return false;
}
return true;
});
for (const messageId of notFound) {
log.info(
`stories.sendStory(${messageId}): message was not found, ` +
'maybe because it was deleted. Giving up on sending it'
);
}
// We want to generate the StoryMessage proto once at the top level so we
// can reuse it but first we'll need textAttachment | fileAttachment.
// This function pulls off the attachment and generates the proto from the
// first message on the list prior to continuing.
let originalStoryMessage: Proto.StoryMessage;
{
const [originalMessageId] = messageIds;
const originalMessage = messages.find(
message => message.id === originalMessageId
);
if (!originalMessage) {
2022-08-10 18:37:19 +00:00
return;
}
2022-11-29 01:02:01 +00:00
const attachments = originalMessage.get('attachments') || [];
const bodyRanges = originalMessage.get('bodyRanges')?.slice();
2022-08-04 19:23:24 +00:00
const [attachment] = attachments;
if (!attachment) {
log.info(
2022-11-29 01:02:01 +00:00
`stories.sendStory(${timestamp}): original story message does not ` +
'have any attachments to send. Giving up on sending it'
2022-08-04 19:23:24 +00:00
);
return;
}
let textAttachment: OutgoingTextAttachmentType | undefined;
let fileAttachment: UploadedAttachmentType | undefined;
2022-08-04 19:23:24 +00:00
if (attachment.textAttachment) {
const localAttachment = attachment.textAttachment;
// Pacify typescript
if (localAttachment.preview === undefined) {
textAttachment = {
...localAttachment,
preview: undefined,
};
} else {
const hydratedPreview = (
await window.Signal.Migrations.loadPreviewData([
localAttachment.preview,
])
)[0];
textAttachment = {
...localAttachment,
preview: {
...hydratedPreview,
image:
hydratedPreview.image &&
(await uploadAttachment(hydratedPreview.image)),
},
};
}
2022-08-04 19:23:24 +00:00
} else {
const hydratedAttachment =
await window.Signal.Migrations.loadAttachmentData(attachment);
fileAttachment = await uploadAttachment(hydratedAttachment);
2022-08-04 19:23:24 +00:00
}
2022-08-10 18:37:19 +00:00
const groupV2 = isGroupV2(conversation.attributes)
? conversation.getGroupV2Info()
: undefined;
2022-08-04 19:23:24 +00:00
// Some distribution lists need allowsReplies false, some need it set to true
// we create this proto (for the sync message) and also to re-use some of the
// attributes inside it.
2022-11-29 01:02:01 +00:00
originalStoryMessage = await messaging.getStoryMessage({
2022-08-04 19:23:24 +00:00
allowsReplies: true,
bodyRanges,
2022-08-04 19:23:24 +00:00
fileAttachment,
2022-08-10 18:37:19 +00:00
groupV2,
2022-08-04 19:23:24 +00:00
textAttachment,
profileKey,
});
}
2022-08-02 19:31:55 +00:00
const canReplyServiceIds = new Set<ServiceIdString>();
const recipientsByServiceId = new Map<
ServiceIdString,
Set<StoryDistributionIdString>
>();
2022-08-09 03:26:21 +00:00
const sentConversationIds = new Map<string, SendState>();
const sentServiceIds = new Set<ServiceIdString>();
2022-08-02 19:31:55 +00:00
// This function is used to keep track of all the recipients so once we're
// done with our send we can build up the storyMessageRecipients object for
// sending in the sync message.
function addDistributionListToServiceIdSent(
listId: StoryDistributionIdString | undefined,
serviceId: ServiceIdString,
2022-08-02 19:31:55 +00:00
canReply?: boolean
): void {
2023-08-16 20:54:39 +00:00
if (conversation.getServiceId() === serviceId) {
2022-08-02 19:31:55 +00:00
return;
}
const distributionListIds =
recipientsByServiceId.get(serviceId) ||
new Set<StoryDistributionIdString>();
2022-08-02 19:31:55 +00:00
2022-08-09 03:26:21 +00:00
if (listId) {
recipientsByServiceId.set(
serviceId,
new Set([...distributionListIds, listId])
);
2022-08-09 03:26:21 +00:00
} else {
recipientsByServiceId.set(serviceId, distributionListIds);
2022-08-09 03:26:21 +00:00
}
2022-08-02 19:31:55 +00:00
if (canReply) {
canReplyServiceIds.add(serviceId);
2022-08-02 19:31:55 +00:00
}
}
let isSyncMessageUpdate = false;
// Note: We capture errors here so we are sure to wait for every send process to
// complete, and so we can send a sync message afterwards if we sent the story
// successfully to at least one recipient.
const sendResults = await Promise.allSettled(
2022-11-29 01:02:01 +00:00
messages.map(async (message: MessageModel): Promise<void> => {
const distributionId = message.get('storyDistributionListId');
const logId = `stories.sendStory(${timestamp}/${distributionId})`;
2022-08-02 19:31:55 +00:00
const listId = message.get('storyDistributionListId');
2022-11-29 01:02:01 +00:00
const receiverId = isGroupV2(conversation.attributes)
? conversation.id
2022-08-09 03:26:21 +00:00
: listId;
2022-08-02 19:31:55 +00:00
2022-08-09 03:26:21 +00:00
if (!receiverId) {
2022-08-02 19:31:55 +00:00
log.info(
`${logId}: did not get a valid recipient ID for message. Giving up on sending it`
2022-08-02 19:31:55 +00:00
);
return;
}
2022-11-29 01:02:01 +00:00
const distributionList = isGroupV2(conversation.attributes)
2022-08-09 03:26:21 +00:00
? undefined
: await dataInterface.getStoryDistributionWithMembers(receiverId);
2022-08-02 19:31:55 +00:00
let messageSendErrors: Array<Error> = [];
// We don't want to save errors on messages unless we're giving up. If it's our
// final attempt, we know upfront that we want to give up. However, we might also
// want to give up if (1) we get a 508 from the server, asking us to please stop
// (2) we get a 428 from the server, flagging the message for spam (3) some other
// reason not known at the time of this writing.
//
// This awkward callback lets us hold onto errors we might want to save, so we can
// decide whether to save them later on.
const saveErrors = isFinalAttempt
? undefined
: (errors: Array<Error>) => {
messageSendErrors = errors;
};
if (!shouldContinue) {
log.info(`${logId}: ran out of time. Giving up on sending it`);
2022-08-02 19:31:55 +00:00
await markMessageFailed(message, [
new Error('Message send ran out of time'),
]);
return;
}
let originalError: Error | undefined;
const {
allRecipientServiceIds,
allowedReplyByServiceId,
pendingSendRecipientServiceIds,
2022-08-09 03:26:21 +00:00
sentRecipientIds,
untrustedServiceIds,
2022-08-02 19:31:55 +00:00
} = getMessageRecipients({
log,
message,
});
try {
if (untrustedServiceIds.length) {
2022-08-02 19:31:55 +00:00
window.reduxActions.conversations.conversationStoppedByMissingVerification(
{
conversationId: conversation.id,
distributionId,
untrustedServiceIds,
2022-08-02 19:31:55 +00:00
}
);
throw new Error(
`${logId}: sending blocked because ${untrustedServiceIds.length} conversation(s) were untrusted. Failing this attempt.`
2022-08-02 19:31:55 +00:00
);
}
if (!pendingSendRecipientServiceIds.length) {
allRecipientServiceIds.forEach(serviceId =>
addDistributionListToServiceIdSent(
2022-08-02 19:31:55 +00:00
listId,
serviceId,
allowedReplyByServiceId.get(serviceId)
2022-08-02 19:31:55 +00:00
)
);
return;
}
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const sendOptions = await getSendOptionsForRecipients(
pendingSendRecipientServiceIds,
{ story: true }
2022-08-02 19:31:55 +00:00
);
log.info(
`stories.sendStory(${timestamp}): sending story to ${receiverId}`
2022-08-02 19:31:55 +00:00
);
const storyMessage = new Proto.StoryMessage();
storyMessage.bodyRanges = originalStoryMessage.bodyRanges;
2022-08-02 19:31:55 +00:00
storyMessage.profileKey = originalStoryMessage.profileKey;
storyMessage.fileAttachment = originalStoryMessage.fileAttachment;
storyMessage.textAttachment = originalStoryMessage.textAttachment;
storyMessage.group = originalStoryMessage.group;
2022-08-09 03:26:21 +00:00
storyMessage.allowsReplies =
2022-11-29 01:02:01 +00:00
isGroupV2(conversation.attributes) ||
2022-08-09 03:26:21 +00:00
Boolean(distributionList?.allowsReplies);
const sendTarget = distributionList
2022-11-29 02:07:26 +00:00
? distributionListToSendTarget(
distributionList,
pendingSendRecipientServiceIds
2022-11-29 02:07:26 +00:00
)
2022-08-09 03:26:21 +00:00
: conversation.toSenderKeyTarget();
2022-08-02 19:31:55 +00:00
const contentMessage = new Proto.Content();
contentMessage.storyMessage = storyMessage;
const innerPromise = sendContentMessageToGroup({
contentHint: ContentHint.IMPLICIT,
contentMessage,
isPartialSend: false,
messageId: undefined,
recipients: pendingSendRecipientServiceIds,
2022-08-02 19:31:55 +00:00
sendOptions,
2022-08-09 03:26:21 +00:00
sendTarget,
2022-08-02 19:31:55 +00:00
sendType: 'story',
story: true,
timestamp: message.get('timestamp'),
2022-08-02 19:31:55 +00:00
urgent: false,
});
// Don't send normal sync messages; a story sync is sent at the end of the process
2022-11-29 01:02:01 +00:00
// eslint-disable-next-line no-param-reassign
message.doNotSendSyncMessage = true;
2022-08-02 19:31:55 +00:00
const messageSendPromise = message.send(
handleMessageSend(innerPromise, {
2022-11-29 01:02:01 +00:00
messageIds: [message.id],
2022-08-02 19:31:55 +00:00
sendType: 'story',
}),
saveErrors
);
// Because message.send swallows and processes errors, we'll await the
// inner promise to get the SendMessageProtoError, which gives us
// information upstream processors need to detect certain kinds of situations.
try {
await innerPromise;
} catch (error) {
if (error instanceof Error) {
originalError = error;
} else {
log.error(
`${logId}: promiseForError threw something other than an error: ${Errors.toLogFormat(
2022-08-02 19:31:55 +00:00
error
)}`
);
}
}
await messageSendPromise;
// Track sendState across message sends so that we can update all
// subsequent messages.
const sendStateByConversationId =
message.get('sendStateByConversationId') || {};
Object.entries(sendStateByConversationId).forEach(
([recipientConversationId, sendState]) => {
2022-08-09 03:26:21 +00:00
if (!isSent(sendState.status)) {
2022-08-02 19:31:55 +00:00
return;
}
2022-08-09 03:26:21 +00:00
sentConversationIds.set(recipientConversationId, sendState);
const recipient = window.ConversationController.get(
recipientConversationId
2022-08-02 19:31:55 +00:00
);
const serviceId = recipient?.getServiceId();
if (!serviceId) {
2022-08-09 03:26:21 +00:00
return;
}
sentServiceIds.add(serviceId);
2022-08-02 19:31:55 +00:00
}
);
allRecipientServiceIds.forEach(serviceId => {
addDistributionListToServiceIdSent(
2022-08-09 03:26:21 +00:00
listId,
serviceId,
allowedReplyByServiceId.get(serviceId)
2022-08-09 03:26:21 +00:00
);
});
2022-08-02 19:31:55 +00:00
const didFullySend =
!messageSendErrors.length || didSendToEveryone(message);
if (!didFullySend) {
throw new Error(`${logId}: message did not fully send`);
2022-08-02 19:31:55 +00:00
}
} catch (thrownError: unknown) {
const errors = [thrownError, ...messageSendErrors];
// We need to check for this here because we can only throw one error up to
// conversationJobQueue.
errors.forEach(error => {
if (error instanceof SendMessageChallengeError) {
void window.Signal.challengeHandler?.register(
{
conversationId: conversation.id,
createdAt: Date.now(),
retryAt: error.retryAt,
token: error.data?.token,
reason:
'conversationJobQueue.run(' +
`${conversation.idForLogging()}, story, ${timestamp}/${distributionId})`,
silent: false,
},
error.data
);
}
});
2022-08-02 19:31:55 +00:00
await handleMultipleSendErrors({
errors,
isFinalAttempt,
log,
markFailed: () => markMessageFailed(message, messageSendErrors),
timeRemaining,
// In the case of a failed group send thrownError will not be
// SentMessageProtoError, but we should have been able to harvest
// the original error. In the Note to Self send case, thrownError
// will be the error we care about, and we won't have an originalError.
toThrow: originalError || thrownError,
});
} finally {
2022-08-09 03:26:21 +00:00
isSyncMessageUpdate = sentRecipientIds.length > 0;
}
})
);
// Some contacts are duplicated across lists and we don't send duplicate
// messages but we still want to make sure that the sendStateByConversationId
// is kept in sync across all messages.
await Promise.all(
2022-11-29 01:02:01 +00:00
messages.map(async message => {
2022-08-09 03:26:21 +00:00
const oldSendStateByConversationId =
message.get('sendStateByConversationId') || {};
2023-02-07 19:33:04 +00:00
let hasFailedSends = false;
2022-08-09 03:26:21 +00:00
const newSendStateByConversationId = Object.keys(
oldSendStateByConversationId
).reduce((acc, conversationId) => {
const sendState = sentConversationIds.get(conversationId);
if (sendState) {
return {
...acc,
[conversationId]: sendState,
};
}
const oldSendState = {
...oldSendStateByConversationId[conversationId],
};
if (!oldSendState) {
return acc;
}
const recipient = window.ConversationController.get(conversationId);
if (!recipient) {
return acc;
}
2022-11-16 22:10:11 +00:00
if (isMe(recipient.attributes)) {
return acc;
}
if (recipient.isEverUnregistered()) {
if (!isSent(oldSendState.status)) {
// We should have filtered this out on initial send, but we'll drop them from
// send list here if needed.
return acc;
}
// If a previous send to them did succeed, we'll keep that status around
return {
...acc,
[conversationId]: oldSendState,
};
}
2023-02-07 19:33:04 +00:00
hasFailedSends = true;
return {
...acc,
[conversationId]: sendStateReducer(oldSendState, {
type: SendActionType.Failed,
updatedAt: Date.now(),
}),
};
2022-08-09 03:26:21 +00:00
}, {} as SendStateByConversationId);
2023-02-07 19:33:04 +00:00
if (hasFailedSends) {
message.notifyStorySendFailed();
}
2022-08-09 03:26:21 +00:00
if (isEqual(oldSendStateByConversationId, newSendStateByConversationId)) {
return;
}
message.set('sendStateByConversationId', newSendStateByConversationId);
return window.Signal.Data.saveMessage(message.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
2022-08-09 03:26:21 +00:00
});
2022-08-02 19:31:55 +00:00
})
);
2022-08-09 03:26:21 +00:00
// Remove any unsent recipients
recipientsByServiceId.forEach((_value, serviceId) => {
if (sentServiceIds.has(serviceId)) {
2022-08-09 03:26:21 +00:00
return;
}
recipientsByServiceId.delete(serviceId);
2022-08-09 03:26:21 +00:00
});
// Build up the sync message's storyMessageRecipients and send it
2022-11-29 02:07:26 +00:00
const storyMessageRecipients: StoryMessageRecipientsType = [];
recipientsByServiceId.forEach((distributionListIds, destinationServiceId) => {
2022-08-02 19:31:55 +00:00
storyMessageRecipients.push({
destinationServiceId,
2022-08-02 19:31:55 +00:00
distributionListIds: Array.from(distributionListIds),
isAllowedToReply: canReplyServiceIds.has(destinationServiceId),
2022-08-02 19:31:55 +00:00
});
});
if (storyMessageRecipients.length === 0) {
log.warn(
'No successful sends; will not send a sync message for this attempt'
);
} else {
const options = await getSendOptions(conversation.attributes, {
syncMessage: true,
});
2022-08-02 19:31:55 +00:00
await messaging.sendSyncMessage({
// Note: these two fields will be undefined if we're sending to a group
destination: conversation.get('e164'),
destinationServiceId: conversation.getServiceId(),
storyMessage: originalStoryMessage,
storyMessageRecipients,
expirationStartTimestamp: null,
isUpdate: isSyncMessageUpdate,
options,
timestamp,
urgent: false,
});
}
// We can only throw one Error up to conversationJobQueue to fail the send
const sendErrors: Array<PromiseRejectedResult> = [];
sendResults.forEach(result => {
if (result.status === 'rejected') {
sendErrors.push(result);
}
2022-08-02 19:31:55 +00:00
});
if (sendErrors.length) {
throw sendErrors[0].reason;
}
2022-08-02 19:31:55 +00:00
}
function getMessageRecipients({
log,
message,
}: Readonly<{
log: LoggerType;
message: MessageModel;
}>): {
allRecipientServiceIds: Array<ServiceIdString>;
allowedReplyByServiceId: Map<ServiceIdString, boolean>;
pendingSendRecipientServiceIds: Array<ServiceIdString>;
2022-08-09 03:26:21 +00:00
sentRecipientIds: Array<string>;
untrustedServiceIds: Array<ServiceIdString>;
2022-08-02 19:31:55 +00:00
} {
const allRecipientServiceIds: Array<ServiceIdString> = [];
const allowedReplyByServiceId = new Map<ServiceIdString, boolean>();
const pendingSendRecipientServiceIds: Array<ServiceIdString> = [];
2022-08-09 03:26:21 +00:00
const sentRecipientIds: Array<string> = [];
const untrustedServiceIds: Array<ServiceIdString> = [];
2022-08-02 19:31:55 +00:00
Object.entries(message.get('sendStateByConversationId') || {}).forEach(
([recipientConversationId, sendState]) => {
const recipient = window.ConversationController.get(
recipientConversationId
);
if (!recipient) {
return;
}
const isRecipientMe = isMe(recipient.attributes);
2022-08-09 03:26:21 +00:00
if (isRecipientMe) {
return;
}
2022-08-02 19:31:55 +00:00
if (recipient.isUntrusted()) {
const serviceId = recipient.getServiceId();
if (!serviceId) {
2022-08-02 19:31:55 +00:00
log.error(
`stories.sendStory/getMessageRecipients: Untrusted conversation ${recipient.idForLogging()} missing serviceId.`
2022-08-02 19:31:55 +00:00
);
return;
}
untrustedServiceIds.push(serviceId);
2022-08-02 19:31:55 +00:00
return;
}
if (recipient.isUnregistered()) {
return;
}
2022-08-09 03:26:21 +00:00
const recipientSendTarget = recipient.getSendTarget();
if (!recipientSendTarget) {
2022-08-02 19:31:55 +00:00
return;
}
allowedReplyByServiceId.set(
2022-08-09 03:26:21 +00:00
recipientSendTarget,
2022-08-02 19:31:55 +00:00
Boolean(sendState.isAllowedToReplyToStory)
);
allRecipientServiceIds.push(recipientSendTarget);
2022-08-02 19:31:55 +00:00
2022-08-09 03:26:21 +00:00
if (sendState.isAlreadyIncludedInAnotherDistributionList) {
2022-08-02 19:31:55 +00:00
return;
}
2022-08-09 03:26:21 +00:00
if (isSent(sendState.status)) {
sentRecipientIds.push(recipientSendTarget);
return;
2022-08-02 19:31:55 +00:00
}
2022-08-09 03:26:21 +00:00
pendingSendRecipientServiceIds.push(recipientSendTarget);
2022-08-02 19:31:55 +00:00
}
);
return {
allRecipientServiceIds,
allowedReplyByServiceId,
pendingSendRecipientServiceIds,
2022-08-09 03:26:21 +00:00
sentRecipientIds,
untrustedServiceIds,
2022-08-02 19:31:55 +00:00
};
}
async function markMessageFailed(
message: MessageModel,
errors: Array<Error>
): Promise<void> {
message.markFailed();
void message.saveErrors(errors, { skipSave: true });
2022-08-02 19:31:55 +00:00
await window.Signal.Data.saveMessage(message.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
2022-08-02 19:31:55 +00:00
});
}
function didSendToEveryone(message: Readonly<MessageModel>): boolean {
const sendStateByConversationId =
message.get('sendStateByConversationId') || {};
2022-08-09 03:26:21 +00:00
return Object.values(sendStateByConversationId).every(
sendState =>
sendState.isAlreadyIncludedInAnotherDistributionList ||
isSent(sendState.status)
2022-08-02 19:31:55 +00:00
);
}