@mpgd/game-services 0.33.1 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admob-ssv.js +6 -26
- package/dist/app-store-verifier-conformance.d.ts +37 -1
- package/dist/app-store-verifier-conformance.js +199 -1
- package/dist/app-store-verifier.d.ts +57 -4
- package/dist/app-store-verifier.js +106 -4
- package/dist/base64.d.ts +10 -0
- package/dist/base64.js +35 -0
- package/dist/google-play-publisher-client.js +24 -1
- package/dist/google-play-rtdn.d.ts +248 -0
- package/dist/google-play-rtdn.js +594 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/notifications/ait-functional-delivery.d.ts +65 -1
- package/dist/notifications/ait-functional-delivery.js +239 -5
- package/dist/notifications/bulk.d.ts +158 -0
- package/dist/notifications/bulk.js +229 -0
- package/dist/notifications/client.d.ts +19 -1
- package/dist/notifications/client.js +24 -2
- package/dist/notifications/copy.d.ts +87 -0
- package/dist/notifications/copy.js +70 -0
- package/dist/notifications/delivery-provider.d.ts +79 -0
- package/dist/notifications/index.d.ts +4 -1
- package/dist/notifications/index.js +4 -1
- package/dist/notifications/registry.js +8 -0
- package/dist/notifications/service.d.ts +32 -0
- package/dist/notifications/service.js +1017 -59
- package/dist/notifications/store.d.ts +6 -0
- package/dist/notifications/store.js +5 -3
- package/dist/notifications/text.d.ts +5 -1
- package/dist/notifications/text.js +11 -2
- package/package.json +9 -5
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { isValidNotificationTimeZone, NotificationError, notificationKeyIssue, notificationReservedKeyPrefix, } from '@mpgd/platform/notifications';
|
|
2
|
+
/**
|
|
3
|
+
* Bulk sends (notifications v2 RFC, open question 5; BK1): the request and
|
|
4
|
+
* answer types of `NotificationService.deliverBulk`, and the small pieces it
|
|
5
|
+
* is built from. A bulk send is many single sends that share provider calls:
|
|
6
|
+
* each recipient keeps its own key, ledger row, gate check, and answer.
|
|
7
|
+
*/
|
|
8
|
+
/** Most recipients one `deliverBulk` call takes. */
|
|
9
|
+
export const maxNotificationBulkRecipients = 10_000;
|
|
10
|
+
/** The send key of one recipient of a bulk send. */
|
|
11
|
+
export function notificationBulkRecipientKey(idempotencyKey, player) {
|
|
12
|
+
return `${idempotencyKey}:${encodeURIComponent(player)}`;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A well-formed bulk request, or `NOTIFICATIONS_INVALID_INPUT` for the whole
|
|
16
|
+
* call. Each recipient's own request (its template data, data, locale, and
|
|
17
|
+
* link, and its derived key) is checked later as a single send's is, and
|
|
18
|
+
* fails that recipient alone.
|
|
19
|
+
*/
|
|
20
|
+
export function parseNotificationBulkRequest(raw) {
|
|
21
|
+
if (!isPlainObject(raw)) {
|
|
22
|
+
throw invalid('A bulk send is an object.');
|
|
23
|
+
}
|
|
24
|
+
const known = new Set([
|
|
25
|
+
'idempotencyKey',
|
|
26
|
+
'provider',
|
|
27
|
+
'topic',
|
|
28
|
+
'recipients',
|
|
29
|
+
'templateData',
|
|
30
|
+
'data',
|
|
31
|
+
'locale',
|
|
32
|
+
'link',
|
|
33
|
+
'batchSize',
|
|
34
|
+
]);
|
|
35
|
+
const unknown = Object.keys(raw).find((key) => !known.has(key));
|
|
36
|
+
if (unknown !== undefined) {
|
|
37
|
+
throw invalid(`Invalid bulk send: ${unknown}: not a bulk send field.`);
|
|
38
|
+
}
|
|
39
|
+
if (typeof raw.idempotencyKey !== 'string') {
|
|
40
|
+
throw invalid('Invalid bulk send: idempotencyKey: expected a string.');
|
|
41
|
+
}
|
|
42
|
+
const keyIssue = notificationKeyIssue(raw.idempotencyKey);
|
|
43
|
+
if (keyIssue !== undefined) {
|
|
44
|
+
throw invalid(`Invalid bulk send: idempotencyKey: ${keyIssue}.`);
|
|
45
|
+
}
|
|
46
|
+
const reserved = notificationReservedKeyPrefix(raw.idempotencyKey);
|
|
47
|
+
if (reserved !== undefined) {
|
|
48
|
+
throw invalid(`Invalid bulk send: idempotencyKey: the ${reserved} prefix is reserved for the scheduler.`);
|
|
49
|
+
}
|
|
50
|
+
for (const name of ['provider', 'topic']) {
|
|
51
|
+
if (typeof raw[name] !== 'string' || raw[name].length === 0 || raw[name].length > 2048) {
|
|
52
|
+
throw invalid(`Invalid bulk send: ${name}: expected a non-empty string.`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (raw.batchSize !== undefined
|
|
56
|
+
&& !(typeof raw.batchSize === 'number' && Number.isSafeInteger(raw.batchSize) && raw.batchSize >= 1)) {
|
|
57
|
+
throw invalid('Invalid bulk send: batchSize: expected a positive integer.');
|
|
58
|
+
}
|
|
59
|
+
const recipients = raw.recipients;
|
|
60
|
+
if (!Array.isArray(recipients) || recipients.length === 0 || recipients.length > maxNotificationBulkRecipients) {
|
|
61
|
+
throw invalid(`Invalid bulk send: recipients: expected 1 to ${maxNotificationBulkRecipients} recipients.`);
|
|
62
|
+
}
|
|
63
|
+
const recipientFields = new Set(['player', 'templateData', 'data', 'locale', 'link']);
|
|
64
|
+
const players = new Set();
|
|
65
|
+
const keys = [];
|
|
66
|
+
for (const [index, recipient] of recipients.entries()) {
|
|
67
|
+
if (!isPlainObject(recipient)) {
|
|
68
|
+
throw invalid(`Invalid bulk send: recipients[${index}]: expected an object.`);
|
|
69
|
+
}
|
|
70
|
+
const field = Object.keys(recipient).find((key) => !recipientFields.has(key));
|
|
71
|
+
if (field !== undefined) {
|
|
72
|
+
throw invalid(`Invalid bulk send: recipients[${index}].${field}: not a recipient field.`);
|
|
73
|
+
}
|
|
74
|
+
const player = recipient.player;
|
|
75
|
+
if (typeof player !== 'string' || player.length === 0 || player.length > 2048) {
|
|
76
|
+
throw invalid(`Invalid bulk send: recipients[${index}].player: expected a non-empty string.`);
|
|
77
|
+
}
|
|
78
|
+
if (players.has(player)) {
|
|
79
|
+
// One send per player: a second would be a second key for the same push.
|
|
80
|
+
throw invalid(`Invalid bulk send: recipients[${index}].player: a player is named once.`);
|
|
81
|
+
}
|
|
82
|
+
players.add(player);
|
|
83
|
+
try {
|
|
84
|
+
keys.push(notificationBulkRecipientKey(raw.idempotencyKey, player));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// `encodeURIComponent` refuses a lone surrogate, which no key may hold.
|
|
88
|
+
throw invalid(`Invalid bulk send: recipients[${index}].player: holds a lone surrogate.`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { input: raw, keys };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* A provider's bulk capability as the registry accepts it: whole numbers,
|
|
95
|
+
* a minimum no larger than the maximum, and a hold window of two `HH:MM`
|
|
96
|
+
* times in a valid time zone. Throws a `TypeError` naming the issue.
|
|
97
|
+
*/
|
|
98
|
+
export function assertNotificationBulkCapability(capability, at) {
|
|
99
|
+
const { maxRecipients, minRecipients, perRecipientOutcomes, holdWindow } = capability;
|
|
100
|
+
if (!Number.isSafeInteger(maxRecipients) || maxRecipients < 1) {
|
|
101
|
+
throw new TypeError(`${at}.bulk.maxRecipients must be a positive integer.`);
|
|
102
|
+
}
|
|
103
|
+
if (minRecipients !== undefined
|
|
104
|
+
&& (!Number.isSafeInteger(minRecipients) || minRecipients < 1 || minRecipients > maxRecipients)) {
|
|
105
|
+
throw new TypeError(`${at}.bulk.minRecipients must be a positive integer no larger than maxRecipients.`);
|
|
106
|
+
}
|
|
107
|
+
if (typeof perRecipientOutcomes !== 'boolean') {
|
|
108
|
+
throw new TypeError(`${at}.bulk.perRecipientOutcomes must be a boolean.`);
|
|
109
|
+
}
|
|
110
|
+
if (holdWindow !== undefined) {
|
|
111
|
+
if (minutesOf(holdWindow.start) === undefined
|
|
112
|
+
|| minutesOf(holdWindow.end) === undefined
|
|
113
|
+
|| holdWindow.start === holdWindow.end
|
|
114
|
+
|| !isValidNotificationTimeZone(holdWindow.timeZone)) {
|
|
115
|
+
throw new TypeError(`${at}.bulk.holdWindow needs two different HH:MM times and a valid time zone.`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Whether `at` (epoch milliseconds) falls inside a provider's hold window. */
|
|
120
|
+
export function isInsideNotificationHoldWindow(window, at) {
|
|
121
|
+
if (window === undefined) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
const start = minutesOf(window.start);
|
|
125
|
+
const end = minutesOf(window.end);
|
|
126
|
+
if (start === undefined || end === undefined) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
const parts = new Intl.DateTimeFormat('en-GB', {
|
|
130
|
+
timeZone: window.timeZone,
|
|
131
|
+
hour: '2-digit',
|
|
132
|
+
minute: '2-digit',
|
|
133
|
+
hourCycle: 'h23',
|
|
134
|
+
}).formatToParts(new Date(at));
|
|
135
|
+
const hour = Number(parts.find((part) => part.type === 'hour')?.value);
|
|
136
|
+
const minute = Number(parts.find((part) => part.type === 'minute')?.value);
|
|
137
|
+
const now = hour * 60 + minute;
|
|
138
|
+
return start < end ? now >= start && now < end : now >= start || now < end;
|
|
139
|
+
}
|
|
140
|
+
function minutesOf(time) {
|
|
141
|
+
const match = /^([01]\d|2[0-3]):([0-5]\d)$/u.exec(time);
|
|
142
|
+
return match === null ? undefined : Number(match[1]) * 60 + Number(match[2]);
|
|
143
|
+
}
|
|
144
|
+
/** A counting semaphore: at most `concurrency` holders at once, served in order. */
|
|
145
|
+
export function createNotificationLimiter(concurrency) {
|
|
146
|
+
let active = 0;
|
|
147
|
+
const waiting = [];
|
|
148
|
+
return {
|
|
149
|
+
acquire() {
|
|
150
|
+
if (active < concurrency) {
|
|
151
|
+
active += 1;
|
|
152
|
+
return Promise.resolve();
|
|
153
|
+
}
|
|
154
|
+
return new Promise((resolve) => {
|
|
155
|
+
waiting.push(resolve);
|
|
156
|
+
});
|
|
157
|
+
},
|
|
158
|
+
release() {
|
|
159
|
+
const next = waiting.shift();
|
|
160
|
+
if (next === undefined) {
|
|
161
|
+
active -= 1;
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
// The slot passes to the next waiter.
|
|
165
|
+
next();
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Gathers the provider calls of a known number of participants into one
|
|
172
|
+
* flush. Each participant either arrives with its item, and waits for its
|
|
173
|
+
* answer, or leaves without one; once every participant did one or the
|
|
174
|
+
* other, `flush` runs once with the arrived items and answers each in
|
|
175
|
+
* order. A participant that holds a concurrency slot gives it up before it
|
|
176
|
+
* waits (see `deliverBulk`), so the barrier never waits on a participant
|
|
177
|
+
* that cannot run.
|
|
178
|
+
*/
|
|
179
|
+
export function createNotificationBatchBarrier(participants, flush) {
|
|
180
|
+
const arrived = [];
|
|
181
|
+
let left = 0;
|
|
182
|
+
let flushed = false;
|
|
183
|
+
const maybeFlush = () => {
|
|
184
|
+
if (flushed || arrived.length + left < participants) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
flushed = true;
|
|
188
|
+
if (arrived.length === 0) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const items = arrived.map((entry) => entry.item);
|
|
192
|
+
flush(items).then((answers) => {
|
|
193
|
+
arrived.forEach((entry, index) => {
|
|
194
|
+
const answer = answers[index];
|
|
195
|
+
if (answer === undefined) {
|
|
196
|
+
entry.reject(new TypeError('The batch flush answered fewer items than it took.'));
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
entry.resolve(answer);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
}, (error) => {
|
|
203
|
+
for (const entry of arrived) {
|
|
204
|
+
entry.reject(error);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
};
|
|
208
|
+
return {
|
|
209
|
+
arrive(item) {
|
|
210
|
+
if (flushed) {
|
|
211
|
+
return Promise.reject(new TypeError('The batch was already flushed.'));
|
|
212
|
+
}
|
|
213
|
+
return new Promise((resolve, reject) => {
|
|
214
|
+
arrived.push({ item, resolve, reject });
|
|
215
|
+
maybeFlush();
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
leave() {
|
|
219
|
+
left += 1;
|
|
220
|
+
maybeFlush();
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function invalid(message) {
|
|
225
|
+
return new NotificationError('NOTIFICATIONS_INVALID_INPUT', { message });
|
|
226
|
+
}
|
|
227
|
+
function isPlainObject(value) {
|
|
228
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
229
|
+
}
|
|
@@ -96,18 +96,36 @@ export interface CreateGameServicesNotificationsClientInput {
|
|
|
96
96
|
}
|
|
97
97
|
/** The default `timeoutMs`: a request still unanswered after this is aborted. */
|
|
98
98
|
export declare const defaultGameServicesNotificationsTimeoutMs = 15000;
|
|
99
|
+
/** A provider entry of the registry this client could not read, and why. */
|
|
100
|
+
export interface GameServicesNotificationUnreadableProvider {
|
|
101
|
+
/** The entry's `provider` id, when the entry has a readable non-empty one. */
|
|
102
|
+
readonly provider?: string;
|
|
103
|
+
/** Why the entry was left out (the validator's text, at most 512 characters). */
|
|
104
|
+
readonly issue: string;
|
|
105
|
+
}
|
|
99
106
|
/** The registry the Worker publishes at `GET /providers`. */
|
|
100
107
|
export interface GameServicesNotificationProviders {
|
|
101
108
|
readonly protocolVersion: string;
|
|
109
|
+
/** The provider entries this client can read. */
|
|
102
110
|
readonly providers: readonly NotificationDeliveryProviderEntry[];
|
|
103
111
|
/** Each served target and the provider id that delivers to it. */
|
|
104
112
|
readonly targets: Readonly<Record<string, string>>;
|
|
113
|
+
/**
|
|
114
|
+
* The provider entries left out of `providers` because this client cannot
|
|
115
|
+
* read them, in registry order; absent when there are none.
|
|
116
|
+
*/
|
|
117
|
+
readonly unreadable?: readonly GameServicesNotificationUnreadableProvider[];
|
|
105
118
|
}
|
|
106
119
|
export interface GameServicesNotificationsClient extends NotificationRuntimeBackend {
|
|
107
120
|
/**
|
|
108
121
|
* The registry the Worker publishes (`GET /providers`, no session needed):
|
|
109
122
|
* pass `providers.find((entry) => entry.provider === provider.provider)` as
|
|
110
|
-
* the runtime's `registryEntry`.
|
|
123
|
+
* the runtime's `registryEntry`. A provider entry this client cannot read
|
|
124
|
+
* (malformed, or shaped by a Worker this client does not know) is left out
|
|
125
|
+
* of `providers`, so the other entries still load, and listed in
|
|
126
|
+
* `unreadable` with its id when readable. A target whose provider is
|
|
127
|
+
* listed there (or any unreadable entry without an id) should be reported,
|
|
128
|
+
* not read as a target the Worker does not serve.
|
|
111
129
|
*/
|
|
112
130
|
getProviders(): Promise<GameServicesNotificationProviders>;
|
|
113
131
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { assertNotificationConsentRecord, assertNotificationDeliveryProviderEntry, assertNotificationNotice, isNotificationErrorCode, NotificationError, } from '@mpgd/platform/notifications';
|
|
2
2
|
import { GameServicesHeaderResolutionError, resolveGameServicesRequestHeaders, } from '../request-headers.js';
|
|
3
|
+
import { describeError } from './text.js';
|
|
3
4
|
/**
|
|
4
5
|
* The game-services notifications client: the notifications runtime's
|
|
5
6
|
* backend (`NotificationRuntimeBackend` from
|
|
@@ -342,14 +343,35 @@ export function createGameServicesNotificationsClient(input) {
|
|
|
342
343
|
|| !Object.values(targets).every((provider) => typeof provider === 'string')) {
|
|
343
344
|
throw contractBroken('/providers', 200, 'the registry is not { protocolVersion, providers, targets }');
|
|
344
345
|
}
|
|
346
|
+
// One entry the client cannot read does not break the others: it is
|
|
347
|
+
// left out of `providers` and listed in `unreadable`.
|
|
348
|
+
const readable = [];
|
|
349
|
+
const unreadable = [];
|
|
350
|
+
for (const entry of providers) {
|
|
351
|
+
try {
|
|
352
|
+
readable.push(assertNotificationDeliveryProviderEntry(entry));
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
const id = typeof entry === 'object' && entry !== null && !Array.isArray(entry)
|
|
356
|
+
? entry['provider']
|
|
357
|
+
: undefined;
|
|
358
|
+
unreadable.push({
|
|
359
|
+
...(typeof id === 'string' && id.length > 0 ? { provider: id } : {}),
|
|
360
|
+
issue: describeError(error).slice(0, unreadableIssueLength),
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
345
364
|
return {
|
|
346
365
|
protocolVersion,
|
|
347
|
-
providers:
|
|
366
|
+
providers: readable,
|
|
348
367
|
targets: { ...targets },
|
|
368
|
+
...(unreadable.length === 0 ? {} : { unreadable }),
|
|
349
369
|
};
|
|
350
370
|
},
|
|
351
371
|
};
|
|
352
372
|
}
|
|
373
|
+
/** The longest `issue` an unreadable registry entry carries. */
|
|
374
|
+
const unreadableIssueLength = 512;
|
|
353
375
|
function globalFetch() {
|
|
354
376
|
const fetcher = globalThis.fetch;
|
|
355
377
|
if (fetcher === undefined) {
|
|
@@ -386,7 +408,7 @@ function checked(route, read) {
|
|
|
386
408
|
return read();
|
|
387
409
|
}
|
|
388
410
|
catch (error) {
|
|
389
|
-
throw contractBroken(route, 200,
|
|
411
|
+
throw contractBroken(route, 200, describeError(error));
|
|
390
412
|
}
|
|
391
413
|
}
|
|
392
414
|
function errorOf(route, response, body) {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type NotificationNoticeVariant, type NotificationResolvedCopy, type NotificationTemplateData, type NotificationTopicCopy, type NotificationTopicDeclaration } from '@mpgd/platform/notifications';
|
|
2
|
+
/**
|
|
3
|
+
* Server-side copy for remote sends (notifications v2 RFC, "Remote copy",
|
|
4
|
+
* open question 4). A send's title and body come from a copy resolver: by
|
|
5
|
+
* default the catalog's `copy` for the recipient's locale
|
|
6
|
+
* (`resolveCatalogNotificationCopy`), or a project's own resolver passed to
|
|
7
|
+
* `createNotificationService` as `resolveCopy`. The service asks the
|
|
8
|
+
* resolver only where the content comes from copy
|
|
9
|
+
* (`notificationContentSource` is `catalog`): a target mapping's own title
|
|
10
|
+
* and body (Devvit) and a host template (Apps in Toss) never reach it.
|
|
11
|
+
*
|
|
12
|
+
* The text never comes from the request: a send or a reminder carries no
|
|
13
|
+
* title or body, only template data for the `{{name}}` placeholders of the
|
|
14
|
+
* text the resolver answers. The policy gate then renders the answer exactly
|
|
15
|
+
* as it renders catalog copy, so under the Korean rules an advertising send
|
|
16
|
+
* still gets the `(광고)` label, the sender line, the opt-out line, and the
|
|
17
|
+
* opt-out action.
|
|
18
|
+
*/
|
|
19
|
+
/** What a copy resolver is asked: one topic, for one recipient's locale, on one provider. */
|
|
20
|
+
export interface NotificationCopyResolverInput {
|
|
21
|
+
/**
|
|
22
|
+
* The topic as declared for the recipient's target, with its mapping and
|
|
23
|
+
* its catalog `copy`; for a consent notice, the notice topic with the
|
|
24
|
+
* variant's `notices.copy` as its `copy`.
|
|
25
|
+
*/
|
|
26
|
+
readonly topic: NotificationTopicDeclaration;
|
|
27
|
+
/** The provider the send goes through. */
|
|
28
|
+
readonly provider: string;
|
|
29
|
+
/**
|
|
30
|
+
* The send's locale, else the recipient's registered locale; absent when
|
|
31
|
+
* neither names one. A resolver falls back from it as the catalog lookup
|
|
32
|
+
* does: the exact locale, then its language, then `defaultLocale`.
|
|
33
|
+
*/
|
|
34
|
+
readonly locale?: string;
|
|
35
|
+
/** The catalog's `defaultLocale`. */
|
|
36
|
+
readonly defaultLocale: string;
|
|
37
|
+
/**
|
|
38
|
+
* The send's template data, which fills the answer's placeholders after
|
|
39
|
+
* the resolver answers. It may come from a player's reminder: a resolver
|
|
40
|
+
* must not copy it into the text it answers.
|
|
41
|
+
*/
|
|
42
|
+
readonly templateData?: NotificationTemplateData;
|
|
43
|
+
/** Set for a consent notice the scheduler pushes: the variant whose text it sends. */
|
|
44
|
+
readonly notice?: {
|
|
45
|
+
readonly variant: NotificationNoticeVariant;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Chooses the title and body templates of one send for one locale, or
|
|
50
|
+
* answers `undefined` when it has none: the send is then skipped as
|
|
51
|
+
* `unavailable: copy-missing`, never sent with empty text. The answer's
|
|
52
|
+
* `{{name}}` placeholders are filled from the send's template data, as
|
|
53
|
+
* catalog copy is. A resolver that throws fails the attempt, and the send
|
|
54
|
+
* is retried: a `NotificationError` keeps its own code, and any other throw
|
|
55
|
+
* is a copy outage (`NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`).
|
|
56
|
+
*/
|
|
57
|
+
export type NotificationCopyResolver = (input: NotificationCopyResolverInput) => NotificationTopicCopy | undefined | Promise<NotificationTopicCopy | undefined>;
|
|
58
|
+
/**
|
|
59
|
+
* The default resolver: the topic's catalog `copy` for the locale, then its
|
|
60
|
+
* language (`en` for `en-US`), then the catalog's `defaultLocale`, each
|
|
61
|
+
* matched without regard to case (`findNotificationCopy`). A project's
|
|
62
|
+
* resolver can fall back to it for the topics or locales it does not cover:
|
|
63
|
+
* `(input) => mine(input) ?? resolveCatalogNotificationCopy(input)`.
|
|
64
|
+
*/
|
|
65
|
+
export declare const resolveCatalogNotificationCopy: NotificationCopyResolver;
|
|
66
|
+
/** A resolver's answer as a send uses it, with why an answer was not usable. */
|
|
67
|
+
export interface NotificationCopyResolution extends NotificationResolvedCopy {
|
|
68
|
+
/** Set when the resolver answered something that cannot be sent; `copy` is then absent. */
|
|
69
|
+
readonly issue?: string;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Asks `resolver` once and checks the answer: a title and a body that are
|
|
73
|
+
* strings and not blank, and for a consent notice the notice text rules
|
|
74
|
+
* (`notificationNoticeTextIssues`: only `{{consentDate}}`, which one of them
|
|
75
|
+
* uses). An answer that fails is no copy, with the reason in `issue`. The
|
|
76
|
+
* result's `copy` is what the gate takes as `resolvedCopy`.
|
|
77
|
+
*/
|
|
78
|
+
export declare function resolveNotificationCopy(resolver: NotificationCopyResolver, input: NotificationCopyResolverInput): Promise<NotificationCopyResolution>;
|
|
79
|
+
/**
|
|
80
|
+
* Resolves one topic for several locales, asking `resolver` once per
|
|
81
|
+
* distinct locale (an absent locale counts as one): a bulk send groups its
|
|
82
|
+
* recipients by locale and sends each group the copy resolved for it. The
|
|
83
|
+
* map is keyed by the locales as given.
|
|
84
|
+
*/
|
|
85
|
+
export declare function resolveNotificationCopyByLocale(resolver: NotificationCopyResolver, input: Omit<NotificationCopyResolverInput, 'locale'> & {
|
|
86
|
+
readonly locales: Iterable<string | undefined>;
|
|
87
|
+
}): Promise<Map<string | undefined, NotificationCopyResolution>>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { findNotificationCopy, notificationNoticeTextIssues, } from '@mpgd/platform/notifications';
|
|
2
|
+
/**
|
|
3
|
+
* The default resolver: the topic's catalog `copy` for the locale, then its
|
|
4
|
+
* language (`en` for `en-US`), then the catalog's `defaultLocale`, each
|
|
5
|
+
* matched without regard to case (`findNotificationCopy`). A project's
|
|
6
|
+
* resolver can fall back to it for the topics or locales it does not cover:
|
|
7
|
+
* `(input) => mine(input) ?? resolveCatalogNotificationCopy(input)`.
|
|
8
|
+
*/
|
|
9
|
+
export const resolveCatalogNotificationCopy = (input) => findNotificationCopy(input.topic, input.locale, input.defaultLocale);
|
|
10
|
+
/**
|
|
11
|
+
* Asks `resolver` once and checks the answer: a title and a body that are
|
|
12
|
+
* strings and not blank, and for a consent notice the notice text rules
|
|
13
|
+
* (`notificationNoticeTextIssues`: only `{{consentDate}}`, which one of them
|
|
14
|
+
* uses). An answer that fails is no copy, with the reason in `issue`. The
|
|
15
|
+
* result's `copy` is what the gate takes as `resolvedCopy`.
|
|
16
|
+
*/
|
|
17
|
+
export async function resolveNotificationCopy(resolver, input) {
|
|
18
|
+
const answer = await resolver(input);
|
|
19
|
+
if (answer === undefined || answer === null) {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
const issue = copyIssue(answer, input);
|
|
23
|
+
if (issue !== undefined) {
|
|
24
|
+
return { issue };
|
|
25
|
+
}
|
|
26
|
+
const { title, body } = answer;
|
|
27
|
+
return { copy: { title, body } };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolves one topic for several locales, asking `resolver` once per
|
|
31
|
+
* distinct locale (an absent locale counts as one): a bulk send groups its
|
|
32
|
+
* recipients by locale and sends each group the copy resolved for it. The
|
|
33
|
+
* map is keyed by the locales as given.
|
|
34
|
+
*/
|
|
35
|
+
export async function resolveNotificationCopyByLocale(resolver, input) {
|
|
36
|
+
const { locales, ...shared } = input;
|
|
37
|
+
const resolved = new Map();
|
|
38
|
+
for (const locale of locales) {
|
|
39
|
+
if (!resolved.has(locale)) {
|
|
40
|
+
resolved.set(locale, await resolveNotificationCopy(resolver, {
|
|
41
|
+
...shared,
|
|
42
|
+
...(locale === undefined ? {} : { locale }),
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return resolved;
|
|
47
|
+
}
|
|
48
|
+
function copyIssue(answer, input) {
|
|
49
|
+
if (typeof answer !== 'object' || answer === null || Array.isArray(answer)) {
|
|
50
|
+
return 'the copy resolver answered something other than a title and a body';
|
|
51
|
+
}
|
|
52
|
+
const { title, body } = answer;
|
|
53
|
+
if (typeof title !== 'string' || typeof body !== 'string') {
|
|
54
|
+
return 'the copy resolver answered no string title and body';
|
|
55
|
+
}
|
|
56
|
+
if (title.trim().length === 0 || body.trim().length === 0) {
|
|
57
|
+
return 'the copy resolver answered a blank title or body';
|
|
58
|
+
}
|
|
59
|
+
if (input.notice !== undefined) {
|
|
60
|
+
// Every notice variant follows the same text rules (`notificationNoticeCopyIssues`
|
|
61
|
+
// applies them to each one); the path names the answer as notices.copy would.
|
|
62
|
+
const issues = notificationNoticeTextIssues({ title, body }, `notices.copy[${JSON.stringify(input.locale ?? input.defaultLocale)}][${JSON.stringify(input.notice.variant)}]`);
|
|
63
|
+
if (issues.length > 0) {
|
|
64
|
+
return `the copy resolver's notice text breaks the notice rules: ${issues
|
|
65
|
+
.map((issue) => `${issue.path}: ${issue.message}`)
|
|
66
|
+
.join('; ')}`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
@@ -89,10 +89,17 @@ export interface NotificationRemoteDeliveryRequest {
|
|
|
89
89
|
* answer). The claim is kept until its lease ends.
|
|
90
90
|
*
|
|
91
91
|
* A provider that throws is treated as `ambiguous`.
|
|
92
|
+
*
|
|
93
|
+
* A batch may also answer `not-sent` for a request it made no call for (its
|
|
94
|
+
* calls ran past `startWithinMs`): the row goes back as it was, due again
|
|
95
|
+
* shortly, without a rate-limit backoff or the 24-hour give-up, since no
|
|
96
|
+
* provider refused anything.
|
|
92
97
|
*/
|
|
93
98
|
export type NotificationRemoteDeliveryAnswer = {
|
|
94
99
|
readonly status: 'accepted';
|
|
95
100
|
readonly providerMessageId?: string;
|
|
101
|
+
} | {
|
|
102
|
+
readonly status: 'not-sent';
|
|
96
103
|
} | {
|
|
97
104
|
readonly status: 'unregistered';
|
|
98
105
|
} | {
|
|
@@ -106,8 +113,69 @@ export type NotificationRemoteDeliveryAnswer = {
|
|
|
106
113
|
} | {
|
|
107
114
|
readonly status: 'ambiguous';
|
|
108
115
|
readonly providerCode?: string;
|
|
116
|
+
/**
|
|
117
|
+
* A bulk call that reached some of its recipients and did not say which
|
|
118
|
+
* (BK1). A replay would reach those recipients again, so the delivery is
|
|
119
|
+
* not replayed: it stays in flight until its key's retention ends, and
|
|
120
|
+
* the backend reports it. Only a batch answer sets it.
|
|
121
|
+
*/
|
|
122
|
+
readonly unattributed?: true;
|
|
109
123
|
};
|
|
110
124
|
/**
|
|
125
|
+
* A provider's bulk path (RFC open question 5, BK1): one provider call for
|
|
126
|
+
* several recipients of one topic, each with its own ledger key and answer.
|
|
127
|
+
* A provider without it sends one recipient per call.
|
|
128
|
+
*/
|
|
129
|
+
export interface NotificationRemoteBulkCapability {
|
|
130
|
+
/** The most recipients one `sendBatch` call takes. */
|
|
131
|
+
readonly maxRecipients: number;
|
|
132
|
+
/**
|
|
133
|
+
* The fewest recipients the bulk API is meant for; a smaller batch goes
|
|
134
|
+
* one recipient per `send` call. 2 by default.
|
|
135
|
+
*/
|
|
136
|
+
readonly minRecipients?: number;
|
|
137
|
+
/**
|
|
138
|
+
* Whether the provider's answer names each recipient. `false` means it
|
|
139
|
+
* answers the batch as a whole (counts only): the provider then gives
|
|
140
|
+
* every recipient the same answer, `accepted` only when the counts prove
|
|
141
|
+
* each one, and `ambiguous` with `unattributed` when they prove only that
|
|
142
|
+
* some were reached.
|
|
143
|
+
*/
|
|
144
|
+
readonly perRecipientOutcomes: boolean;
|
|
145
|
+
/**
|
|
146
|
+
* A daily window, in a time zone, in which the host holds bulk messages
|
|
147
|
+
* until it ends (Apps in Toss holds `send-bulk-message` from 21:00 to
|
|
148
|
+
* 08:00). The service sends one recipient per call inside it, so the
|
|
149
|
+
* gate's timing stands.
|
|
150
|
+
*/
|
|
151
|
+
/**
|
|
152
|
+
* The provider's call group of a request: requests with different keys
|
|
153
|
+
* never share a call (Devvit takes one title and body a call). The
|
|
154
|
+
* service makes one `sendBatch` per group and runs the gate again for
|
|
155
|
+
* each request just before its group's call, so a later call never sends
|
|
156
|
+
* on a stale gate check. One group when absent.
|
|
157
|
+
*/
|
|
158
|
+
callKey?(request: NotificationRemoteDeliveryRequest): string;
|
|
159
|
+
readonly holdWindow?: {
|
|
160
|
+
/** `HH:MM`, inclusive. */
|
|
161
|
+
readonly start: string;
|
|
162
|
+
/** `HH:MM`, exclusive; earlier than `start` when the window spans midnight. */
|
|
163
|
+
readonly end: string;
|
|
164
|
+
/** An IANA time zone. */
|
|
165
|
+
readonly timeZone: string;
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/** How long a provider may keep starting calls for one `sendBatch`. */
|
|
169
|
+
export interface NotificationRemoteBatchOptions {
|
|
170
|
+
/**
|
|
171
|
+
* Milliseconds from the start of `sendBatch` after which the provider
|
|
172
|
+
* starts no further call (one batch may take several, such as one per
|
|
173
|
+
* text): the requests' claims must outlive the calls. A request it did not
|
|
174
|
+
* start answers `not-sent`.
|
|
175
|
+
*/
|
|
176
|
+
readonly startWithinMs?: number;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
111
179
|
* The provider code of a `rejected` answer for a message that would arrive
|
|
112
180
|
* after its expiry: nothing was sent, and it is routine, not a setup error.
|
|
113
181
|
*/
|
|
@@ -117,6 +185,17 @@ export interface NotificationRemoteDeliveryProvider {
|
|
|
117
185
|
/** Equals the registry entry's `provider`. */
|
|
118
186
|
readonly id: NotificationProviderId;
|
|
119
187
|
send(request: NotificationRemoteDeliveryRequest): Promise<NotificationRemoteDeliveryAnswer>;
|
|
188
|
+
/** The bulk path, declared with `sendBatch`; without it every recipient is one `send`. */
|
|
189
|
+
readonly bulk?: NotificationRemoteBulkCapability;
|
|
190
|
+
/**
|
|
191
|
+
* One provider call for `requests` (at most `bulk.maxRecipients`, each a
|
|
192
|
+
* different recipient of the same send kind): the answers in the order of
|
|
193
|
+
* the requests, one per request. The service runs the gate for each
|
|
194
|
+
* request before the call and settles each answer on that request's own
|
|
195
|
+
* ledger key; an answer that is missing or not one of the five kinds is
|
|
196
|
+
* `ambiguous`, as a throw is for every request.
|
|
197
|
+
*/
|
|
198
|
+
sendBatch?(requests: readonly NotificationRemoteDeliveryRequest[], options?: NotificationRemoteBatchOptions): Promise<readonly NotificationRemoteDeliveryAnswer[]>;
|
|
120
199
|
/**
|
|
121
200
|
* The host's permission at send time, for a provider whose registry entry
|
|
122
201
|
* is `serverReadable` (Devvit `isOptedIn`). The gate requires it there.
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
* "Server"): the provider registry, the endpoint registry, consent records
|
|
4
4
|
* and the notices API, sends through the policy gate, server reminders and
|
|
5
5
|
* the scheduler (consent notice pushes, paced due rows), and the remote
|
|
6
|
-
* delivery provider contract, with the FCM provider
|
|
6
|
+
* delivery provider contract, with the FCM provider, and the copy resolver
|
|
7
|
+
* port that chooses a send's localized title and body.
|
|
7
8
|
*/
|
|
9
|
+
export * from './bulk.js';
|
|
10
|
+
export * from './copy.js';
|
|
8
11
|
export * from './delivery-provider.js';
|
|
9
12
|
export * from './fcm.js';
|
|
10
13
|
export * from './registry.js';
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
* "Server"): the provider registry, the endpoint registry, consent records
|
|
4
4
|
* and the notices API, sends through the policy gate, server reminders and
|
|
5
5
|
* the scheduler (consent notice pushes, paced due rows), and the remote
|
|
6
|
-
* delivery provider contract, with the FCM provider
|
|
6
|
+
* delivery provider contract, with the FCM provider, and the copy resolver
|
|
7
|
+
* port that chooses a send's localized title and body.
|
|
7
8
|
*/
|
|
9
|
+
export * from './bulk.js';
|
|
10
|
+
export * from './copy.js';
|
|
8
11
|
export * from './delivery-provider.js';
|
|
9
12
|
export * from './fcm.js';
|
|
10
13
|
export * from './registry.js';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { assertNotificationTopicCatalog, resolveNotificationTopicsForTarget, } from '@mpgd/catalog';
|
|
2
2
|
import { assertNotificationDeliveryProviderEntry, assertNotificationDeployment, referenceNotificationTargetProviders, } from '@mpgd/platform/notifications';
|
|
3
|
+
import { assertNotificationBulkCapability } from './bulk.js';
|
|
3
4
|
import { describeError as describe } from './text.js';
|
|
4
5
|
/**
|
|
5
6
|
* The server-side provider registry (notifications v2 RFC, "Provider
|
|
@@ -50,6 +51,13 @@ export function createNotificationProviderRegistry(registrations) {
|
|
|
50
51
|
if (entry.permission.serverReadable && registration.delivery.readPermission === undefined) {
|
|
51
52
|
throw new TypeError(`${at}: provider ${entry.provider} is server-readable but its delivery provider has no readPermission.`);
|
|
52
53
|
}
|
|
54
|
+
const { bulk, sendBatch } = registration.delivery;
|
|
55
|
+
if ((bulk === undefined) !== (sendBatch === undefined)) {
|
|
56
|
+
throw new TypeError(`${at}: provider ${entry.provider} declares a bulk capability and sendBatch together, or neither.`);
|
|
57
|
+
}
|
|
58
|
+
if (bulk !== undefined) {
|
|
59
|
+
assertNotificationBulkCapability(bulk, at);
|
|
60
|
+
}
|
|
53
61
|
for (const limit of entry.limits ?? []) {
|
|
54
62
|
if (limit.windowSeconds > maxHistoryWindowSeconds) {
|
|
55
63
|
throw new TypeError(`${at}: a limit window of ${limit.windowSeconds} seconds is longer than the ${notificationHistoryRetentionDays}-day delivery history.`);
|