@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
|
@@ -43,6 +43,27 @@ import type { AppsInTossMutualTlsFetcher } from '../apps-in-toss-partner-api.js'
|
|
|
43
43
|
*/
|
|
44
44
|
export declare const aitFunctionalMessageProviderId: "apps-in-toss";
|
|
45
45
|
export declare const aitFunctionalMessagePath = "api-partner/v1/apps-in-toss/messenger/send-message";
|
|
46
|
+
/**
|
|
47
|
+
* The bulk functional-message API (BK1), checked 2026-11-01 in
|
|
48
|
+
* https://developers-apps-in-toss.toss.im/documentation/common/growth/smart-message.md
|
|
49
|
+
* ("대량 메시지 발송하기") and the partner API's `sendBulkMessage` operation in
|
|
50
|
+
* https://developers-apps-in-toss.toss.im/api/push.md:
|
|
51
|
+
* - `POST /api-partner/v1/apps-in-toss/messenger/send-bulk-message` with
|
|
52
|
+
* `{ templateSetCode, contextList }`, each item `{ userKey | anonKey, context }`
|
|
53
|
+
* (the recipient goes in the body, not a header).
|
|
54
|
+
* - It is meant for 50 or more recipients, and one call takes at most 2,500.
|
|
55
|
+
* - Messages requested from 21:00 to before 08:00 are held and sent after
|
|
56
|
+
* 08:00.
|
|
57
|
+
* - The answer has the `send-message` shape: per-channel counts and lists of
|
|
58
|
+
* `{ contentId, reachedFailReason? }`, which name no recipient. Its
|
|
59
|
+
* business errors are 5004, 4034, and 4095 as for `send-message`, and the
|
|
60
|
+
* API is limited to 3,000 calls per minute per app.
|
|
61
|
+
*/
|
|
62
|
+
export declare const aitFunctionalBulkMessagePath = "api-partner/v1/apps-in-toss/messenger/send-bulk-message";
|
|
63
|
+
/** The fewest recipients the bulk API is meant for; fewer go one `send-message` call each. */
|
|
64
|
+
export declare const aitFunctionalBulkMessageMinRecipients = 50;
|
|
65
|
+
/** The most recipients one `send-bulk-message` call takes. */
|
|
66
|
+
export declare const aitFunctionalBulkMessageMaxRecipients = 2500;
|
|
46
67
|
export declare const defaultAitFunctionalMessageBaseUrl = "https://apps-in-toss-api.toss.im";
|
|
47
68
|
/**
|
|
48
69
|
* Hosts the partner certificate and the recipient key may be sent to.
|
|
@@ -84,6 +105,10 @@ export type AitFunctionalMessageAnswer = {
|
|
|
84
105
|
readonly status: 'rate-limited';
|
|
85
106
|
readonly retryAfterSeconds?: number;
|
|
86
107
|
readonly providerCode?: string;
|
|
108
|
+
}
|
|
109
|
+
/** A bulk request no call was started for (past `startWithinMs`): nothing was sent. */
|
|
110
|
+
| {
|
|
111
|
+
readonly status: 'not-sent';
|
|
87
112
|
} | {
|
|
88
113
|
readonly status: 'rejected';
|
|
89
114
|
readonly reason: AitFunctionalMessageRejection;
|
|
@@ -92,6 +117,8 @@ export type AitFunctionalMessageAnswer = {
|
|
|
92
117
|
} | {
|
|
93
118
|
readonly status: 'ambiguous';
|
|
94
119
|
readonly providerCode?: string;
|
|
120
|
+
/** A bulk call that reached some recipients without saying which: not replayed. */
|
|
121
|
+
readonly unattributed?: true;
|
|
95
122
|
};
|
|
96
123
|
/**
|
|
97
124
|
* Who one delivery goes to. Apps in Toss recipients are platform users whose
|
|
@@ -109,7 +136,7 @@ export type AitFunctionalMessageRecipient = {
|
|
|
109
136
|
export interface AitFunctionalMessageDeliveryRequest {
|
|
110
137
|
readonly recipient: AitFunctionalMessageRecipient;
|
|
111
138
|
/** The topic as declared for this provider: its mapping carries `templateSetCode`. */
|
|
112
|
-
readonly topic: Pick<NotificationTopicDeclaration, 'id' | 'mapping'
|
|
139
|
+
readonly topic: Pick<NotificationTopicDeclaration, 'id' | 'mapping'> & Partial<Pick<NotificationTopicDeclaration, 'purpose'>>;
|
|
113
140
|
/** The template's `context` values. */
|
|
114
141
|
readonly templateData?: NotificationTemplateData;
|
|
115
142
|
readonly signal?: AbortSignal;
|
|
@@ -151,6 +178,43 @@ export interface AitFunctionalMessageDeliveryProvider {
|
|
|
151
178
|
/** The registry entry the policy gate reads for this provider. */
|
|
152
179
|
readonly entry: NotificationDeliveryProviderEntry;
|
|
153
180
|
send(request: AitFunctionalMessageDeliveryRequest): Promise<AitFunctionalMessageAnswer>;
|
|
181
|
+
/**
|
|
182
|
+
* The bulk path: 50 to 2,500 recipients a call, answered as a whole (the
|
|
183
|
+
* answer names no recipient), and held by the host from 21:00 to 08:00
|
|
184
|
+
* Asia/Seoul. The declared window starts at 20:55, so a call in flight
|
|
185
|
+
* never lands in the hold; the service sends one by one inside it.
|
|
186
|
+
*/
|
|
187
|
+
readonly bulk: {
|
|
188
|
+
readonly maxRecipients: number;
|
|
189
|
+
readonly minRecipients: number;
|
|
190
|
+
readonly perRecipientOutcomes: false;
|
|
191
|
+
callKey(request: AitFunctionalMessageDeliveryRequest): string;
|
|
192
|
+
readonly holdWindow: {
|
|
193
|
+
readonly start: string;
|
|
194
|
+
readonly end: string;
|
|
195
|
+
readonly timeZone: string;
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
/**
|
|
199
|
+
* One `send-bulk-message` call per template set code, answered in the
|
|
200
|
+
* order of `requests`. Every recipient of a call gets the call's answer:
|
|
201
|
+
* `accepted` only when one channel's sent count equals the recipients
|
|
202
|
+
* (the kit reads a channel as reaching each recipient at most once per
|
|
203
|
+
* message, which the documentation does not state) or the channels'
|
|
204
|
+
* sent counts add up to exactly the recipients (read as each recipient
|
|
205
|
+
* reached on at most one channel per message, also not stated), `rejected` with
|
|
206
|
+
* reason `recipient` when nothing was sent on any channel, and otherwise
|
|
207
|
+
* `ambiguous` with `unattributed` (code `BULK_PARTIAL`): some were
|
|
208
|
+
* reached, and a replay would reach them again. A failure of the whole
|
|
209
|
+
* call maps as for `send` (4095 and HTTP 429 `rate-limited`, 5004 and 4034
|
|
210
|
+
* `configuration`), except that an `agreement` code reads as `recipient`,
|
|
211
|
+
* since the call names no recipient whose agreement is missing. A request
|
|
212
|
+
* `send` would refuse, or an `advertising` topic (functional messages
|
|
213
|
+
* carry no advertising), is `rejected` without a call.
|
|
214
|
+
*/
|
|
215
|
+
sendBatch(requests: readonly AitFunctionalMessageDeliveryRequest[], options?: {
|
|
216
|
+
readonly startWithinMs?: number;
|
|
217
|
+
}): Promise<readonly AitFunctionalMessageAnswer[]>;
|
|
154
218
|
}
|
|
155
219
|
/**
|
|
156
220
|
* Creates the provider. `send` never throws for a partner API answer: every
|
|
@@ -42,6 +42,27 @@ import { notificationTemplateDataIssue, referenceNotificationProviders, } from '
|
|
|
42
42
|
*/
|
|
43
43
|
export const aitFunctionalMessageProviderId = 'apps-in-toss';
|
|
44
44
|
export const aitFunctionalMessagePath = 'api-partner/v1/apps-in-toss/messenger/send-message';
|
|
45
|
+
/**
|
|
46
|
+
* The bulk functional-message API (BK1), checked 2026-11-01 in
|
|
47
|
+
* https://developers-apps-in-toss.toss.im/documentation/common/growth/smart-message.md
|
|
48
|
+
* ("대량 메시지 발송하기") and the partner API's `sendBulkMessage` operation in
|
|
49
|
+
* https://developers-apps-in-toss.toss.im/api/push.md:
|
|
50
|
+
* - `POST /api-partner/v1/apps-in-toss/messenger/send-bulk-message` with
|
|
51
|
+
* `{ templateSetCode, contextList }`, each item `{ userKey | anonKey, context }`
|
|
52
|
+
* (the recipient goes in the body, not a header).
|
|
53
|
+
* - It is meant for 50 or more recipients, and one call takes at most 2,500.
|
|
54
|
+
* - Messages requested from 21:00 to before 08:00 are held and sent after
|
|
55
|
+
* 08:00.
|
|
56
|
+
* - The answer has the `send-message` shape: per-channel counts and lists of
|
|
57
|
+
* `{ contentId, reachedFailReason? }`, which name no recipient. Its
|
|
58
|
+
* business errors are 5004, 4034, and 4095 as for `send-message`, and the
|
|
59
|
+
* API is limited to 3,000 calls per minute per app.
|
|
60
|
+
*/
|
|
61
|
+
export const aitFunctionalBulkMessagePath = 'api-partner/v1/apps-in-toss/messenger/send-bulk-message';
|
|
62
|
+
/** The fewest recipients the bulk API is meant for; fewer go one `send-message` call each. */
|
|
63
|
+
export const aitFunctionalBulkMessageMinRecipients = 50;
|
|
64
|
+
/** The most recipients one `send-bulk-message` call takes. */
|
|
65
|
+
export const aitFunctionalBulkMessageMaxRecipients = 2_500;
|
|
45
66
|
// The same values as `appsInTossPartnerApiBaseUrl` and
|
|
46
67
|
// `defaultAppsInTossPartnerApiTimeoutMs` (a test keeps them equal), kept here
|
|
47
68
|
// so this module loads none of the partner client's code.
|
|
@@ -78,6 +99,8 @@ const channelFields = {
|
|
|
78
99
|
friendtalk: 'sentFriendtalk',
|
|
79
100
|
};
|
|
80
101
|
const maximumResponseBodyBytes = 256 * 1_024;
|
|
102
|
+
/** A bulk answer lists up to 2,500 messages per channel. */
|
|
103
|
+
const maximumBulkResponseBodyBytes = 4 * 1_024 * 1_024;
|
|
81
104
|
/** The longest `reachedFailReason` the provider keeps; a longer one is cut to this. */
|
|
82
105
|
const maximumFailReasonLength = 1_024;
|
|
83
106
|
const maximumIdentifierLength = 2_048;
|
|
@@ -152,9 +175,140 @@ export function createAitFunctionalMessageDeliveryProvider(options) {
|
|
|
152
175
|
? { status: 'rejected', reason: 'recipient', providerCode: code }
|
|
153
176
|
: { status: 'ambiguous', providerCode: code };
|
|
154
177
|
};
|
|
178
|
+
const bulkUrl = new URL(aitFunctionalBulkMessagePath, normalizeBaseUrl(options.baseUrl ?? defaultAitFunctionalMessageBaseUrl, {
|
|
179
|
+
allowUnlistedHost: options.allowUnlistedHost === true,
|
|
180
|
+
})).href;
|
|
181
|
+
const bulkKey = options.recipientKey === 'anonymous' ? 'anonKey' : 'userKey';
|
|
182
|
+
/** One `send-bulk-message` call's answer, the same for each of its `count` recipients. */
|
|
183
|
+
const sendBulkGroup = async (templateSetCode, contextList) => {
|
|
184
|
+
const response = await post({
|
|
185
|
+
mtls: options.mtls,
|
|
186
|
+
url: bulkUrl,
|
|
187
|
+
headers: {},
|
|
188
|
+
body: { templateSetCode, contextList },
|
|
189
|
+
timeoutMs,
|
|
190
|
+
// Per-channel lists of up to 2,500 messages each outgrow the single
|
|
191
|
+
// send's 256 KiB.
|
|
192
|
+
maxBodyBytes: maximumBulkResponseBodyBytes,
|
|
193
|
+
});
|
|
194
|
+
if (response.kind === 'failed') {
|
|
195
|
+
return { status: 'ambiguous', providerCode: response.reason };
|
|
196
|
+
}
|
|
197
|
+
const { status, body } = response;
|
|
198
|
+
if (status >= 200 && status < 300 && body === undefined) {
|
|
199
|
+
// Taken, but its counts cannot be read: some or all may have been sent,
|
|
200
|
+
// and a replay would reach them again.
|
|
201
|
+
return { status: 'ambiguous', providerCode: 'BULK_UNREADABLE', unattributed: true };
|
|
202
|
+
}
|
|
203
|
+
if (status >= 500 || status === 408 || status === 425) {
|
|
204
|
+
return { status: 'ambiguous', ...providerCodeOf(body) };
|
|
205
|
+
}
|
|
206
|
+
if (status === 429) {
|
|
207
|
+
const error = isRecord(body) && isRecord(body['error']) ? body['error'] : undefined;
|
|
208
|
+
const retryAfterSeconds = retryAfterOf(error, response.retryAfter);
|
|
209
|
+
return {
|
|
210
|
+
status: 'rate-limited',
|
|
211
|
+
...providerCodeOf(body),
|
|
212
|
+
...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const resultType = isRecord(body) ? body['resultType'] : undefined;
|
|
216
|
+
if (resultType === 'SUCCESS') {
|
|
217
|
+
return status >= 200 && status < 300
|
|
218
|
+
? readBulkSuccess(isRecord(body) ? body['success'] : undefined, contextList.length)
|
|
219
|
+
: { status: 'ambiguous', providerCode: 'SUCCESS' };
|
|
220
|
+
}
|
|
221
|
+
if (resultType === 'FAIL' && isRecord(body)) {
|
|
222
|
+
const error = isRecord(body['error']) ? body['error'] : undefined;
|
|
223
|
+
const code = errorCodeOf(error?.['errorCode']);
|
|
224
|
+
if (code === undefined) {
|
|
225
|
+
return { status: 'ambiguous', providerCode: 'FAIL' };
|
|
226
|
+
}
|
|
227
|
+
const answer = classifyFailCode(code, status);
|
|
228
|
+
if (answer.status === 'rate-limited') {
|
|
229
|
+
const retryAfterSeconds = retryAfterOf(error, response.retryAfter);
|
|
230
|
+
return retryAfterSeconds === undefined ? answer : { ...answer, retryAfterSeconds };
|
|
231
|
+
}
|
|
232
|
+
// The call names no recipient: an agreement code cannot be one player's denial.
|
|
233
|
+
return answer.status === 'rejected' && answer.reason === 'agreement'
|
|
234
|
+
? { ...answer, reason: 'recipient' }
|
|
235
|
+
: answer;
|
|
236
|
+
}
|
|
237
|
+
if (typeof resultType === 'string' && ambiguousResultTypes.has(resultType)) {
|
|
238
|
+
return { status: 'ambiguous', providerCode: resultType };
|
|
239
|
+
}
|
|
240
|
+
if (refusedRequestStatuses.has(status)) {
|
|
241
|
+
return { status: 'rejected', reason: 'configuration', ...providerCodeOf(body) };
|
|
242
|
+
}
|
|
243
|
+
return { status: 'ambiguous', ...providerCodeOf(body) };
|
|
244
|
+
};
|
|
155
245
|
return {
|
|
156
246
|
id: aitFunctionalMessageProviderId,
|
|
157
247
|
entry: referenceNotificationProviders['apps-in-toss'],
|
|
248
|
+
bulk: {
|
|
249
|
+
maxRecipients: aitFunctionalBulkMessageMaxRecipients,
|
|
250
|
+
minRecipients: aitFunctionalBulkMessageMinRecipients,
|
|
251
|
+
perRecipientOutcomes: false,
|
|
252
|
+
// One template set code a `send-bulk-message`.
|
|
253
|
+
callKey: (request) => {
|
|
254
|
+
const code = request.topic.mapping?.['templateSetCode'];
|
|
255
|
+
return typeof code === 'string' ? code : '';
|
|
256
|
+
},
|
|
257
|
+
// Five minutes early, so a call still in flight never lands in the hold.
|
|
258
|
+
holdWindow: { start: '20:55', end: '08:00', timeZone: 'Asia/Seoul' },
|
|
259
|
+
},
|
|
260
|
+
async sendBatch(requests, batchOptions = {}) {
|
|
261
|
+
const startedAt = globalThis.performance.now();
|
|
262
|
+
const answers = requests.map(() => undefined);
|
|
263
|
+
const groups = new Map();
|
|
264
|
+
requests.forEach((request, index) => {
|
|
265
|
+
if (request.signal?.aborted === true) {
|
|
266
|
+
// Aborted before its call: it joins none, and nothing is sent to it.
|
|
267
|
+
answers[index] = { status: 'not-sent' };
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const templateSetCode = request.topic.mapping?.['templateSetCode'];
|
|
271
|
+
const context = request.templateData ?? {};
|
|
272
|
+
if (request.topic.purpose === 'advertising'
|
|
273
|
+
|| typeof templateSetCode !== 'string'
|
|
274
|
+
|| !isIdentifier(templateSetCode)
|
|
275
|
+
|| notificationTemplateDataIssue(context) !== undefined
|
|
276
|
+
|| request.recipient.kind !== 'platform-user') {
|
|
277
|
+
answers[index] = { status: 'rejected', reason: 'configuration' };
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const id = request.recipient.id;
|
|
281
|
+
if (!isHeaderValue(id) || (bulkKey === 'userKey' && !/^\d+$/u.test(id))) {
|
|
282
|
+
answers[index] = { status: 'rejected', reason: 'recipient' };
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
// The documentation types userKey as a number (int64) or a string; a
|
|
286
|
+
// number past 2^53 would lose digits in JSON, so it stays a string.
|
|
287
|
+
const key = bulkKey === 'userKey' && Number.isSafeInteger(Number(id)) ? Number(id) : id;
|
|
288
|
+
let group = groups.get(templateSetCode);
|
|
289
|
+
if (group === undefined) {
|
|
290
|
+
group = { indexes: [], contexts: [] };
|
|
291
|
+
groups.set(templateSetCode, group);
|
|
292
|
+
}
|
|
293
|
+
group.indexes.push(index);
|
|
294
|
+
group.contexts.push({ [bulkKey]: key, context });
|
|
295
|
+
});
|
|
296
|
+
for (const [templateSetCode, group] of groups) {
|
|
297
|
+
for (let start = 0; start < group.contexts.length; start += aitFunctionalBulkMessageMaxRecipients) {
|
|
298
|
+
const contexts = group.contexts.slice(start, start + aitFunctionalBulkMessageMaxRecipients);
|
|
299
|
+
const late = batchOptions.startWithinMs !== undefined
|
|
300
|
+
&& globalThis.performance.now() - startedAt >= batchOptions.startWithinMs;
|
|
301
|
+
// Too late to start another call within the recipients' claims: nothing was sent to them.
|
|
302
|
+
const answer = late
|
|
303
|
+
? { status: 'not-sent' }
|
|
304
|
+
: await sendBulkGroup(templateSetCode, contexts);
|
|
305
|
+
for (const index of group.indexes.slice(start, start + contexts.length)) {
|
|
306
|
+
answers[index] = answer;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return answers.map((answer) => answer ?? { status: 'ambiguous' });
|
|
311
|
+
},
|
|
158
312
|
async send(request) {
|
|
159
313
|
const templateSetCode = request.topic.mapping?.['templateSetCode'];
|
|
160
314
|
if (typeof templateSetCode !== 'string' || !isIdentifier(templateSetCode)) {
|
|
@@ -272,6 +426,86 @@ function readSuccess(success, agreementFailReasons) {
|
|
|
272
426
|
&& agreementFailReasons.has(failure.reachedFailReason.trim())));
|
|
273
427
|
return { status: 'rejected', reason: agreement ? 'agreement' : 'recipient', channels };
|
|
274
428
|
}
|
|
429
|
+
/**
|
|
430
|
+
* A `send-bulk-message` `SUCCESS` for `recipients` recipients. The answer
|
|
431
|
+
* names no recipient, so the counts decide, under two readings the
|
|
432
|
+
* documentation does not state:
|
|
433
|
+
* - a channel reaches each recipient at most once per message, so one
|
|
434
|
+
* channel that sent exactly as many messages as there were recipients
|
|
435
|
+
* reached each of them;
|
|
436
|
+
* - a recipient is reached on at most one channel per message (push, else
|
|
437
|
+
* inbox, and so on), so per-channel sent counts that add up to exactly
|
|
438
|
+
* the recipients, none above it, reached each of them once.
|
|
439
|
+
* It proves nothing sent only when every count is zero. Anything else is
|
|
440
|
+
* some recipients reached and some not, without saying which: each is
|
|
441
|
+
* `unattributed`, its row stays in flight and is never replayed. Such a
|
|
442
|
+
* row writes no delivery history, so the app-wide pace (the registry
|
|
443
|
+
* `rate`) never counts it; the gate still counts it toward the player's
|
|
444
|
+
* frequency caps and per-user limits as an attempted in-flight send.
|
|
445
|
+
*/
|
|
446
|
+
function readBulkSuccess(success, recipients) {
|
|
447
|
+
if (!isRecord(success) || !isCount(success['msgCount'])) {
|
|
448
|
+
return { status: 'ambiguous', providerCode: 'SUCCESS' };
|
|
449
|
+
}
|
|
450
|
+
const detail = success['detail'];
|
|
451
|
+
const fail = success['fail'];
|
|
452
|
+
const countFields = {
|
|
453
|
+
push: 'sentPushCount',
|
|
454
|
+
inbox: 'sentInboxCount',
|
|
455
|
+
sms: 'sentSmsCount',
|
|
456
|
+
alimtalk: 'sentAlimtalkCount',
|
|
457
|
+
friendtalk: 'sentFriendtalkCount',
|
|
458
|
+
};
|
|
459
|
+
const channelNames = Object.keys(channelFields);
|
|
460
|
+
/** A channel's sent list: its entries, and how many name their message (a string `contentId`). */
|
|
461
|
+
const listedOn = (channel) => {
|
|
462
|
+
const listed = isRecord(detail) ? detail[channelFields[channel]] : undefined;
|
|
463
|
+
if (!Array.isArray(listed)) {
|
|
464
|
+
return undefined;
|
|
465
|
+
}
|
|
466
|
+
const valid = listed.filter((item) => isRecord(item) && typeof item['contentId'] === 'string' && item['contentId'].length > 0).length;
|
|
467
|
+
return { entries: listed.length, valid };
|
|
468
|
+
};
|
|
469
|
+
const sentOn = (channel) => {
|
|
470
|
+
const counted = success[countFields[channel]];
|
|
471
|
+
return Math.max(isCount(counted) ? counted : 0, listedOn(channel)?.valid ?? 0);
|
|
472
|
+
};
|
|
473
|
+
// A count and a list of sent messages that disagree, or a list entry that
|
|
474
|
+
// names no message, prove nothing about who was reached. An empty list is
|
|
475
|
+
// a list the answer left out, and the count stands.
|
|
476
|
+
const disagree = Object.keys(channelFields).some((channel) => {
|
|
477
|
+
const counted = success[countFields[channel]];
|
|
478
|
+
const listed = listedOn(channel);
|
|
479
|
+
if (listed === undefined || listed.entries === 0) {
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
return listed.valid !== listed.entries || (isCount(counted) && listed.entries !== counted);
|
|
483
|
+
});
|
|
484
|
+
if (disagree) {
|
|
485
|
+
return { status: 'ambiguous', providerCode: 'BULK_COUNTS_DISAGREE', unattributed: true };
|
|
486
|
+
}
|
|
487
|
+
const channels = Object.fromEntries(channelNames.map((channel) => [
|
|
488
|
+
channel,
|
|
489
|
+
{
|
|
490
|
+
sent: readContents(isRecord(detail) ? detail[channelFields[channel]] : undefined),
|
|
491
|
+
failed: readContents(isRecord(fail) ? fail[channelFields[channel]] : undefined),
|
|
492
|
+
},
|
|
493
|
+
]));
|
|
494
|
+
const messageCount = success['msgCount'];
|
|
495
|
+
if (channelNames.some((channel) => sentOn(channel) === recipients)) {
|
|
496
|
+
return { status: 'accepted', messageCount, channels };
|
|
497
|
+
}
|
|
498
|
+
// Split across channels (some by push, the rest by inbox): exactly one message each.
|
|
499
|
+
const counts = channelNames.map(sentOn);
|
|
500
|
+
if (counts.reduce((sum, count) => sum + count, 0) === recipients
|
|
501
|
+
&& counts.every((count) => count <= recipients)) {
|
|
502
|
+
return { status: 'accepted', messageCount, channels };
|
|
503
|
+
}
|
|
504
|
+
if (messageCount === 0 && channelNames.every((channel) => sentOn(channel) === 0)) {
|
|
505
|
+
return { status: 'rejected', reason: 'recipient', channels };
|
|
506
|
+
}
|
|
507
|
+
return { status: 'ambiguous', providerCode: 'BULK_PARTIAL', unattributed: true };
|
|
508
|
+
}
|
|
275
509
|
function readContents(input) {
|
|
276
510
|
if (!Array.isArray(input)) {
|
|
277
511
|
return [];
|
|
@@ -323,7 +557,7 @@ async function post(input) {
|
|
|
323
557
|
redirect: 'manual',
|
|
324
558
|
signal: controller.signal,
|
|
325
559
|
});
|
|
326
|
-
const text = await readBoundedText(response);
|
|
560
|
+
const text = await readBoundedText(response, input.maxBodyBytes ?? maximumResponseBodyBytes);
|
|
327
561
|
let body;
|
|
328
562
|
if (text !== undefined && text.length > 0) {
|
|
329
563
|
try {
|
|
@@ -353,12 +587,12 @@ async function post(input) {
|
|
|
353
587
|
controller.abort();
|
|
354
588
|
}
|
|
355
589
|
}
|
|
356
|
-
/** The body as UTF-8 text, or `undefined` when it is larger than
|
|
357
|
-
async function readBoundedText(response) {
|
|
590
|
+
/** The body as UTF-8 text, or `undefined` when it is larger than `maxBytes` or not UTF-8. */
|
|
591
|
+
async function readBoundedText(response, maxBytes) {
|
|
358
592
|
const declaredLength = response.headers.get('content-length');
|
|
359
593
|
if (declaredLength !== null
|
|
360
594
|
&& /^\d+$/u.test(declaredLength)
|
|
361
|
-
&& Number(declaredLength) >
|
|
595
|
+
&& Number(declaredLength) > maxBytes) {
|
|
362
596
|
await response.body?.cancel().catch(() => { });
|
|
363
597
|
return undefined;
|
|
364
598
|
}
|
|
@@ -375,7 +609,7 @@ async function readBoundedText(response) {
|
|
|
375
609
|
break;
|
|
376
610
|
}
|
|
377
611
|
length += chunk.value.byteLength;
|
|
378
|
-
if (length >
|
|
612
|
+
if (length > maxBytes) {
|
|
379
613
|
await reader.cancel().catch(() => { });
|
|
380
614
|
return undefined;
|
|
381
615
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { type NotificationDeliveryOutcome } from '@mpgd/platform/notifications';
|
|
2
|
+
import type { NotificationRemoteBulkCapability } from './delivery-provider.js';
|
|
3
|
+
/**
|
|
4
|
+
* Bulk sends (notifications v2 RFC, open question 5; BK1): the request and
|
|
5
|
+
* answer types of `NotificationService.deliverBulk`, and the small pieces it
|
|
6
|
+
* is built from. A bulk send is many single sends that share provider calls:
|
|
7
|
+
* each recipient keeps its own key, ledger row, gate check, and answer.
|
|
8
|
+
*/
|
|
9
|
+
/** Most recipients one `deliverBulk` call takes. */
|
|
10
|
+
export declare const maxNotificationBulkRecipients = 10000;
|
|
11
|
+
/** One recipient of a bulk send. Its own fields replace the send's shared ones. */
|
|
12
|
+
export interface NotificationBulkRecipientInput {
|
|
13
|
+
readonly player: string;
|
|
14
|
+
readonly templateData?: Readonly<Record<string, string | number | boolean>>;
|
|
15
|
+
readonly data?: Readonly<Record<string, string>>;
|
|
16
|
+
/** The locale the gate resolves this recipient's copy for, else the registered one. */
|
|
17
|
+
readonly locale?: string;
|
|
18
|
+
readonly link?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A bulk send from the game's server or an operator tool, never from a
|
|
22
|
+
* client session (the Worker serves it only on the service binding).
|
|
23
|
+
*/
|
|
24
|
+
export interface NotificationBulkDeliverInput {
|
|
25
|
+
/**
|
|
26
|
+
* The bulk send's key. Each recipient's send key is
|
|
27
|
+
* `<idempotencyKey>:<encodeURIComponent(player)>`, so the same call
|
|
28
|
+
* retried answers recipients already sent from the ledger and sends only
|
|
29
|
+
* the rest.
|
|
30
|
+
*/
|
|
31
|
+
readonly idempotencyKey: string;
|
|
32
|
+
/** The one provider every recipient is sent through. */
|
|
33
|
+
readonly provider: string;
|
|
34
|
+
readonly topic: string;
|
|
35
|
+
/** 1 to `maxNotificationBulkRecipients` recipients, each player once. */
|
|
36
|
+
readonly recipients: readonly NotificationBulkRecipientInput[];
|
|
37
|
+
readonly templateData?: Readonly<Record<string, string | number | boolean>>;
|
|
38
|
+
readonly data?: Readonly<Record<string, string>>;
|
|
39
|
+
readonly locale?: string;
|
|
40
|
+
readonly link?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Recipients per provider call, at most the provider's
|
|
43
|
+
* `bulk.maxRecipients` (its maximum by default; 100 for a provider that
|
|
44
|
+
* sends one by one).
|
|
45
|
+
*/
|
|
46
|
+
readonly batchSize?: number;
|
|
47
|
+
}
|
|
48
|
+
/** What the host that serves `deliverBulk` bounds a call by. */
|
|
49
|
+
export interface NotificationBulkDeliverOptions {
|
|
50
|
+
/** Wall time after which no further batch starts; 20 seconds by default. */
|
|
51
|
+
readonly budgetMs?: number;
|
|
52
|
+
/**
|
|
53
|
+
* Deliveries one call attempts at most (sends it makes or replays, one
|
|
54
|
+
* per device of a device provider); recipients past it answer
|
|
55
|
+
* `not-attempted` (`count-budget`). A recipient answered from the ledger
|
|
56
|
+
* does not count. A device past it stays queued for the scheduler.
|
|
57
|
+
*/
|
|
58
|
+
readonly maxRecipients?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Recipients whose key one call reads at most, in the order of the
|
|
61
|
+
* request, whether or not they then need an attempt; the rest answer
|
|
62
|
+
* `not-attempted` (`count-budget`). It bounds the ledger reads of a large
|
|
63
|
+
* retry, which `maxRecipients` does not.
|
|
64
|
+
*/
|
|
65
|
+
readonly maxLookups?: number;
|
|
66
|
+
}
|
|
67
|
+
/** Why a recipient was not attempted in this call; the same call retried attempts it. */
|
|
68
|
+
export type NotificationBulkNotAttemptedReason =
|
|
69
|
+
/** The provider's app-wide `rate` (Devvit: 25,000 a day) has no room left now. */
|
|
70
|
+
'provider-rate'
|
|
71
|
+
/** The call's wall-time budget ran out before this recipient's batch. */
|
|
72
|
+
| 'time-budget'
|
|
73
|
+
/** The host's per-call recipient or read budget ran out. */
|
|
74
|
+
| 'count-budget';
|
|
75
|
+
export type NotificationBulkRecipientResult = {
|
|
76
|
+
readonly player: string;
|
|
77
|
+
readonly idempotencyKey: string;
|
|
78
|
+
readonly status: 'answered';
|
|
79
|
+
/** What `deliver` would answer for this recipient's key. */
|
|
80
|
+
readonly outcome: NotificationDeliveryOutcome;
|
|
81
|
+
} | {
|
|
82
|
+
readonly player: string;
|
|
83
|
+
readonly idempotencyKey: string;
|
|
84
|
+
readonly status: 'not-attempted';
|
|
85
|
+
readonly reason: NotificationBulkNotAttemptedReason;
|
|
86
|
+
} | {
|
|
87
|
+
readonly player: string;
|
|
88
|
+
readonly idempotencyKey: string;
|
|
89
|
+
readonly status: 'failed';
|
|
90
|
+
/** As `deliver` would have thrown it: a retryable error may pass on a retry of the call. */
|
|
91
|
+
readonly error: {
|
|
92
|
+
readonly code: string;
|
|
93
|
+
readonly message: string;
|
|
94
|
+
readonly retryable: boolean;
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
export interface NotificationBulkSummary {
|
|
98
|
+
/** Delivered by this call. */
|
|
99
|
+
readonly delivered: number;
|
|
100
|
+
/** Delivered by an earlier call with the same key. */
|
|
101
|
+
readonly alreadyProcessed: number;
|
|
102
|
+
readonly deferred: number;
|
|
103
|
+
readonly inFlight: number;
|
|
104
|
+
readonly suppressed: number;
|
|
105
|
+
readonly unavailable: number;
|
|
106
|
+
readonly notAttempted: number;
|
|
107
|
+
readonly failed: number;
|
|
108
|
+
}
|
|
109
|
+
export interface NotificationBulkDeliveryResult {
|
|
110
|
+
/** One per recipient, in the order of the request. */
|
|
111
|
+
readonly recipients: readonly NotificationBulkRecipientResult[];
|
|
112
|
+
readonly summary: NotificationBulkSummary;
|
|
113
|
+
/** Provider calls this call made: bulk calls, and recipients sent one per call. */
|
|
114
|
+
readonly providerCalls: {
|
|
115
|
+
readonly batch: number;
|
|
116
|
+
readonly single: number;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** A checked bulk request with its recipients' send keys. */
|
|
120
|
+
export interface ParsedNotificationBulkRequest {
|
|
121
|
+
readonly input: NotificationBulkDeliverInput;
|
|
122
|
+
readonly keys: readonly string[];
|
|
123
|
+
}
|
|
124
|
+
/** The send key of one recipient of a bulk send. */
|
|
125
|
+
export declare function notificationBulkRecipientKey(idempotencyKey: string, player: string): string;
|
|
126
|
+
/**
|
|
127
|
+
* A well-formed bulk request, or `NOTIFICATIONS_INVALID_INPUT` for the whole
|
|
128
|
+
* call. Each recipient's own request (its template data, data, locale, and
|
|
129
|
+
* link, and its derived key) is checked later as a single send's is, and
|
|
130
|
+
* fails that recipient alone.
|
|
131
|
+
*/
|
|
132
|
+
export declare function parseNotificationBulkRequest(raw: unknown): ParsedNotificationBulkRequest;
|
|
133
|
+
/**
|
|
134
|
+
* A provider's bulk capability as the registry accepts it: whole numbers,
|
|
135
|
+
* a minimum no larger than the maximum, and a hold window of two `HH:MM`
|
|
136
|
+
* times in a valid time zone. Throws a `TypeError` naming the issue.
|
|
137
|
+
*/
|
|
138
|
+
export declare function assertNotificationBulkCapability(capability: NotificationRemoteBulkCapability, at: string): void;
|
|
139
|
+
/** Whether `at` (epoch milliseconds) falls inside a provider's hold window. */
|
|
140
|
+
export declare function isInsideNotificationHoldWindow(window: NotificationRemoteBulkCapability['holdWindow'], at: number): boolean;
|
|
141
|
+
/** A counting semaphore: at most `concurrency` holders at once, served in order. */
|
|
142
|
+
export declare function createNotificationLimiter(concurrency: number): {
|
|
143
|
+
acquire(): Promise<void>;
|
|
144
|
+
release(): void;
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* Gathers the provider calls of a known number of participants into one
|
|
148
|
+
* flush. Each participant either arrives with its item, and waits for its
|
|
149
|
+
* answer, or leaves without one; once every participant did one or the
|
|
150
|
+
* other, `flush` runs once with the arrived items and answers each in
|
|
151
|
+
* order. A participant that holds a concurrency slot gives it up before it
|
|
152
|
+
* waits (see `deliverBulk`), so the barrier never waits on a participant
|
|
153
|
+
* that cannot run.
|
|
154
|
+
*/
|
|
155
|
+
export declare function createNotificationBatchBarrier<Item, Answer>(participants: number, flush: (items: readonly Item[]) => Promise<readonly Answer[]>): {
|
|
156
|
+
arrive(item: Item): Promise<Answer>;
|
|
157
|
+
leave(): void;
|
|
158
|
+
};
|