@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.
- package/dist/notifications-backend-client.d.ts +1 -1
- package/dist/notifications-backend-client.js +8 -4
- package/dist/notifications-client.d.ts +1 -1
- package/dist/notifications-client.js +8 -4
- package/dist/notifications-contract.d.ts +3 -2
- package/dist/notifications-contract.js +3 -2
- package/dist/server/notifications-backend-bridge.d.ts +1 -1
- package/dist/server/notifications-backend-bridge.js +3 -2
- package/dist/server/notifications-bridge.d.ts +1 -1
- package/dist/server/notifications-bridge.js +2 -1
- package/dist/server/notifications-delivery.d.ts +73 -1
- package/dist/server/notifications-delivery.js +300 -70
- package/dist/server/redis-notification-store.js +6 -3
- package/package.json +4 -4
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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
|
|
283
|
+
if (isThrownInstance(error, NotificationError)) {
|
|
283
284
|
return error;
|
|
284
285
|
}
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
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
|
|
113
|
+
if (isThrownInstance(error, NotificationError)) {
|
|
113
114
|
return error;
|
|
114
115
|
}
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
69
|
-
|| (error
|
|
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
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
};
|
|
178
|
-
const failure = (source, message, fallback) => {
|
|
179
|
-
let kind = fallback;
|
|
180
|
-
let note = '';
|
|
293
|
+
let response;
|
|
181
294
|
try {
|
|
182
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
195
|
-
|
|
325
|
+
if (rest.length === 0) {
|
|
326
|
+
continue;
|
|
196
327
|
}
|
|
197
|
-
|
|
198
|
-
if (
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
|
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
|
-
|
|
1114
|
-
return [
|
|
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.
|
|
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/
|
|
59
|
-
"@mpgd/game-services": "0.
|
|
60
|
-
"@mpgd/
|
|
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",
|