@mpgd/adapter-devvit 0.14.12 → 0.15.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.
@@ -30,8 +30,9 @@ export declare const devvitNotificationsPerUserPerDay = 2;
30
30
  export declare const devvitNotificationsPerAppPerDay = 25000;
31
31
  /**
32
32
  * Devvit's length unit, which the RFC left to P3. The docs give the limits
33
- * in "characters" without a unit, the SDK (`@devvit/notifications` 0.14.1)
34
- * checks nothing before `Enqueue`, and Devvit admins approve the copy
33
+ * in "characters" without a unit, the SDK (`@devvit/notifications` 0.14.1,
34
+ * and again 0.15.0 when re-checked on 2026-11-01) passes the title and body
35
+ * to `Enqueue` and checks nothing first, and Devvit admins approve the copy
35
36
  * before a game may send it, so the limit is a copy-review rule more than an
36
37
  * API one. The kit counts Unicode code points, as the catalog validator
37
38
  * counts its mapping fields: an accented letter or an emoji counts once, as
@@ -29,8 +29,9 @@ export const devvitNotificationsPerUserPerDay = 2;
29
29
  export const devvitNotificationsPerAppPerDay = 25_000;
30
30
  /**
31
31
  * Devvit's length unit, which the RFC left to P3. The docs give the limits
32
- * in "characters" without a unit, the SDK (`@devvit/notifications` 0.14.1)
33
- * checks nothing before `Enqueue`, and Devvit admins approve the copy
32
+ * in "characters" without a unit, the SDK (`@devvit/notifications` 0.14.1,
33
+ * and again 0.15.0 when re-checked on 2026-11-01) passes the title and body
34
+ * to `Enqueue` and checks nothing first, and Devvit admins approve the copy
34
35
  * before a game may send it, so the limit is a copy-review rule more than an
35
36
  * API one. The kit counts Unicode code points, as the catalog validator
36
37
  * counts its mapping fields: an accented letter or an emoji counts once, as
@@ -1,4 +1,4 @@
1
- import { type CreateNotificationServiceInput, type NotificationProviderRegistry, type NotificationRemoteDeliveryAnswer, type NotificationRemoteDeliveryProvider, type NotificationService, type NotificationServiceCatalog, type NotificationSession, type NotificationStore } from '@mpgd/game-services/notifications';
1
+ import { type CreateNotificationServiceInput, type NotificationBulkDeliverOptions, type NotificationBulkDeliveryResult, type NotificationProviderRegistry, type NotificationRemoteDeliveryAnswer, type NotificationRemoteDeliveryProvider, type NotificationService, type NotificationServiceCatalog, type NotificationSession, type NotificationStore } from '@mpgd/game-services/notifications';
2
2
  import { type NotificationDeployment, type NotificationProviderRegistryEntry } from '@mpgd/platform/notifications';
3
3
  import { type CreateDevvitRedisNotificationStoreOptions, type DevvitNotificationRedis } from './redis-notification-store.js';
4
4
  /**
@@ -34,8 +34,27 @@ export interface DevvitNotificationsDeliveryClient {
34
34
  recipients: DevvitEnqueueRecipient[];
35
35
  }): Promise<DevvitEnqueueResponse>;
36
36
  isOptedIn(userId: `t2_${string}`): Promise<boolean>;
37
+ /**
38
+ * One page of the users opted in to notifications, oldest opt-in first
39
+ * (`ListOptedInUsersOptions`/`ListOptedInUsersResponse`); only
40
+ * `runDevvitNotificationBulkPage` needs it.
41
+ */
42
+ listOptedInUsers?(options: {
43
+ limit?: number;
44
+ after?: string | undefined;
45
+ }): Promise<{
46
+ readonly userIds: readonly string[];
47
+ readonly next?: string | undefined;
48
+ }>;
37
49
  }
38
50
  /**
51
+ * Recipients one `enqueue` call takes (`EnqueueOptions['recipients']`: "Max
52
+ * number of recipients is 1000" in `@devvit/notifications` 0.15.0), and
53
+ * users one `listOptedInUsers` page answers ("The default and maximum limit
54
+ * is 1,000" in the 0.15 push notification guide).
55
+ */
56
+ export declare const devvitNotificationEnqueueMaxRecipients = 1000;
57
+ /**
39
58
  * How an `enqueue` failure is answered: a per-recipient error of the answer
40
59
  * (`source: 'answer'`: Devvit counted a failure for the user, but an error
41
60
  * the defaults cannot read is still `ambiguous`) or a call that threw
@@ -86,6 +105,8 @@ export declare const devvitNotificationProviderCodes: Readonly<{
86
105
  readonly enqueueRejected: 'DEVVIT_ENQUEUE_REJECTED';
87
106
  readonly enqueueUnanswered: 'DEVVIT_ENQUEUE_UNANSWERED';
88
107
  readonly enqueueUnclassified: 'DEVVIT_ENQUEUE_UNCLASSIFIED';
108
+ /** A bulk `enqueue` counted some of the unnamed recipients as sent and some not, without naming them. */
109
+ readonly enqueuePartial: 'DEVVIT_ENQUEUE_PARTIAL';
89
110
  }>;
90
111
  /**
91
112
  * The Devvit delivery provider (`{ id: 'devvit', send, readPermission }`).
@@ -278,3 +299,54 @@ export declare function runDevvitNotificationTask(service: Pick<NotificationServ
278
299
  readonly dispatchLimit?: number;
279
300
  readonly budgetMs?: number;
280
301
  }): Promise<DevvitNotificationTaskResult>;
302
+ /** One page of a bulk send to the users opted in on Devvit. */
303
+ export interface RunDevvitNotificationBulkPageInput {
304
+ readonly service: Pick<NotificationService, 'deliverBulk'>;
305
+ /** `notifications` from `@devvit/web/server`, for `listOptedInUsers`. */
306
+ readonly notifications: Pick<DevvitNotificationsDeliveryClient, 'listOptedInUsers'>;
307
+ /** The campaign's key; each user's send key is `<idempotencyKey>:<encodeURIComponent(userId)>`. */
308
+ readonly idempotencyKey: string;
309
+ readonly topic: string;
310
+ /** The post or comment every notification opens, unless the topic's `reddit` mapping has one. */
311
+ readonly link?: string;
312
+ readonly templateData?: Readonly<Record<string, string | number | boolean>>;
313
+ /** The cursor `next` answered for the page before; the first page without it. */
314
+ readonly after?: string;
315
+ /** Users per page, 1 to 1,000 (the most `listOptedInUsers` and `enqueue` take); 1,000 by default. */
316
+ readonly limit?: number;
317
+ /**
318
+ * The `pending` users an earlier run of this page answered: they are sent
319
+ * again without reading the list, and `next` is not answered.
320
+ */
321
+ readonly pending?: readonly string[];
322
+ readonly options?: NotificationBulkDeliverOptions;
323
+ }
324
+ export interface DevvitNotificationBulkPageResult {
325
+ /** The bulk send's answer for these users; `undefined` when there were none. */
326
+ readonly result?: NotificationBulkDeliveryResult;
327
+ /** The cursor of the next page; `undefined` once the list is done, and on a `pending` run. */
328
+ readonly next?: string;
329
+ /**
330
+ * The users still owed an attempt: not attempted (a budget or Devvit's
331
+ * daily app limit) or failed with a retryable error. Run again with them
332
+ * as `pending` before moving to `next`; users already sent answer from the
333
+ * ledger, and refused ones are not tried again.
334
+ */
335
+ readonly pending: readonly string[];
336
+ /** Whether `pending` is empty. */
337
+ readonly complete: boolean;
338
+ }
339
+ /**
340
+ * Sends one page of `listOptedInUsers` through `deliverBulk` (RFC open
341
+ * question 5): Devvit's opt-in list only picks the audience. The kit's gate
342
+ * still decides each user: it reads `isOptedIn` again at send time and the
343
+ * kit's own records (topic preference, frequency, quiet hours), so a user
344
+ * opted in on Devvit whom the gate refuses is not sent to. Devvit's limits
345
+ * hold per user (2 a day, the entry's provider limit, which suppresses the
346
+ * excess) and per app (25,000 a day, the entry's `rate`: users past it are
347
+ * `pending`, `not-attempted` with `provider-rate`, until the window frees).
348
+ * Run it from a scheduler task or a moderator action on the Devvit server,
349
+ * never from a player's request: run the `pending` users again until none
350
+ * are left, then the `next` page.
351
+ */
352
+ export declare function runDevvitNotificationBulkPage(input: RunDevvitNotificationBulkPageInput): Promise<DevvitNotificationBulkPageResult>;
@@ -3,6 +3,13 @@ import { createNotificationProviderRegistry, createNotificationService, createNo
3
3
  import { referenceNotificationProviders, } from '@mpgd/platform/notifications';
4
4
  import { devvitNotificationBodyMaxLength, devvitNotificationLinkPattern, devvitNotificationProviderId, devvitNotificationsPerAppPerDay, devvitNotificationsPerUserPerDay, devvitNotificationTarget, devvitNotificationTextLength, devvitNotificationTitleMaxLength, devvitUserIdPattern, } from '../notifications-contract.js';
5
5
  import { createDevvitRedisNotificationStore, } from './redis-notification-store.js';
6
+ /**
7
+ * Recipients one `enqueue` call takes (`EnqueueOptions['recipients']`: "Max
8
+ * number of recipients is 1000" in `@devvit/notifications` 0.15.0), and
9
+ * users one `listOptedInUsers` page answers ("The default and maximum limit
10
+ * is 1,000" in the 0.15 push notification guide).
11
+ */
12
+ export const devvitNotificationEnqueueMaxRecipients = 1_000;
6
13
  /** Provider codes of the answers this provider gives without calling `enqueue`. */
7
14
  export const devvitNotificationProviderCodes = Object.freeze({
8
15
  deviceRecipient: 'DEVVIT_DEVICE_RECIPIENT',
@@ -16,6 +23,8 @@ export const devvitNotificationProviderCodes = Object.freeze({
16
23
  enqueueRejected: 'DEVVIT_ENQUEUE_REJECTED',
17
24
  enqueueUnanswered: 'DEVVIT_ENQUEUE_UNANSWERED',
18
25
  enqueueUnclassified: 'DEVVIT_ENQUEUE_UNCLASSIFIED',
26
+ /** A bulk `enqueue` counted some of the unnamed recipients as sent and some not, without naming them. */
27
+ enqueuePartial: 'DEVVIT_ENQUEUE_PARTIAL',
19
28
  });
20
29
  /**
21
30
  * Whether a message reads as a rate limit: per user (2 a day) or per app
@@ -131,86 +140,240 @@ function reasonOf(answer) {
131
140
  export function createDevvitNotificationDeliveryProvider(input) {
132
141
  const userIdOf = input.userIdOf ?? ((player) => player);
133
142
  const rejected = (reason, providerCode) => ({ status: 'rejected', reason, providerCode });
143
+ /** The text, link, and recipient one send needs, or the answer that refuses it without a call. */
144
+ const precheck = (request) => {
145
+ if (request.recipient.kind !== 'platform-user') {
146
+ return { answer: rejected('configuration', devvitNotificationProviderCodes.deviceRecipient) };
147
+ }
148
+ const userId = request.recipient.id;
149
+ if (!devvitUserIdPattern.test(userId)) {
150
+ return { answer: rejected('recipient', devvitNotificationProviderCodes.recipientInvalid) };
151
+ }
152
+ const link = request.link;
153
+ if (link === undefined || !devvitNotificationLinkPattern.test(link)) {
154
+ return { answer: rejected('configuration', devvitNotificationProviderCodes.linkInvalid) };
155
+ }
156
+ const { title, body } = request.content;
157
+ if (title === undefined || body === undefined || title.trim() === '' || body.trim() === '') {
158
+ // A host template or an empty text: Devvit sends the title and body it is given.
159
+ return { answer: rejected('configuration', devvitNotificationProviderCodes.textMissing) };
160
+ }
161
+ if (devvitNotificationTextLength(title) > devvitNotificationTitleMaxLength) {
162
+ return { answer: rejected('configuration', devvitNotificationProviderCodes.titleTooLong) };
163
+ }
164
+ if (devvitNotificationTextLength(body) > devvitNotificationBodyMaxLength) {
165
+ return { answer: rejected('configuration', devvitNotificationProviderCodes.bodyTooLong) };
166
+ }
167
+ if (title.includes('{{') || body.includes('{{')) {
168
+ // From template data or from a catalog brace outside a `{{name}}`
169
+ // placeholder: either way the operator should see it.
170
+ return { answer: rejected('configuration', devvitNotificationProviderCodes.templateSyntax) };
171
+ }
172
+ return { userId, link, title, body };
173
+ };
174
+ /** Tells `onEnqueueError` about a failure; never changes the answer. */
175
+ const report = (sendKey, source, message, answer) => {
176
+ try {
177
+ input.onEnqueueError?.({
178
+ sendKey,
179
+ source,
180
+ message,
181
+ answer: answer.status,
182
+ ...reasonOf(answer),
183
+ });
184
+ }
185
+ catch {
186
+ // A failing log never changes the answer.
187
+ }
188
+ return answer;
189
+ };
190
+ /** The answer for an `enqueue` failure after `classifyEnqueueError`, reported. */
191
+ const failure = (sendKey, source, message, fallback) => {
192
+ let kind = fallback;
193
+ let note = '';
194
+ try {
195
+ kind = input.classifyEnqueueError?.(message, source) ?? fallback;
196
+ }
197
+ catch (error) {
198
+ // A failing classifier keeps the default answer; the report says so.
199
+ note = ` (classifyEnqueueError threw: ${thrownMessage(error)})`;
200
+ }
201
+ // The provider code follows the final answer, after any override; a
202
+ // thrown call keeps DEVVIT_ENQUEUE_FAILED, which names its source.
203
+ let providerCode = devvitNotificationProviderCodes.enqueueRejected;
204
+ if (source === 'thrown') {
205
+ providerCode = devvitNotificationProviderCodes.enqueueFailed;
206
+ }
207
+ else if (kind === 'ambiguous') {
208
+ providerCode = devvitNotificationProviderCodes.enqueueUnclassified;
209
+ }
210
+ let answer;
211
+ if (kind === 'rate-limited') {
212
+ // A daily limit lifts no sooner than an hour from now; any other
213
+ // waits the service's default first delay.
214
+ answer = dailyLimitWords.test(message)
215
+ ? { status: 'rate-limited', retryAfterSeconds: 3600 }
216
+ : { status: 'rate-limited' };
217
+ }
218
+ else if (kind === 'ambiguous') {
219
+ answer = { status: 'ambiguous', providerCode };
220
+ }
221
+ else {
222
+ answer = rejected(kind === 'recipient' ? 'recipient' : 'configuration', providerCode);
223
+ }
224
+ return report(sendKey, source, `${message}${note}`, answer);
225
+ };
226
+ /** The default answer for an error message: a rate limit, a text refusal, or, named for the user, their refusal. */
227
+ const fallbackOf = (message, named) => {
228
+ if (readsAsRateLimit(message)) {
229
+ return 'rate-limited';
230
+ }
231
+ if (readsAsSizeOrTextRefusal(message)) {
232
+ return 'configuration';
233
+ }
234
+ // Only an error that names this recipient proves a refusal of the user.
235
+ return named && readsAsUserRefusal(message) ? 'recipient' : 'ambiguous';
236
+ };
134
237
  return {
135
238
  id: devvitNotificationProviderId,
136
- async send(request) {
137
- if (request.recipient.kind !== 'platform-user') {
138
- return rejected('configuration', devvitNotificationProviderCodes.deviceRecipient);
139
- }
140
- const userId = request.recipient.id;
141
- if (!devvitUserIdPattern.test(userId)) {
142
- return rejected('recipient', devvitNotificationProviderCodes.recipientInvalid);
143
- }
144
- const link = request.link;
145
- if (link === undefined || !devvitNotificationLinkPattern.test(link)) {
146
- return rejected('configuration', devvitNotificationProviderCodes.linkInvalid);
147
- }
148
- const { title, body } = request.content;
149
- if (title === undefined || body === undefined || title.trim() === '' || body.trim() === '') {
150
- // A host template or an empty text: Devvit sends the title and body it is given.
151
- return rejected('configuration', devvitNotificationProviderCodes.textMissing);
152
- }
153
- if (devvitNotificationTextLength(title) > devvitNotificationTitleMaxLength) {
154
- return rejected('configuration', devvitNotificationProviderCodes.titleTooLong);
155
- }
156
- if (devvitNotificationTextLength(body) > devvitNotificationBodyMaxLength) {
157
- return rejected('configuration', devvitNotificationProviderCodes.bodyTooLong);
158
- }
159
- if (title.includes('{{') || body.includes('{{')) {
160
- // From template data or from a catalog brace outside a `{{name}}`
161
- // placeholder: either way the operator should see it.
162
- return rejected('configuration', devvitNotificationProviderCodes.templateSyntax);
163
- }
164
- const report = (source, message, answer) => {
165
- try {
166
- input.onEnqueueError?.({
167
- sendKey: request.sendKey,
168
- source,
169
- message,
170
- answer: answer.status,
171
- ...reasonOf(answer),
172
- });
239
+ bulk: {
240
+ maxRecipients: devvitNotificationEnqueueMaxRecipients,
241
+ perRecipientOutcomes: true,
242
+ // One title and body an `enqueue`: the service calls once per text, each just after its gate check.
243
+ callKey: (request) => JSON.stringify([request.content.title ?? '', request.content.body ?? '']),
244
+ },
245
+ /**
246
+ * One `enqueue` per distinct title and body (Devvit takes one title and
247
+ * body a call, with a link per recipient), each user once a call. Each
248
+ * recipient's answer comes from its own error, or from the counts:
249
+ * - an error that names the recipient classifies as for `send`;
250
+ * - the recipients no error names are `accepted` only when
251
+ * `successCount` equals their number. With `successCount` 0 none of
252
+ * them was queued, and the errors that name no user decide as for
253
+ * `send` when they agree and `failureCount` counts every recipient of
254
+ * the call (else `ambiguous`, replayed: nothing was queued). Any other count means some were queued and some not,
255
+ * without saying which: `ambiguous` with `unattributed` (code
256
+ * `DEVVIT_ENQUEUE_PARTIAL`), not replayed, since `enqueue` takes no
257
+ * idempotency key and a replay would notify the queued ones again.
258
+ * - A call that throws is `ambiguous` for each of its recipients.
259
+ * - Past `startWithinMs` no further call starts: its recipients are
260
+ * `not-sent` and are replayed shortly.
261
+ */
262
+ async sendBatch(requests, options = {}) {
263
+ const startedAt = globalThis.performance.now();
264
+ const answers = requests.map(() => undefined);
265
+ const calls = [];
266
+ requests.forEach((request, index) => {
267
+ const checked = precheck(request);
268
+ if ('answer' in checked) {
269
+ answers[index] = checked.answer;
270
+ return;
271
+ }
272
+ // The first call of this text that does not have the user yet.
273
+ let call = calls.find((candidate) => candidate.title === checked.title
274
+ && candidate.body === checked.body
275
+ && candidate.members.length < devvitNotificationEnqueueMaxRecipients
276
+ && !candidate.members.some((member) => member.userId === checked.userId));
277
+ if (call === undefined) {
278
+ call = { title: checked.title, body: checked.body, members: [] };
279
+ calls.push(call);
173
280
  }
174
- catch {
175
- // A failing log never changes the answer.
281
+ call.members.push({ index, userId: checked.userId, link: checked.link });
282
+ });
283
+ for (const call of calls) {
284
+ const keyOf = (index) => requests[index]?.sendKey ?? '';
285
+ if (options.startWithinMs !== undefined
286
+ && globalThis.performance.now() - startedAt >= options.startWithinMs) {
287
+ // Too late to start another call within the recipients' claims: nothing was sent to them.
288
+ for (const member of call.members) {
289
+ answers[member.index] = { status: 'not-sent' };
290
+ }
291
+ continue;
176
292
  }
177
- return answer;
178
- };
179
- const failure = (source, message, fallback) => {
180
- let kind = fallback;
181
- let note = '';
293
+ let response;
182
294
  try {
183
- kind = input.classifyEnqueueError?.(message, source) ?? fallback;
295
+ response = await input.notifications.enqueue({
296
+ title: call.title,
297
+ body: call.body,
298
+ recipients: call.members.map((member) => ({
299
+ userId: member.userId,
300
+ link: member.link,
301
+ data: {},
302
+ })),
303
+ });
184
304
  }
185
305
  catch (error) {
186
- // A failing classifier keeps the default answer; the report says so.
187
- note = ` (classifyEnqueueError threw: ${thrownMessage(error)})`;
306
+ const message = thrownMessage(error);
307
+ for (const member of call.members) {
308
+ answers[member.index] = failure(keyOf(member.index), 'thrown', message, 'ambiguous');
309
+ }
310
+ continue;
188
311
  }
189
- // The provider code follows the final answer, after any override; a
190
- // thrown call keeps DEVVIT_ENQUEUE_FAILED, which names its source.
191
- let providerCode = devvitNotificationProviderCodes.enqueueRejected;
192
- if (source === 'thrown') {
193
- providerCode = devvitNotificationProviderCodes.enqueueFailed;
312
+ const errors = Array.isArray(response?.errors) ? response.errors : [];
313
+ const users = new Set(call.members.map((member) => member.userId));
314
+ const unnamed = errors.filter((error) => typeof error?.userId !== 'string' || !users.has(error.userId));
315
+ const rest = [];
316
+ for (const member of call.members) {
317
+ const ours = errors.find((error) => error?.userId === member.userId);
318
+ if (ours === undefined) {
319
+ rest.push(member);
320
+ continue;
321
+ }
322
+ const message = typeof ours.message === 'string' ? ours.message : '';
323
+ answers[member.index] = failure(keyOf(member.index), 'answer', message, fallbackOf(message, true));
194
324
  }
195
- else if (kind === 'ambiguous') {
196
- providerCode = devvitNotificationProviderCodes.enqueueUnclassified;
325
+ if (rest.length === 0) {
326
+ continue;
197
327
  }
198
- let answer;
199
- if (kind === 'rate-limited') {
200
- // A daily limit lifts no sooner than an hour from now; any other
201
- // waits the service's default first delay.
202
- answer = dailyLimitWords.test(message)
203
- ? { status: 'rate-limited', retryAfterSeconds: 3600 }
204
- : { status: 'rate-limited' };
328
+ const succeeded = response?.successCount;
329
+ if (typeof succeeded === 'number' && succeeded === rest.length) {
330
+ for (const member of rest) {
331
+ answers[member.index] = { status: 'accepted' };
332
+ }
333
+ continue;
205
334
  }
206
- else if (kind === 'ambiguous') {
207
- answer = { status: 'ambiguous', providerCode };
335
+ if (succeeded === 0) {
336
+ const messages = unnamed.map((error) => (typeof error?.message === 'string' ? error.message : ''));
337
+ const kinds = new Set(messages.map((message) => fallbackOf(message, false)));
338
+ const failed = typeof response?.failureCount === 'number' && response.failureCount > call.members.length - rest.length;
339
+ // Unnamed errors speak for every unnamed recipient only when Devvit
340
+ // counted a failure for each recipient of the call.
341
+ const accounted = typeof response?.failureCount === 'number' && response.failureCount >= call.members.length;
342
+ for (const member of rest) {
343
+ if (messages.length > 0 && !accounted) {
344
+ answers[member.index] = report(keyOf(member.index), 'answer', `${messages[0] ?? ''} (failureCount ${String(response?.failureCount)} for ${call.members.length} recipients)`, { status: 'ambiguous', providerCode: devvitNotificationProviderCodes.enqueueUnclassified });
345
+ }
346
+ else if (messages.length > 0) {
347
+ // Errors that agree decide for every unnamed recipient; errors that differ prove nothing for any one.
348
+ answers[member.index] = failure(keyOf(member.index), 'answer', messages[0] ?? '', kinds.size === 1 ? ([...kinds][0] ?? 'ambiguous') : 'ambiguous');
349
+ }
350
+ else if (failed) {
351
+ answers[member.index] = failure(keyOf(member.index), 'answer', '', 'ambiguous');
352
+ }
353
+ else {
354
+ // Neither queued nor refused: Devvit may still have queued it.
355
+ answers[member.index] = report(keyOf(member.index), 'answer', '(no success and no error in the answer)', {
356
+ status: 'ambiguous',
357
+ providerCode: devvitNotificationProviderCodes.enqueueUnanswered,
358
+ });
359
+ }
360
+ }
361
+ continue;
208
362
  }
209
- else {
210
- answer = rejected(kind === 'recipient' ? 'recipient' : 'configuration', providerCode);
363
+ for (const member of rest) {
364
+ answers[member.index] = report(keyOf(member.index), 'answer', `(successCount ${String(succeeded)} for ${rest.length} recipients no error names)`, typeof succeeded === 'number'
365
+ ? { status: 'ambiguous', providerCode: devvitNotificationProviderCodes.enqueuePartial, unattributed: true }
366
+ : { status: 'ambiguous', providerCode: devvitNotificationProviderCodes.enqueueUnanswered });
211
367
  }
212
- return report(source, `${message}${note}`, answer);
213
- };
368
+ }
369
+ return answers.map((answer) => answer ?? { status: 'ambiguous' });
370
+ },
371
+ async send(request) {
372
+ const checked = precheck(request);
373
+ if ('answer' in checked) {
374
+ return checked.answer;
375
+ }
376
+ const { userId, link, title, body } = checked;
214
377
  let response;
215
378
  try {
216
379
  response = await input.notifications.enqueue({
@@ -224,7 +387,7 @@ export function createDevvitNotificationDeliveryProvider(input) {
224
387
  });
225
388
  }
226
389
  catch (error) {
227
- return failure('thrown', thrownMessage(error), 'ambiguous');
390
+ return failure(request.sendKey, 'thrown', thrownMessage(error), 'ambiguous');
228
391
  }
229
392
  if (typeof response?.successCount === 'number' && response.successCount > 0) {
230
393
  return { status: 'accepted' };
@@ -236,7 +399,7 @@ export function createDevvitNotificationDeliveryProvider(input) {
236
399
  if (ours === undefined && !failed) {
237
400
  // Neither queued nor refused: Devvit may still have queued it. No
238
401
  // error exists to classify, so the answer stays ambiguous.
239
- return report('answer', '(no success and no error in the answer)', {
402
+ return report(request.sendKey, 'answer', '(no success and no error in the answer)', {
240
403
  status: 'ambiguous',
241
404
  providerCode: devvitNotificationProviderCodes.enqueueUnanswered,
242
405
  });
@@ -253,7 +416,7 @@ export function createDevvitNotificationDeliveryProvider(input) {
253
416
  // Only an error that names this recipient proves a refusal of the user.
254
417
  fallback = 'recipient';
255
418
  }
256
- return failure('answer', message, fallback);
419
+ return failure(request.sendKey, 'answer', message, fallback);
257
420
  },
258
421
  async readPermission({ player }) {
259
422
  const userId = userIdOf(player);
@@ -469,3 +632,69 @@ export async function runDevvitNotificationTask(service, task, options = {}) {
469
632
  const counts = await service.purge();
470
633
  return { task, ...counts };
471
634
  }
635
+ /**
636
+ * Sends one page of `listOptedInUsers` through `deliverBulk` (RFC open
637
+ * question 5): Devvit's opt-in list only picks the audience. The kit's gate
638
+ * still decides each user: it reads `isOptedIn` again at send time and the
639
+ * kit's own records (topic preference, frequency, quiet hours), so a user
640
+ * opted in on Devvit whom the gate refuses is not sent to. Devvit's limits
641
+ * hold per user (2 a day, the entry's provider limit, which suppresses the
642
+ * excess) and per app (25,000 a day, the entry's `rate`: users past it are
643
+ * `pending`, `not-attempted` with `provider-rate`, until the window frees).
644
+ * Run it from a scheduler task or a moderator action on the Devvit server,
645
+ * never from a player's request: run the `pending` users again until none
646
+ * are left, then the `next` page.
647
+ */
648
+ export async function runDevvitNotificationBulkPage(input) {
649
+ const limit = input.limit ?? devvitNotificationEnqueueMaxRecipients;
650
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > devvitNotificationEnqueueMaxRecipients) {
651
+ throw new RangeError(`limit must be an integer from 1 to ${devvitNotificationEnqueueMaxRecipients}.`);
652
+ }
653
+ let userIds;
654
+ let next;
655
+ if (input.pending === undefined) {
656
+ const list = input.notifications.listOptedInUsers;
657
+ if (list === undefined) {
658
+ throw new TypeError('The Devvit notifications client has no listOptedInUsers.');
659
+ }
660
+ const page = await list.call(input.notifications, {
661
+ limit,
662
+ ...(input.after === undefined ? {} : { after: input.after }),
663
+ });
664
+ userIds = Array.isArray(page?.userIds) ? page.userIds : [];
665
+ next = typeof page?.next === 'string' && page.next.length > 0 && userIds.length > 0
666
+ ? page.next
667
+ : undefined;
668
+ }
669
+ else {
670
+ userIds = input.pending.slice(0, limit);
671
+ }
672
+ const players = [
673
+ ...new Set(userIds.filter((id) => typeof id === 'string' && devvitUserIdPattern.test(id))),
674
+ ];
675
+ const left = input.pending === undefined ? [] : input.pending.slice(limit);
676
+ if (players.length === 0) {
677
+ return { ...(next === undefined ? {} : { next }), pending: left, complete: left.length === 0 };
678
+ }
679
+ const result = await input.service.deliverBulk({
680
+ idempotencyKey: input.idempotencyKey,
681
+ provider: devvitNotificationProviderId,
682
+ topic: input.topic,
683
+ recipients: players.map((player) => ({ player })),
684
+ ...(input.link === undefined ? {} : { link: input.link }),
685
+ ...(input.templateData === undefined ? {} : { templateData: input.templateData }),
686
+ }, input.options);
687
+ const pending = [
688
+ ...result.recipients
689
+ .filter((recipient) => recipient.status === 'not-attempted'
690
+ || (recipient.status === 'failed' && recipient.error.retryable))
691
+ .map((recipient) => recipient.player),
692
+ ...left,
693
+ ];
694
+ return {
695
+ result,
696
+ ...(next === undefined ? {} : { next }),
697
+ pending,
698
+ complete: pending.length === 0,
699
+ };
700
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/adapter-devvit",
3
- "version": "0.14.12",
3
+ "version": "0.15.0",
4
4
  "description": "Reddit Devvit Web platform adapter for mpgd games.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -55,9 +55,9 @@
55
55
  ],
56
56
  "dependencies": {
57
57
  "@devvit/web": "0.15.0",
58
- "@mpgd/platform": "0.35.1",
59
- "@mpgd/game-services": "0.34.0",
60
- "@mpgd/bridge": "0.19.0"
58
+ "@mpgd/platform": "0.36.0",
59
+ "@mpgd/game-services": "0.35.0",
60
+ "@mpgd/bridge": "0.19.1"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/node": "^24.0.0",