Files
Desktop/ts/services/releaseNoteAndMegaphoneFetcher.preload.ts
2026-02-11 09:18:38 -06:00

658 lines
21 KiB
TypeScript

// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import semver from 'semver';
import lodash from 'lodash';
import * as durations from '../util/durations/index.std.js';
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary.std.js';
import * as Registration from '../util/registration.preload.js';
import { createLogger } from '../logging/log.std.js';
import * as Errors from '../types/errors.std.js';
import { HTTPError } from '../types/HTTPError.std.js';
import { drop } from '../util/drop.std.js';
import {
writeNewAttachmentData,
processNewAttachment,
writeNewMegaphoneImageFileData,
} from '../util/migrations.preload.js';
import { strictAssert } from '../util/assert.std.js';
import type { MessageAttributesType } from '../model-types.d.ts';
import { ReadStatus } from '../messages/MessageReadStatus.std.js';
import { incrementMessageCounter } from '../util/incrementMessageCounter.preload.js';
import { SeenStatus } from '../MessageSeenStatus.std.js';
import { saveNewMessageBatcher } from '../util/messageBatcher.preload.js';
import { generateMessageId } from '../util/generateMessageId.node.js';
import type { RawBodyRange } from '../types/BodyRange.std.js';
import { BodyRange } from '../types/BodyRange.std.js';
import type {
ReleaseNotesManifestResponseType,
ReleaseNoteResponseType,
isOnline as doIsOnline,
getMegaphone as doGetMegaphone,
getReleaseNote as doGetReleaseNote,
getReleaseNoteHash as doGetReleaseNoteHash,
getReleaseNoteImageAttachment as doGetReleaseNoteImageAttachment,
getReleaseNotesManifest as doGetReleaseNotesManifest,
getReleaseNotesManifestHash as doGetReleaseNotesManifestHash,
MegaphoneResponseType,
} from '../textsecure/WebAPI.preload.js';
import type { WithRequiredProperties } from '../types/Util.std.js';
import { MessageModel } from '../models/messages.preload.js';
import { stringToMIMEType } from '../types/MIME.std.js';
import { isNotNil } from '../util/isNotNil.std.js';
import { itemStorage } from '../textsecure/Storage.preload.js';
import { DataReader, DataWriter } from '../sql/Client.preload.js';
import { type RemoteMegaphoneType } from '../types/Megaphone.std.js';
import { isCountryPpmCsvBucketEnabled } from '../RemoteConfig.dom.js';
import type { AciString } from '../types/ServiceId.std.js';
import {
deleteMegaphoneAndRemoveFromRedux,
isRemoteMegaphoneEnabled,
runMegaphoneCheck,
} from './megaphone.preload.js';
const { last } = lodash;
const log = createLogger('releaseNoteAndMegaphoneFetcher');
const FETCH_INTERVAL = 1 * durations.DAY;
const ERROR_RETRY_DELAY = 3 * durations.HOUR;
const NEXT_FETCH_TIME_STORAGE_KEY = 'releaseNotesNextFetchTime';
const PREVIOUS_MANIFEST_HASH_STORAGE_KEY = 'releaseNotesPreviousManifestHash';
const VERSION_WATERMARK_STORAGE_KEY = 'releaseNotesVersionWatermark';
const BUCKET_VALUE_HASH_SALT = 'ReleaseNoteAndMegaphoneFetcher';
type MinimalEventsType = {
on(event: 'timetravel', callback: () => void): void;
};
type FetchOptions = {
isNewVersion?: boolean;
};
type ManifestReleaseNoteType = WithRequiredProperties<
ReleaseNotesManifestResponseType['announcements'][0],
'desktopMinVersion'
>;
export type ReleaseNoteType = ReleaseNoteResponseType &
Pick<ReleaseNotesManifestResponseType['announcements'][0], 'ctaId' | 'link'>;
const STYLE_MAPPING: Record<string, BodyRange.Style> = {
bold: BodyRange.Style.BOLD,
italic: BodyRange.Style.ITALIC,
strikethrough: BodyRange.Style.STRIKETHROUGH,
spoiler: BodyRange.Style.SPOILER,
mono: BodyRange.Style.MONOSPACE,
};
type ManifestMegaphoneType = WithRequiredProperties<
ReleaseNotesManifestResponseType['megaphones'][0],
'desktopMinVersion'
>;
type LocaleMegaphoneType = MegaphoneResponseType & {
imagePath: string | null;
localeFetched: string;
};
export type ServerType = Readonly<{
isOnline: typeof doIsOnline;
getMegaphone: typeof doGetMegaphone;
getReleaseNote: typeof doGetReleaseNote;
getReleaseNoteHash: typeof doGetReleaseNoteHash;
getReleaseNoteImageAttachment: typeof doGetReleaseNoteImageAttachment;
getReleaseNotesManifest: typeof doGetReleaseNotesManifest;
getReleaseNotesManifestHash: typeof doGetReleaseNotesManifestHash;
}>;
export class ReleaseNoteAndMegaphoneFetcher {
static initComplete = false;
#timeout: NodeJS.Timeout | undefined;
#isRunning = false;
#server: ServerType;
constructor(server: ServerType) {
this.#server = server;
}
protected setTimeoutForNextRun(options?: FetchOptions): void {
const now = Date.now();
const time = itemStorage.get(NEXT_FETCH_TIME_STORAGE_KEY, now);
log.info('Next update scheduled for', new Date(time).toISOString());
let waitTime = time - now;
if (waitTime < 0) {
waitTime = 0;
}
clearTimeoutIfNecessary(this.#timeout);
this.#timeout = setTimeout(() => this.#runWhenOnline(options), waitTime);
}
#getOrInitializeVersionWatermark(): string {
const versionWatermark = itemStorage.get(VERSION_WATERMARK_STORAGE_KEY);
if (versionWatermark) {
return versionWatermark;
}
log.info('Initializing version high watermark to current version');
const currentVersion = window.getVersion();
drop(itemStorage.put(VERSION_WATERMARK_STORAGE_KEY, currentVersion));
return currentVersion;
}
#getLocales(): ReadonlyArray<string> {
const globalLocale = new Intl.Locale(window.SignalContext.getI18nLocale());
return [
globalLocale.toString(),
globalLocale.language.toString(),
'en',
].map(locale => locale.toLocaleLowerCase().replace('-', '_'));
}
async #maybeGetLocaleMegaphone(
uuid: string,
locales: ReadonlyArray<string>
): Promise<LocaleMegaphoneType | undefined> {
for (const locale of locales) {
// megaphones share URL with release notes
// eslint-disable-next-line no-await-in-loop
const hash = await this.#server.getReleaseNoteHash({
uuid,
locale,
});
if (hash === undefined) {
continue;
}
// eslint-disable-next-line no-await-in-loop
const localeMegaphone = await this.#server.getMegaphone({ uuid, locale });
if (localeMegaphone == null) {
log.warn(
`saveNewMegaphones could not fetch locale megaphone for ${uuid}, skipping`
);
continue;
}
// Fetch image and save locally
let imagePath: string | null;
if (localeMegaphone.image) {
const { imageData: rawAttachmentData } =
// eslint-disable-next-line no-await-in-loop
await this.#server.getReleaseNoteImageAttachment(
localeMegaphone.image
);
// eslint-disable-next-line no-await-in-loop
imagePath = await writeNewMegaphoneImageFileData(rawAttachmentData);
} else {
imagePath = null;
}
return {
...localeMegaphone,
imagePath,
localeFetched: locale,
};
}
return undefined;
}
static isCountryCodeMatch({
countryPpmCsv,
e164,
aci,
}: {
countryPpmCsv: string | undefined;
e164: string | undefined;
aci: AciString | undefined;
}): boolean {
if (!countryPpmCsv) {
return true;
}
// Megaphone per country config uses the RemoteConfig country PPM CSV format
return isCountryPpmCsvBucketEnabled(
BUCKET_VALUE_HASH_SALT,
countryPpmCsv,
e164,
aci
);
}
async #deleteUnknownMegaphones(
manifestMegaphones: ReadonlyArray<ManifestMegaphoneType>
): Promise<void> {
const manifestMegaphoneIds = new Set(
manifestMegaphones.map(({ uuid }) => uuid)
);
const localMegaphoneIds = await DataReader.getAllMegaphoneIds();
for (const id of localMegaphoneIds) {
if (manifestMegaphoneIds.has(id)) {
continue;
}
log.warn(
`deleteUnknownMegaphones: Found local megaphone missing in manifest, deleting: ${id}`
);
// eslint-disable-next-line no-await-in-loop
await deleteMegaphoneAndRemoveFromRedux(id);
}
}
async #saveNewMegaphones(
megaphones: ReadonlyArray<ManifestMegaphoneType>
): Promise<number> {
const nowSeconds = Math.round(Date.now() / 1000);
const ourE164 = itemStorage.user.getNumber();
const ourAci = itemStorage.user.getAci();
const locales = this.#getLocales();
let savedCount = 0;
for (const megaphone of megaphones) {
const { uuid } = megaphone;
if (
nowSeconds > megaphone.dontShowAfterEpochSeconds ||
!ReleaseNoteAndMegaphoneFetcher.isCountryCodeMatch({
countryPpmCsv: megaphone.countries,
e164: ourE164,
aci: ourAci,
}) ||
// eslint-disable-next-line no-await-in-loop
(await DataReader.hasMegaphone(uuid))
) {
continue;
}
try {
// eslint-disable-next-line no-await-in-loop
const localeDetail = await this.#maybeGetLocaleMegaphone(uuid, locales);
if (localeDetail == null) {
log.warn(
`saveNewMegaphones: could not fetch locale megaphone for ${uuid}, skipping`
);
continue;
}
// API allows for empty imagePath, but we require it for desktop
if (localeDetail.imagePath == null) {
log.error(
`saveNewMegaphones: megaphone ${uuid} ${localeDetail.localeFetched} missing imagePath, skipping`
);
continue;
}
// Create the megaphone
log.info(`saveNewMegaphones: saving megaphone ${uuid}`);
const hydratedMegaphone: RemoteMegaphoneType = {
id: uuid,
desktopMinVersion: megaphone.desktopMinVersion,
priority: megaphone.priority,
dontShowBeforeEpochMs: megaphone.dontShowBeforeEpochSeconds * 1000,
dontShowAfterEpochMs: megaphone.dontShowAfterEpochSeconds * 1000,
showForNumberOfDays: megaphone.showForNumberOfDays,
primaryCtaId: megaphone.primaryCtaId ?? null,
secondaryCtaId: megaphone.secondaryCtaId ?? null,
primaryCtaData: megaphone.primaryCtaData ?? null,
secondaryCtaData: megaphone.secondaryCtaData ?? null,
conditionalId: megaphone.conditionalId ?? null,
title: localeDetail.title,
body: localeDetail.body,
primaryCtaText: localeDetail.primaryCtaText ?? null,
secondaryCtaText: localeDetail.secondaryCtaText ?? null,
imagePath: localeDetail.imagePath,
localeFetched: localeDetail.localeFetched,
shownAt: null,
snoozedAt: null,
snoozeCount: 0,
isFinished: false,
};
// eslint-disable-next-line no-await-in-loop
await DataWriter.createMegaphone(hydratedMegaphone);
savedCount += 1;
} catch (error) {
// Don't add it, we'll try again later
log.warn(
`saveNewMegaphones: failed for ${uuid}`,
Errors.toLogFormat(error)
);
}
}
return savedCount;
}
async #getReleaseNote(
note: ManifestReleaseNoteType
): Promise<ReleaseNoteType | undefined> {
const { uuid, ctaId, link } = note;
const localesToTry = this.#getLocales();
for (const localeToTry of localesToTry) {
try {
// eslint-disable-next-line no-await-in-loop
const hash = await this.#server.getReleaseNoteHash({
uuid,
locale: localeToTry,
});
if (hash === undefined) {
continue;
}
// eslint-disable-next-line no-await-in-loop
const result = await this.#server.getReleaseNote({
uuid,
locale: localeToTry,
});
strictAssert(
result.uuid === uuid,
'UUID of localized release note should match requested UUID'
);
return {
...result,
uuid,
ctaId,
link,
};
} catch {
// If either request fails, try the next locale
continue;
}
}
throw new Error(
`Could not fetch release note with any locale for UUID ${uuid}`
);
}
async #processReleaseNotes(
notes: ReadonlyArray<ManifestReleaseNoteType>
): Promise<void> {
log.info('Ensuring Signal conversation');
const signalConversation =
await window.ConversationController.getOrCreateSignalConversation();
const sortedNotes = [...notes].sort(
(a: ManifestReleaseNoteType, b: ManifestReleaseNoteType) =>
semver.compare(a.desktopMinVersion, b.desktopMinVersion)
);
const newestNote = last(sortedNotes);
strictAssert(newestNote, 'processReleaseNotes requires at least 1 note');
const versionWatermark = newestNote.desktopMinVersion;
if (signalConversation.isBlocked()) {
log.info(
`Signal conversation is blocked, updating watermark to ${versionWatermark}`
);
drop(itemStorage.put(VERSION_WATERMARK_STORAGE_KEY, versionWatermark));
return;
}
const hydratedNotesWithRawAttachments = (
await Promise.all(
sortedNotes.map(async note => {
if (!note) {
return null;
}
const hydratedNote = await this.#getReleaseNote(note);
if (!hydratedNote) {
return null;
}
if (hydratedNote.media) {
const { imageData: rawAttachmentData, contentType } =
await this.#server.getReleaseNoteImageAttachment(
hydratedNote.media
);
return {
hydratedNote,
rawAttachmentData,
contentType: hydratedNote.mediaContentType ?? contentType,
};
}
return { hydratedNote, rawAttachmentData: null, contentType: null };
})
)
).filter(isNotNil);
const hydratedNotes = await Promise.all(
hydratedNotesWithRawAttachments.map(
async ({ hydratedNote, rawAttachmentData, contentType }) => {
if (rawAttachmentData && !contentType) {
throw new Error('Content type is missing from attachment');
}
if (!rawAttachmentData || !contentType) {
return { hydratedNote, processedAttachment: null };
}
const localAttachment =
await writeNewAttachmentData(rawAttachmentData);
const processedAttachment = await processNewAttachment(
{
...localAttachment,
contentType: stringToMIMEType(contentType),
},
'attachment'
);
return { hydratedNote, processedAttachment };
}
)
);
if (!hydratedNotes.length) {
log.warn('No hydrated notes available, stopping');
return;
}
const messages: Array<MessageAttributesType> = [];
hydratedNotes.forEach(
({ hydratedNote: note, processedAttachment }, index) => {
if (!note) {
return;
}
const { title, body, bodyRanges: noteBodyRanges } = note;
const titleBodySeparator = '\n\n';
const filteredNoteBodyRanges: Array<RawBodyRange> = (
noteBodyRanges ?? []
)
.map(range => {
if (
range.length == null ||
range.start == null ||
range.style == null ||
!STYLE_MAPPING[range.style] ||
range.start + range.length - 1 >= body.length
) {
return null;
}
const relativeStart =
range.start + title.length + titleBodySeparator.length;
return {
start: relativeStart,
length: range.length,
style: STYLE_MAPPING[range.style],
};
})
.filter(isNotNil);
const messageBody = `${title}${titleBodySeparator}${body}`;
const bodyRanges: Array<RawBodyRange> = [
{ start: 0, length: title.length, style: BodyRange.Style.BOLD },
...filteredNoteBodyRanges,
];
const timestamp = Date.now() + index;
const message = new MessageModel({
...generateMessageId(incrementMessageCounter()),
...(processedAttachment
? { attachments: [processedAttachment] }
: {}),
body: messageBody,
bodyRanges,
conversationId: signalConversation.id,
readStatus: ReadStatus.Unread,
seenStatus: SeenStatus.Unseen,
received_at_ms: timestamp,
sent_at: timestamp,
serverTimestamp: timestamp,
sourceDevice: 1,
sourceServiceId: signalConversation.getServiceId(),
timestamp,
type: 'incoming',
});
window.MessageCache.register(message);
drop(signalConversation.onNewMessage(message));
messages.push(message.attributes);
}
);
await Promise.all(
messages.map(message => saveNewMessageBatcher.add(message))
);
signalConversation.set({ active_at: Date.now(), isArchived: false });
signalConversation.throttledUpdateUnread();
log.info(`Updating version watermark to ${versionWatermark}`);
drop(itemStorage.put(VERSION_WATERMARK_STORAGE_KEY, versionWatermark));
}
async #scheduleForNextRun(options?: {
isNewVersion?: boolean;
}): Promise<void> {
const now = Date.now();
const nextTime = options?.isNewVersion ? now : now + FETCH_INTERVAL;
await itemStorage.put(NEXT_FETCH_TIME_STORAGE_KEY, nextTime);
}
async #run(options?: FetchOptions): Promise<void> {
if (this.#isRunning) {
log.warn('Already running, preventing reentrancy');
return;
}
this.#isRunning = true;
log.info('Starting');
try {
const versionWatermark = this.#getOrInitializeVersionWatermark();
log.info(`Version watermark is ${versionWatermark}`);
const hash = await this.#server.getReleaseNotesManifestHash();
if (!hash) {
throw new Error('Release notes manifest hash missing');
}
const previousHash = itemStorage.get(PREVIOUS_MANIFEST_HASH_STORAGE_KEY);
if (hash !== previousHash || options?.isNewVersion) {
log.info(
`Fetching manifest, isNewVersion=${
options?.isNewVersion ? 'true' : 'false'
}, hashChanged=${hash !== previousHash ? 'true' : 'false'}`
);
const manifest = await this.#server.getReleaseNotesManifest();
const currentVersion = window.getVersion();
if (isRemoteMegaphoneEnabled()) {
// Remote megaphones can be saved prior to desktopMinVersion.
// Saved megaphones are periodically checked to see if we should show them.
const validMegaphones = manifest.megaphones.filter(
(megaphone): megaphone is ManifestMegaphoneType =>
megaphone.desktopMinVersion != null
);
await this.#deleteUnknownMegaphones(validMegaphones);
const savedCount = await this.#saveNewMegaphones(validMegaphones);
if (savedCount > 0) {
drop(runMegaphoneCheck());
}
}
const validNotes = manifest.announcements.filter(
(note): note is ManifestReleaseNoteType =>
note.desktopMinVersion != null &&
semver.gt(note.desktopMinVersion, versionWatermark) &&
semver.lte(note.desktopMinVersion, currentVersion)
);
if (validNotes.length) {
log.info(`Processing ${validNotes.length} new release notes`);
await this.#processReleaseNotes(validNotes);
} else {
log.info('No new release notes');
}
drop(itemStorage.put(PREVIOUS_MANIFEST_HASH_STORAGE_KEY, hash));
} else {
log.info('Manifest hash unchanged, aborting fetch');
}
await this.#scheduleForNextRun();
this.setTimeoutForNextRun();
window.SignalCI?.handleEvent('release_notes_fetcher_complete', {});
} catch (error) {
const errorString =
error instanceof HTTPError
? error.code.toString()
: Errors.toLogFormat(error);
log.error(`Error, trying again later. ${errorString}`);
setTimeout(() => this.setTimeoutForNextRun(), ERROR_RETRY_DELAY);
} finally {
this.#isRunning = false;
}
}
#runWhenOnline(options?: FetchOptions) {
if (this.#server.isOnline()) {
drop(this.#run(options));
} else {
log.info('We are offline; will fetch when we are next online');
const listener = () => {
window.Whisper.events.off('online', listener);
this.setTimeoutForNextRun(options);
};
window.Whisper.events.on('online', listener);
}
}
public static async init(
server: ServerType,
events: MinimalEventsType,
isNewVersion: boolean
): Promise<void> {
if (ReleaseNoteAndMegaphoneFetcher.initComplete) {
return;
}
ReleaseNoteAndMegaphoneFetcher.initComplete = true;
const listener = new ReleaseNoteAndMegaphoneFetcher(server);
if (isNewVersion) {
await listener.#scheduleForNextRun({ isNewVersion });
}
listener.setTimeoutForNextRun({ isNewVersion });
events.on('timetravel', () => {
if (Registration.isDone()) {
listener.setTimeoutForNextRun();
}
});
}
}