@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.
@@ -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 256 KiB or not UTF-8. */
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) > maximumResponseBodyBytes) {
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 > maximumResponseBodyBytes) {
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
+ };