@mpgd/game-services 0.34.0 → 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/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/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/package.json +5 -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
|
+
}
|
|
@@ -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.`);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { NotificationError, type NotificationConsentRecord, type NotificationDeliveryOutcome, type NotificationDeployment, type NotificationNotice, type NotificationTemplateData } from '@mpgd/platform/notifications';
|
|
2
|
+
import { type NotificationBulkDeliverOptions, type NotificationBulkDeliveryResult } from './bulk.js';
|
|
3
|
+
import { type NotificationCopyResolver } from './copy.js';
|
|
2
4
|
import { type NotificationProviderRegistry, type NotificationProviderRegistrySnapshot, type NotificationServiceCatalog } from './registry.js';
|
|
3
5
|
import { type NotificationNoticeChannel, type NotificationPurgeCounts, type NotificationStore } from './store.js';
|
|
4
6
|
/**
|
|
@@ -70,6 +72,17 @@ export interface CreateNotificationServiceInput {
|
|
|
70
72
|
readonly player: string;
|
|
71
73
|
}) => string | undefined | Promise<string | undefined>;
|
|
72
74
|
/**
|
|
75
|
+
* Chooses a send's title and body per recipient locale where the content
|
|
76
|
+
* comes from copy (not a mapping's own text or a host template): reminders,
|
|
77
|
+
* consent notices, and direct sends alike. It replaces the default,
|
|
78
|
+
* `resolveCatalogNotificationCopy` (the catalog's `copy`, falling back to
|
|
79
|
+
* the language and then `defaultLocale`); a resolver that wants that
|
|
80
|
+
* fallback calls it. No copy skips the recipient as `unavailable:
|
|
81
|
+
* copy-missing`, and a resolver that throws is retried as a host that
|
|
82
|
+
* cannot answer is.
|
|
83
|
+
*/
|
|
84
|
+
readonly resolveCopy?: NotificationCopyResolver;
|
|
85
|
+
/**
|
|
73
86
|
* Runs dispatch jobs later, so a fan-out never exceeds one invocation's
|
|
74
87
|
* subrequest limit. Without it every job runs inline before `deliver`
|
|
75
88
|
* answers.
|
|
@@ -159,6 +172,25 @@ export interface NotificationService {
|
|
|
159
172
|
}): Promise<void>;
|
|
160
173
|
/** One send through the ledger, the gate, and the providers. */
|
|
161
174
|
deliver(input: unknown): Promise<NotificationDeliveryOutcome>;
|
|
175
|
+
/**
|
|
176
|
+
* A bulk send (RFC open question 5): one topic to many players through one
|
|
177
|
+
* provider, each recipient a single send with its own key
|
|
178
|
+
* (`<idempotencyKey>:<encodeURIComponent(player)>`), ledger row, and gate
|
|
179
|
+
* check, made immediately before its batch's provider call. Recipients the
|
|
180
|
+
* gate refuses never reach the provider. A provider with a bulk capability
|
|
181
|
+
* gets one `sendBatch` call per batch of allowed recipients (one `send`
|
|
182
|
+
* each below its `minRecipients` or inside its hold window); any other
|
|
183
|
+
* provider gets one `send` per recipient. The same call retried answers
|
|
184
|
+
* recipients already sent from the ledger and sends only the rest.
|
|
185
|
+
*
|
|
186
|
+
* It is an operator action from the game's server, like `deliver`: never
|
|
187
|
+
* serve it to a client session. `options` are the host's bounds: no batch
|
|
188
|
+
* starts after `budgetMs`, and recipients past `maxRecipients` attempts, or
|
|
189
|
+
* past the provider's `rate` budget, answer `not-attempted`. A recipient
|
|
190
|
+
* answered from the ledger is no attempt, though its key is still read:
|
|
191
|
+
* resend only the `not-attempted` recipients to keep a retry small.
|
|
192
|
+
*/
|
|
193
|
+
deliverBulk(input: unknown, options?: NotificationBulkDeliverOptions): Promise<NotificationBulkDeliveryResult>;
|
|
162
194
|
/** Runs one queued dispatch job; a job that is no longer due does nothing. */
|
|
163
195
|
dispatch(job: NotificationDispatchJob): Promise<void>;
|
|
164
196
|
/**
|