@mpgd/adapter-devvit 0.14.11 → 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.
@@ -1,4 +1,4 @@
1
- import type { BridgeNotificationsBackendMethod } from '@mpgd/bridge';
1
+ import { type BridgeNotificationsBackendMethod } from '@mpgd/bridge';
2
2
  import type { NotificationRuntimeBackend, NotificationRuntimeSession } from '@mpgd/platform/notifications-runtime';
3
3
  /**
4
4
  * The webview side of the notifications runtime's backend on Devvit
@@ -1,3 +1,4 @@
1
+ import { isThrownInstance, readThrownField, } from '@mpgd/bridge';
1
2
  import { assertBridgeNotificationsBackendPayload, assertBridgeNotificationsBackendResult, notificationsBackendBridgeHostCodes, readBridgeNotificationsHostCode, } from '@mpgd/bridge/notifications';
2
3
  import { isNotificationErrorCode, NotificationError, notificationsProtocol, } from '@mpgd/platform/notifications';
3
4
  /** The default `timeoutMs`: a request still unanswered after this is no longer awaited. */
@@ -279,12 +280,15 @@ export function createDevvitNotificationsBackendClient(input) {
279
280
  };
280
281
  }
281
282
  function toNotificationError(method, error) {
282
- if (error instanceof NotificationError) {
283
+ if (isThrownInstance(error, NotificationError)) {
283
284
  return error;
284
285
  }
285
- const failure = typeof error === 'object' && error !== null
286
- ? error
287
- : {};
286
+ // Each field read once; one whose getter or Proxy trap throws counts as missing.
287
+ const failure = {
288
+ code: readThrownField(error, 'code'),
289
+ message: readThrownField(error, 'message'),
290
+ retryable: readThrownField(error, 'retryable'),
291
+ };
288
292
  const message = typeof failure.message === 'string' ? failure.message : undefined;
289
293
  if (backendUnavailableCodes.has(failure.code)) {
290
294
  return new NotificationError('NOTIFICATIONS_CONFIGURATION_REQUIRED', {
@@ -1,4 +1,4 @@
1
- import type { BridgeNotificationsV2Method } from '@mpgd/bridge';
1
+ import { type BridgeNotificationsV2Method } from '@mpgd/bridge';
2
2
  import type { NotificationSubscriptionAdapter } from '@mpgd/platform';
3
3
  import { type NotificationProvider } from '@mpgd/platform/notifications';
4
4
  /**
@@ -1,3 +1,4 @@
1
+ import { isThrownInstance, readThrownField } from '@mpgd/bridge';
1
2
  import { assertBridgeNotificationsV2Result, readBridgeNotificationsHostCode, } from '@mpgd/bridge/notifications';
2
3
  import { isNotificationErrorCode, isValidNotificationTimeZone, NotificationError, notificationsProtocol, toNotificationSubscriptionAdapter, } from '@mpgd/platform/notifications';
3
4
  import { devvitNotificationProviderId } from './notifications-contract.js';
@@ -109,12 +110,15 @@ function defaultLocale() {
109
110
  return typeof navigatorLike?.language === 'string' ? navigatorLike.language : undefined;
110
111
  }
111
112
  function toNotificationError(error) {
112
- if (error instanceof NotificationError) {
113
+ if (isThrownInstance(error, NotificationError)) {
113
114
  return error;
114
115
  }
115
- const failure = typeof error === 'object' && error !== null
116
- ? error
117
- : {};
116
+ // Each field read once; one whose getter or Proxy trap throws counts as missing.
117
+ const failure = {
118
+ code: readThrownField(error, 'code'),
119
+ message: readThrownField(error, 'message'),
120
+ retryable: readThrownField(error, 'retryable'),
121
+ };
118
122
  const message = typeof failure.message === 'string' ? failure.message : undefined;
119
123
  const explicit = typeof failure.retryable === 'boolean' ? { retryable: failure.retryable } : {};
120
124
  if (isNotificationErrorCode(failure.code)) {
@@ -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 { BridgeRequest, BridgeResponse } from '@mpgd/bridge';
1
+ import { type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
2
2
  import { type NotificationService } from '@mpgd/game-services/notifications';
3
3
  import { type DevvitNotificationSessionContext } from './notifications-delivery.js';
4
4
  /**
@@ -1,3 +1,4 @@
1
+ import { isThrownInstance } from '@mpgd/bridge';
1
2
  import { createNotificationsBackendBridgeHandler } from '@mpgd/bridge/notifications';
2
3
  import { NotificationError } from '@mpgd/platform/notifications';
3
4
  import { NotificationConsentRateLimitError, } from '@mpgd/game-services/notifications';
@@ -65,8 +66,8 @@ function warnBackendFailure(error, context) {
65
66
  // A throttled consent write is the player's own cap, and an input the
66
67
  // service refuses is the caller's: neither is a server failure, and a
67
68
  // webview could otherwise fill the log with them.
68
- if (error instanceof NotificationConsentRateLimitError
69
- || (error instanceof NotificationError && error.code === 'NOTIFICATIONS_INVALID_INPUT')) {
69
+ if (isThrownInstance(error, NotificationConsentRateLimitError)
70
+ || (isThrownInstance(error, NotificationError) && error.code === 'NOTIFICATIONS_INVALID_INPUT')) {
70
71
  return;
71
72
  }
72
73
  console.warn(`devvit notifications ${context.method} failed`, error);
@@ -1,4 +1,4 @@
1
- import type { BridgeRequest, BridgeResponse } from '@mpgd/bridge';
1
+ import { type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
2
2
  import { type NotificationProvider } from '@mpgd/platform/notifications';
3
3
  /**
4
4
  * The game-rendered consent of the Devvit profile (notifications v2 RFC,
@@ -1,3 +1,4 @@
1
+ import { isThrownInstance } from '@mpgd/bridge';
1
2
  import { createNotificationsV2BridgeHandler } from '@mpgd/bridge/notifications';
2
3
  import { NotificationError, } from '@mpgd/platform/notifications';
3
4
  import { createDevvitNotificationCapabilities, devvitNotificationProviderId, devvitUserIdPattern, } from '../notifications-contract.js';
@@ -177,7 +178,7 @@ export function createDevvitNotificationsV2BridgeHandler(input) {
177
178
  // An unexpected failure's text stays on the server.
178
179
  unexpectedFailureMessage: (method) => `${method} failed on the Devvit server.`,
179
180
  onError: input.onError ?? ((error, context) => {
180
- if (error instanceof NotificationError && error.code === 'NOTIFICATIONS_UNSUPPORTED') {
181
+ if (isThrownInstance(error, NotificationError) && error.code === 'NOTIFICATIONS_UNSUPPORTED') {
181
182
  return;
182
183
  }
183
184
  console.warn(`devvit notifications ${context.method} failed`, error);
@@ -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>;
@@ -1,7 +1,15 @@
1
+ import { thrownMessage } from '@mpgd/bridge';
1
2
  import { createNotificationProviderRegistry, createNotificationService, createNotificationServiceCatalog, } from '@mpgd/game-services/notifications';
2
3
  import { referenceNotificationProviders, } from '@mpgd/platform/notifications';
3
4
  import { devvitNotificationBodyMaxLength, devvitNotificationLinkPattern, devvitNotificationProviderId, devvitNotificationsPerAppPerDay, devvitNotificationsPerUserPerDay, devvitNotificationTarget, devvitNotificationTextLength, devvitNotificationTitleMaxLength, devvitUserIdPattern, } from '../notifications-contract.js';
4
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;
5
13
  /** Provider codes of the answers this provider gives without calling `enqueue`. */
6
14
  export const devvitNotificationProviderCodes = Object.freeze({
7
15
  deviceRecipient: 'DEVVIT_DEVICE_RECIPIENT',
@@ -15,6 +23,8 @@ export const devvitNotificationProviderCodes = Object.freeze({
15
23
  enqueueRejected: 'DEVVIT_ENQUEUE_REJECTED',
16
24
  enqueueUnanswered: 'DEVVIT_ENQUEUE_UNANSWERED',
17
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',
18
28
  });
19
29
  /**
20
30
  * Whether a message reads as a rate limit: per user (2 a day) or per app
@@ -130,86 +140,240 @@ function reasonOf(answer) {
130
140
  export function createDevvitNotificationDeliveryProvider(input) {
131
141
  const userIdOf = input.userIdOf ?? ((player) => player);
132
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
+ };
133
237
  return {
134
238
  id: devvitNotificationProviderId,
135
- async send(request) {
136
- if (request.recipient.kind !== 'platform-user') {
137
- return rejected('configuration', devvitNotificationProviderCodes.deviceRecipient);
138
- }
139
- const userId = request.recipient.id;
140
- if (!devvitUserIdPattern.test(userId)) {
141
- return rejected('recipient', devvitNotificationProviderCodes.recipientInvalid);
142
- }
143
- const link = request.link;
144
- if (link === undefined || !devvitNotificationLinkPattern.test(link)) {
145
- return rejected('configuration', devvitNotificationProviderCodes.linkInvalid);
146
- }
147
- const { title, body } = request.content;
148
- if (title === undefined || body === undefined || title.trim() === '' || body.trim() === '') {
149
- // A host template or an empty text: Devvit sends the title and body it is given.
150
- return rejected('configuration', devvitNotificationProviderCodes.textMissing);
151
- }
152
- if (devvitNotificationTextLength(title) > devvitNotificationTitleMaxLength) {
153
- return rejected('configuration', devvitNotificationProviderCodes.titleTooLong);
154
- }
155
- if (devvitNotificationTextLength(body) > devvitNotificationBodyMaxLength) {
156
- return rejected('configuration', devvitNotificationProviderCodes.bodyTooLong);
157
- }
158
- if (title.includes('{{') || body.includes('{{')) {
159
- // From template data or from a catalog brace outside a `{{name}}`
160
- // placeholder: either way the operator should see it.
161
- return rejected('configuration', devvitNotificationProviderCodes.templateSyntax);
162
- }
163
- const report = (source, message, answer) => {
164
- try {
165
- input.onEnqueueError?.({
166
- sendKey: request.sendKey,
167
- source,
168
- message,
169
- answer: answer.status,
170
- ...reasonOf(answer),
171
- });
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);
172
280
  }
173
- catch {
174
- // 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;
175
292
  }
176
- return answer;
177
- };
178
- const failure = (source, message, fallback) => {
179
- let kind = fallback;
180
- let note = '';
293
+ let response;
181
294
  try {
182
- 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
+ });
183
304
  }
184
305
  catch (error) {
185
- // A failing classifier keeps the default answer; the report says so.
186
- note = ` (classifyEnqueueError threw: ${error instanceof Error ? error.message : String(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;
187
311
  }
188
- // The provider code follows the final answer, after any override; a
189
- // thrown call keeps DEVVIT_ENQUEUE_FAILED, which names its source.
190
- let providerCode = devvitNotificationProviderCodes.enqueueRejected;
191
- if (source === 'thrown') {
192
- 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));
193
324
  }
194
- else if (kind === 'ambiguous') {
195
- providerCode = devvitNotificationProviderCodes.enqueueUnclassified;
325
+ if (rest.length === 0) {
326
+ continue;
196
327
  }
197
- let answer;
198
- if (kind === 'rate-limited') {
199
- // A daily limit lifts no sooner than an hour from now; any other
200
- // waits the service's default first delay.
201
- answer = dailyLimitWords.test(message)
202
- ? { status: 'rate-limited', retryAfterSeconds: 3600 }
203
- : { 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;
204
334
  }
205
- else if (kind === 'ambiguous') {
206
- 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;
207
362
  }
208
- else {
209
- 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 });
210
367
  }
211
- return report(source, `${message}${note}`, answer);
212
- };
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;
213
377
  let response;
214
378
  try {
215
379
  response = await input.notifications.enqueue({
@@ -223,7 +387,7 @@ export function createDevvitNotificationDeliveryProvider(input) {
223
387
  });
224
388
  }
225
389
  catch (error) {
226
- return failure('thrown', error instanceof Error ? error.message : String(error), 'ambiguous');
390
+ return failure(request.sendKey, 'thrown', thrownMessage(error), 'ambiguous');
227
391
  }
228
392
  if (typeof response?.successCount === 'number' && response.successCount > 0) {
229
393
  return { status: 'accepted' };
@@ -235,7 +399,7 @@ export function createDevvitNotificationDeliveryProvider(input) {
235
399
  if (ours === undefined && !failed) {
236
400
  // Neither queued nor refused: Devvit may still have queued it. No
237
401
  // error exists to classify, so the answer stays ambiguous.
238
- 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)', {
239
403
  status: 'ambiguous',
240
404
  providerCode: devvitNotificationProviderCodes.enqueueUnanswered,
241
405
  });
@@ -252,7 +416,7 @@ export function createDevvitNotificationDeliveryProvider(input) {
252
416
  // Only an error that names this recipient proves a refusal of the user.
253
417
  fallback = 'recipient';
254
418
  }
255
- return failure('answer', message, fallback);
419
+ return failure(request.sendKey, 'answer', message, fallback);
256
420
  },
257
421
  async readPermission({ player }) {
258
422
  const userId = userIdOf(player);
@@ -468,3 +632,69 @@ export async function runDevvitNotificationTask(service, task, options = {}) {
468
632
  const counts = await service.purge();
469
633
  return { task, ...counts };
470
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
+ }
@@ -1,3 +1,4 @@
1
+ import { isThrownInstance, readThrownField } from '@mpgd/bridge';
1
2
  import { formatNotificationInstant, notificationSendDueAt, } from '@mpgd/game-services/notifications';
2
3
  import { createHash } from 'node:crypto';
3
4
  export const defaultDevvitNotificationNamespace = 'mpgd:notifications';
@@ -1084,7 +1085,9 @@ const permanentRedisError = /not allowed|allow-?list|unknown command|WRONGTYPE|u
1084
1085
  const connectionLoss = /\b(?:econnreset|econnrefused|socket hang up|temporarily unavailable|resource[_ ]exhausted|try again later)\b/iu;
1085
1086
  const transactionAbort = /\b(?:timed?\s*out|timeout|deadline exceeded)\b/iu;
1086
1087
  function redisThrowKind(error) {
1087
- if (error instanceof TypeError || error instanceof RangeError || error instanceof SyntaxError) {
1088
+ if (isThrownInstance(error, TypeError)
1089
+ || isThrownInstance(error, RangeError)
1090
+ || isThrownInstance(error, SyntaxError)) {
1088
1091
  return 'permanent';
1089
1092
  }
1090
1093
  const text = redisErrorText(error);
@@ -1110,8 +1113,8 @@ function redisErrorText(error) {
1110
1113
  if (typeof error !== 'object' || error === null) {
1111
1114
  return '';
1112
1115
  }
1113
- const record = error;
1114
- return [record.message, record.code, record.msg]
1116
+ // A field whose getter or Proxy trap throws counts as missing.
1117
+ return [readThrownField(error, 'message'), readThrownField(error, 'code'), readThrownField(error, 'msg')]
1115
1118
  .filter((part) => typeof part === 'string')
1116
1119
  .join(' ');
1117
1120
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/adapter-devvit",
3
- "version": "0.14.11",
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/bridge": "0.18.1",
59
- "@mpgd/game-services": "0.33.1",
60
- "@mpgd/platform": "0.35.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",