@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
|
-
*
|
|
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 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
};
|
|
179
|
-
const failure = (source, message, fallback) => {
|
|
180
|
-
let kind = fallback;
|
|
181
|
-
let note = '';
|
|
293
|
+
let response;
|
|
182
294
|
try {
|
|
183
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
196
|
-
|
|
325
|
+
if (rest.length === 0) {
|
|
326
|
+
continue;
|
|
197
327
|
}
|
|
198
|
-
|
|
199
|
-
if (
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
59
|
-
"@mpgd/game-services": "0.
|
|
60
|
-
"@mpgd/bridge": "0.19.
|
|
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",
|