@takosjp/yurucommu-core 3.0.3 → 3.2.1
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/README.en.md +92 -0
- package/README.md +56 -47
- package/migrations/0019_notification_push_delivery.sql +103 -0
- package/migrations/README.md +7 -6
- package/package.json +11 -5
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/browser-push.ts +545 -0
- package/packages/api/src/lib/api/communities.ts +25 -2
- package/packages/api/src/lib/api/dm.ts +14 -2
- package/packages/api/src/lib/api/normalize.ts +15 -4
- package/packages/api/src/lib/api/notification-target.ts +106 -0
- package/packages/api/src/lib/api/notifications.ts +53 -1
- package/packages/api/src/lib/api/push-config.ts +132 -0
- package/packages/api/src/lib/api.ts +3 -0
- package/packages/api/src/social-server.ts +9 -0
- package/packages/api/src/types/index.ts +48 -0
- package/src/backend/index.ts +67 -3
- package/src/backend/lib/attachments.ts +52 -0
- package/src/backend/lib/delivery/queue.ts +73 -0
- package/src/backend/lib/delivery/types.ts +15 -1
- package/src/backend/lib/notification-eligibility.ts +150 -0
- package/src/backend/lib/notification-push.ts +1213 -0
- package/src/backend/lib/notification-pusher-contract.ts +340 -0
- package/src/backend/lib/oauth-providers.ts +7 -6
- package/src/backend/lib/session-actor.ts +16 -1
- package/src/backend/lib/unread-counts.ts +79 -0
- package/src/backend/middleware/csrf.ts +11 -0
- package/src/backend/routes/account-teardown.ts +13 -0
- package/src/backend/routes/auth-helpers.ts +10 -7
- package/src/backend/routes/auth.ts +124 -2
- package/src/backend/routes/communities/messages.ts +123 -9
- package/src/backend/routes/dm/contacts.ts +6 -44
- package/src/backend/routes/dm/messages.ts +51 -4
- package/src/backend/routes/notification-pushers.ts +93 -0
- package/src/backend/routes/notifications.ts +76 -69
- package/src/backend/routes/posts/transformers.ts +8 -10
- package/src/backend/server.ts +6 -0
- package/src/backend/types.ts +12 -0
- package/src/db/index.ts +11 -10
- package/src/db/schema/mobile.ts +99 -1
|
@@ -0,0 +1,1213 @@
|
|
|
1
|
+
import type { Message } from "@cloudflare/workers-types";
|
|
2
|
+
import { and, asc, eq, exists, inArray, lte, ne, sql } from "drizzle-orm";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
activities,
|
|
6
|
+
affectedRowCount,
|
|
7
|
+
communityMembers,
|
|
8
|
+
inbox,
|
|
9
|
+
notificationPushers,
|
|
10
|
+
notificationPushJobs,
|
|
11
|
+
objectRecipients,
|
|
12
|
+
objects,
|
|
13
|
+
type Database,
|
|
14
|
+
} from "../../db/index.ts";
|
|
15
|
+
import type { Actor, Env } from "../types.ts";
|
|
16
|
+
import {
|
|
17
|
+
isLoopbackGatewayUrl,
|
|
18
|
+
normalizeGatewayUrl,
|
|
19
|
+
type JsonObject,
|
|
20
|
+
type ParsedNotificationPusherDeleteRequest,
|
|
21
|
+
type ParsedNotificationPusherSetRequest,
|
|
22
|
+
type SocialNotificationProduct,
|
|
23
|
+
} from "./notification-pusher-contract.ts";
|
|
24
|
+
import {
|
|
25
|
+
DELIVERY_QUEUE_MESSAGE_VERSION,
|
|
26
|
+
type DeliveryNotificationPushMessageV1,
|
|
27
|
+
type DeliveryQueueMessageV1,
|
|
28
|
+
} from "./delivery/types.ts";
|
|
29
|
+
import { excludeBlockedMutedAuthors } from "./feed-exclude.ts";
|
|
30
|
+
import {
|
|
31
|
+
NOTIFICATION_ACTIVITY_TYPES,
|
|
32
|
+
notificationEligibilityWhere,
|
|
33
|
+
} from "./notification-eligibility.ts";
|
|
34
|
+
import { yurumeUnreadCounts } from "./unread-counts.ts";
|
|
35
|
+
import { logger } from "./logger.ts";
|
|
36
|
+
import { generateId } from "./oauth-utils.ts";
|
|
37
|
+
|
|
38
|
+
const log = logger.child({ component: "notification.push" });
|
|
39
|
+
|
|
40
|
+
export const MAX_NOTIFICATION_PUSHERS_PER_PRODUCT = 16;
|
|
41
|
+
export const MAX_NOTIFICATION_PUSHERS_PER_APP = 8;
|
|
42
|
+
export const MAX_NOTIFICATION_PUSH_DISPATCH = 16;
|
|
43
|
+
export const MAX_NOTIFICATION_PUSH_ATTEMPTS = 5;
|
|
44
|
+
export const NOTIFICATION_PUSHER_RETENTION_DAYS = 90;
|
|
45
|
+
export const NOTIFICATION_PUSH_JOB_RETENTION_DAYS = 90;
|
|
46
|
+
export const MAX_NOTIFICATION_PUSH_JOB_PURGE = 50;
|
|
47
|
+
export const MAX_NOTIFICATION_PUSHER_PURGE = 50;
|
|
48
|
+
|
|
49
|
+
const DEFAULT_GATEWAY_TIMEOUT_MS = 10_000;
|
|
50
|
+
const MAX_GATEWAY_RESPONSE_BYTES = 64 * 1024;
|
|
51
|
+
const MAX_QUEUE_SCAN = 50;
|
|
52
|
+
const STALE_PROCESSING_MS = 2 * 60 * 1000;
|
|
53
|
+
// A 'queued'/'processing' row whose queue message is GONE (auto-dead-lettered
|
|
54
|
+
// after max retries with the raw body, dropped after queue retention, consumer
|
|
55
|
+
// down long enough) is otherwise unreachable: the sweep sends only
|
|
56
|
+
// pending/retry_wait and message-based recovery needs a message. Reclaim such
|
|
57
|
+
// rows to 'pending' once they are far staler than any live in-flight state —
|
|
58
|
+
// a live processing owner refreshes its lease before every gateway call
|
|
59
|
+
// (≤ ~40s between touches) and a queued message is consumed or dead-lettered
|
|
60
|
+
// within minutes.
|
|
61
|
+
const STALE_INFLIGHT_RECLAIM_MS = 15 * 60 * 1000;
|
|
62
|
+
|
|
63
|
+
export interface NotificationPusherRegistrationResponse {
|
|
64
|
+
readonly id: string;
|
|
65
|
+
readonly kind: "http";
|
|
66
|
+
readonly app_id: string;
|
|
67
|
+
readonly app_display_name?: string;
|
|
68
|
+
readonly device_display_name?: string;
|
|
69
|
+
readonly profile_tag?: string;
|
|
70
|
+
readonly lang?: string;
|
|
71
|
+
readonly data: JsonObject;
|
|
72
|
+
readonly gateway_url: string;
|
|
73
|
+
readonly product: SocialNotificationProduct;
|
|
74
|
+
readonly scope: string | null;
|
|
75
|
+
readonly registered_at: string;
|
|
76
|
+
readonly last_seen_at: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type StoredPusher = {
|
|
80
|
+
id: string;
|
|
81
|
+
actorApId: string;
|
|
82
|
+
product: string;
|
|
83
|
+
appId: string;
|
|
84
|
+
pushkey: string;
|
|
85
|
+
pushkeyHash: string;
|
|
86
|
+
dataJson: string;
|
|
87
|
+
gatewayUrl: string;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
type GatewayResult = {
|
|
91
|
+
retryIds: string[];
|
|
92
|
+
retryAfterSeconds: number;
|
|
93
|
+
error: string | null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
type NotificationPushFormat = "event_id_only" | "full";
|
|
97
|
+
|
|
98
|
+
type GatewayDispatchGroup = {
|
|
99
|
+
gatewayUrl: string;
|
|
100
|
+
format: NotificationPushFormat;
|
|
101
|
+
pushers: StoredPusher[];
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
type ProcessingLease = {
|
|
105
|
+
jobId: string;
|
|
106
|
+
processingToken: string;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export function isNotificationGatewayAllowed(env: Env, value: string): boolean {
|
|
110
|
+
const normalized = normalizeGatewayUrl(value);
|
|
111
|
+
if (!normalized) return false;
|
|
112
|
+
if (isLoopbackGatewayUrl(normalized)) {
|
|
113
|
+
return isTruthy(env.YURUCOMMU_NOTIFICATION_PUSH_ALLOW_INSECURE_LOOPBACK);
|
|
114
|
+
}
|
|
115
|
+
const host = new URL(normalized).hostname.toLowerCase();
|
|
116
|
+
const allowed = new Set(
|
|
117
|
+
(env.YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_ALLOWED_HOSTS ?? "")
|
|
118
|
+
.split(",")
|
|
119
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
120
|
+
.filter(Boolean),
|
|
121
|
+
);
|
|
122
|
+
return allowed.has(host);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function registerNotificationPusher(
|
|
126
|
+
db: Database,
|
|
127
|
+
actor: Actor,
|
|
128
|
+
input: ParsedNotificationPusherSetRequest,
|
|
129
|
+
): Promise<NotificationPusherRegistrationResponse> {
|
|
130
|
+
const now = new Date().toISOString();
|
|
131
|
+
const pushkeyHash = await sha256Hex(input.pusher.pushkey);
|
|
132
|
+
|
|
133
|
+
// The device key is globally unique inside product+app. Reassigning that row
|
|
134
|
+
// is one atomic upsert, so concurrent logins cannot leave duplicate owners.
|
|
135
|
+
const existing = await db
|
|
136
|
+
.select({
|
|
137
|
+
id: notificationPushers.id,
|
|
138
|
+
actorApId: notificationPushers.actorApId,
|
|
139
|
+
})
|
|
140
|
+
.from(notificationPushers)
|
|
141
|
+
.where(
|
|
142
|
+
and(
|
|
143
|
+
eq(notificationPushers.product, input.product),
|
|
144
|
+
eq(notificationPushers.appId, input.pusher.app_id),
|
|
145
|
+
eq(notificationPushers.pushkeyHash, pushkeyHash),
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
.get();
|
|
149
|
+
|
|
150
|
+
const sameActor = existing?.actorApId === actor.ap_id;
|
|
151
|
+
if (!sameActor) {
|
|
152
|
+
await enforcePusherQuota(
|
|
153
|
+
db,
|
|
154
|
+
actor.ap_id,
|
|
155
|
+
input.product,
|
|
156
|
+
input.pusher.app_id,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const registrationId = sameActor ? existing.id : generateId(16);
|
|
160
|
+
|
|
161
|
+
await db
|
|
162
|
+
.insert(notificationPushers)
|
|
163
|
+
.values({
|
|
164
|
+
id: registrationId,
|
|
165
|
+
actorApId: actor.ap_id,
|
|
166
|
+
product: input.product,
|
|
167
|
+
scope: input.scope,
|
|
168
|
+
kind: "http",
|
|
169
|
+
appId: input.pusher.app_id,
|
|
170
|
+
pushkey: input.pusher.pushkey,
|
|
171
|
+
pushkeyHash,
|
|
172
|
+
appDisplayName: input.pusher.app_display_name ?? null,
|
|
173
|
+
deviceDisplayName: input.pusher.device_display_name ?? null,
|
|
174
|
+
profileTag: input.pusher.profile_tag ?? null,
|
|
175
|
+
lang: input.pusher.lang ?? null,
|
|
176
|
+
dataJson: JSON.stringify(input.storedData),
|
|
177
|
+
gatewayUrl: input.gatewayUrl,
|
|
178
|
+
createdAt: now,
|
|
179
|
+
updatedAt: now,
|
|
180
|
+
lastSeenAt: now,
|
|
181
|
+
})
|
|
182
|
+
.onConflictDoUpdate({
|
|
183
|
+
target: [
|
|
184
|
+
notificationPushers.product,
|
|
185
|
+
notificationPushers.appId,
|
|
186
|
+
notificationPushers.pushkeyHash,
|
|
187
|
+
],
|
|
188
|
+
set: {
|
|
189
|
+
id: registrationId,
|
|
190
|
+
actorApId: actor.ap_id,
|
|
191
|
+
kind: "http",
|
|
192
|
+
pushkey: input.pusher.pushkey,
|
|
193
|
+
scope: input.scope,
|
|
194
|
+
appDisplayName: input.pusher.app_display_name ?? null,
|
|
195
|
+
deviceDisplayName: input.pusher.device_display_name ?? null,
|
|
196
|
+
profileTag: input.pusher.profile_tag ?? null,
|
|
197
|
+
lang: input.pusher.lang ?? null,
|
|
198
|
+
dataJson: JSON.stringify(input.storedData),
|
|
199
|
+
gatewayUrl: input.gatewayUrl,
|
|
200
|
+
...(sameActor ? {} : { createdAt: now }),
|
|
201
|
+
updatedAt: now,
|
|
202
|
+
lastSeenAt: now,
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const row = await db
|
|
207
|
+
.select()
|
|
208
|
+
.from(notificationPushers)
|
|
209
|
+
.where(
|
|
210
|
+
and(
|
|
211
|
+
eq(notificationPushers.actorApId, actor.ap_id),
|
|
212
|
+
eq(notificationPushers.product, input.product),
|
|
213
|
+
eq(notificationPushers.appId, input.pusher.app_id),
|
|
214
|
+
eq(notificationPushers.pushkeyHash, pushkeyHash),
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
.get();
|
|
218
|
+
if (!row) throw new Error("Failed to register notification pusher");
|
|
219
|
+
return {
|
|
220
|
+
id: row.id,
|
|
221
|
+
kind: "http",
|
|
222
|
+
app_id: row.appId,
|
|
223
|
+
...(row.appDisplayName ? { app_display_name: row.appDisplayName } : {}),
|
|
224
|
+
...(row.deviceDisplayName
|
|
225
|
+
? { device_display_name: row.deviceDisplayName }
|
|
226
|
+
: {}),
|
|
227
|
+
...(row.profileTag ? { profile_tag: row.profileTag } : {}),
|
|
228
|
+
...(row.lang ? { lang: row.lang } : {}),
|
|
229
|
+
data: parseStoredData(row.dataJson),
|
|
230
|
+
gateway_url: row.gatewayUrl,
|
|
231
|
+
product: input.product,
|
|
232
|
+
scope: row.scope,
|
|
233
|
+
registered_at: row.createdAt,
|
|
234
|
+
last_seen_at: row.lastSeenAt,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function deleteNotificationPusher(
|
|
239
|
+
db: Database,
|
|
240
|
+
actor: Actor,
|
|
241
|
+
input: ParsedNotificationPusherDeleteRequest,
|
|
242
|
+
): Promise<void> {
|
|
243
|
+
const hash = await sha256Hex(input.pushkey);
|
|
244
|
+
await db
|
|
245
|
+
.delete(notificationPushers)
|
|
246
|
+
.where(
|
|
247
|
+
and(
|
|
248
|
+
eq(notificationPushers.actorApId, actor.ap_id),
|
|
249
|
+
eq(notificationPushers.product, input.product),
|
|
250
|
+
eq(notificationPushers.appId, input.appId),
|
|
251
|
+
eq(notificationPushers.pushkeyHash, hash),
|
|
252
|
+
eq(notificationPushers.pushkey, input.pushkey),
|
|
253
|
+
input.scope === null
|
|
254
|
+
? undefined
|
|
255
|
+
: eq(notificationPushers.scope, input.scope),
|
|
256
|
+
),
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function enforcePusherQuota(
|
|
261
|
+
db: Database,
|
|
262
|
+
actorApId: string,
|
|
263
|
+
product: SocialNotificationProduct,
|
|
264
|
+
appId: string,
|
|
265
|
+
): Promise<void> {
|
|
266
|
+
const [sameApp, sameProduct] = await Promise.all([
|
|
267
|
+
db
|
|
268
|
+
.select({ id: notificationPushers.id })
|
|
269
|
+
.from(notificationPushers)
|
|
270
|
+
.where(
|
|
271
|
+
and(
|
|
272
|
+
eq(notificationPushers.actorApId, actorApId),
|
|
273
|
+
eq(notificationPushers.product, product),
|
|
274
|
+
eq(notificationPushers.appId, appId),
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
.orderBy(asc(notificationPushers.createdAt)),
|
|
278
|
+
db
|
|
279
|
+
.select({ id: notificationPushers.id })
|
|
280
|
+
.from(notificationPushers)
|
|
281
|
+
.where(
|
|
282
|
+
and(
|
|
283
|
+
eq(notificationPushers.actorApId, actorApId),
|
|
284
|
+
eq(notificationPushers.product, product),
|
|
285
|
+
),
|
|
286
|
+
)
|
|
287
|
+
.orderBy(asc(notificationPushers.createdAt)),
|
|
288
|
+
]);
|
|
289
|
+
const evict = new Set<string>();
|
|
290
|
+
for (const row of sameApp.slice(
|
|
291
|
+
0,
|
|
292
|
+
Math.max(0, sameApp.length + 1 - MAX_NOTIFICATION_PUSHERS_PER_APP),
|
|
293
|
+
)) {
|
|
294
|
+
evict.add(row.id);
|
|
295
|
+
}
|
|
296
|
+
const remainingProduct = sameProduct.filter((row) => !evict.has(row.id));
|
|
297
|
+
for (const row of remainingProduct.slice(
|
|
298
|
+
0,
|
|
299
|
+
Math.max(
|
|
300
|
+
0,
|
|
301
|
+
remainingProduct.length + 1 - MAX_NOTIFICATION_PUSHERS_PER_PRODUCT,
|
|
302
|
+
),
|
|
303
|
+
)) {
|
|
304
|
+
evict.add(row.id);
|
|
305
|
+
}
|
|
306
|
+
if (evict.size > 0) {
|
|
307
|
+
await db
|
|
308
|
+
.delete(notificationPushers)
|
|
309
|
+
.where(inArray(notificationPushers.id, [...evict]));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function buildNotificationPushMessage(
|
|
314
|
+
jobId: string,
|
|
315
|
+
): DeliveryNotificationPushMessageV1 {
|
|
316
|
+
return {
|
|
317
|
+
version: DELIVERY_QUEUE_MESSAGE_VERSION,
|
|
318
|
+
type: "notification_push",
|
|
319
|
+
jobId,
|
|
320
|
+
scheduledAt: new Date().toISOString(),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Enqueue durable outbox rows. Safe to call after every request/queue batch. */
|
|
325
|
+
export async function enqueuePendingNotificationPushJobs(
|
|
326
|
+
env: Env,
|
|
327
|
+
): Promise<number> {
|
|
328
|
+
const db = env.DB_INSTANCE;
|
|
329
|
+
// Expired rows are retained long enough to preserve the deterministic job
|
|
330
|
+
// idempotency window, then removed opportunistically in a bounded batch.
|
|
331
|
+
// This runs even without a Queue binding so disabled push delivery cannot
|
|
332
|
+
// turn the outbox ledger into unbounded storage. Stale pushers are purged
|
|
333
|
+
// here too (bounded), NOT inside per-job processing.
|
|
334
|
+
await purgeExpiredNotificationPushJobs(db);
|
|
335
|
+
await purgeExpiredNotificationPushers(db);
|
|
336
|
+
if (!env.DELIVERY_QUEUE) return 0;
|
|
337
|
+
const now = new Date().toISOString();
|
|
338
|
+
|
|
339
|
+
// Reclaim in-flight rows whose queue message is gone (see
|
|
340
|
+
// STALE_INFLIGHT_RECLAIM_MS). The reclaim consumes one attempt so a
|
|
341
|
+
// crash-looping job terminates at the same MAX_NOTIFICATION_PUSH_ATTEMPTS
|
|
342
|
+
// budget instead of ping-ponging forever.
|
|
343
|
+
const staleCutoff = new Date(
|
|
344
|
+
Date.now() - STALE_INFLIGHT_RECLAIM_MS,
|
|
345
|
+
).toISOString();
|
|
346
|
+
await db
|
|
347
|
+
.update(notificationPushJobs)
|
|
348
|
+
.set({
|
|
349
|
+
status: sql`CASE WHEN ${notificationPushJobs.attempts} + 1 >= ${MAX_NOTIFICATION_PUSH_ATTEMPTS} THEN 'failed' ELSE 'pending' END`,
|
|
350
|
+
attempts: sql`${notificationPushJobs.attempts} + 1`,
|
|
351
|
+
processingToken: null,
|
|
352
|
+
lastError: sql`COALESCE(${notificationPushJobs.lastError}, 'reclaimed stale in-flight push job')`,
|
|
353
|
+
updatedAt: now,
|
|
354
|
+
})
|
|
355
|
+
.where(
|
|
356
|
+
and(
|
|
357
|
+
inArray(notificationPushJobs.status, ["queued", "processing"]),
|
|
358
|
+
lte(notificationPushJobs.updatedAt, staleCutoff),
|
|
359
|
+
),
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
// The trigger creates a job for EVERY unread inbox insert, including
|
|
363
|
+
// recipients with zero pushers. Only enqueue jobs that can actually deliver;
|
|
364
|
+
// pusher-less rows stay pending (no queue traffic, no processing cycle) and
|
|
365
|
+
// age out through the retention purge above.
|
|
366
|
+
const actorHasPusher = exists(
|
|
367
|
+
db
|
|
368
|
+
.select({ id: notificationPushers.id })
|
|
369
|
+
.from(notificationPushers)
|
|
370
|
+
.where(eq(notificationPushers.actorApId, notificationPushJobs.actorApId)),
|
|
371
|
+
);
|
|
372
|
+
const rows = await db
|
|
373
|
+
.select({ id: notificationPushJobs.id })
|
|
374
|
+
.from(notificationPushJobs)
|
|
375
|
+
.where(
|
|
376
|
+
and(
|
|
377
|
+
inArray(notificationPushJobs.status, ["pending", "retry_wait"]),
|
|
378
|
+
lte(notificationPushJobs.nextAttemptAt, now),
|
|
379
|
+
actorHasPusher,
|
|
380
|
+
),
|
|
381
|
+
)
|
|
382
|
+
.orderBy(asc(notificationPushJobs.createdAt))
|
|
383
|
+
.limit(MAX_QUEUE_SCAN);
|
|
384
|
+
if (rows.length === 0) return 0;
|
|
385
|
+
|
|
386
|
+
await env.DELIVERY_QUEUE.sendBatch(
|
|
387
|
+
rows.map((row) => ({ body: buildNotificationPushMessage(row.id) })),
|
|
388
|
+
);
|
|
389
|
+
await db
|
|
390
|
+
.update(notificationPushJobs)
|
|
391
|
+
.set({ status: "queued", processingToken: null, updatedAt: now })
|
|
392
|
+
.where(
|
|
393
|
+
and(
|
|
394
|
+
inArray(
|
|
395
|
+
notificationPushJobs.id,
|
|
396
|
+
rows.map((row) => row.id),
|
|
397
|
+
),
|
|
398
|
+
inArray(notificationPushJobs.status, ["pending", "retry_wait"]),
|
|
399
|
+
),
|
|
400
|
+
);
|
|
401
|
+
return rows.length;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Reset a dead-lettered push job so the durable outbox can retry it. Called by
|
|
406
|
+
* the DLQ consumer when a `notification_push` message exhausted its Cloudflare
|
|
407
|
+
* Queue retries with the RAW body (automatic dead-lettering) — without this,
|
|
408
|
+
* the job row would be stranded in 'queued'/'processing' forever. Consumes one
|
|
409
|
+
* attempt so a permanently failing job still terminates at the attempts budget.
|
|
410
|
+
*/
|
|
411
|
+
export async function recoverDeadLetteredNotificationPushJob(
|
|
412
|
+
db: Database,
|
|
413
|
+
jobId: string,
|
|
414
|
+
): Promise<boolean> {
|
|
415
|
+
const now = new Date().toISOString();
|
|
416
|
+
const recovered = await db
|
|
417
|
+
.update(notificationPushJobs)
|
|
418
|
+
.set({
|
|
419
|
+
status: sql`CASE WHEN ${notificationPushJobs.attempts} + 1 >= ${MAX_NOTIFICATION_PUSH_ATTEMPTS} THEN 'failed' ELSE 'retry_wait' END`,
|
|
420
|
+
attempts: sql`${notificationPushJobs.attempts} + 1`,
|
|
421
|
+
processingToken: null,
|
|
422
|
+
nextAttemptAt: now,
|
|
423
|
+
lastError: sql`COALESCE(${notificationPushJobs.lastError}, 'queue message dead-lettered')`,
|
|
424
|
+
updatedAt: now,
|
|
425
|
+
})
|
|
426
|
+
.where(
|
|
427
|
+
and(
|
|
428
|
+
eq(notificationPushJobs.id, jobId),
|
|
429
|
+
inArray(notificationPushJobs.status, [
|
|
430
|
+
"pending",
|
|
431
|
+
"queued",
|
|
432
|
+
"processing",
|
|
433
|
+
"retry_wait",
|
|
434
|
+
]),
|
|
435
|
+
),
|
|
436
|
+
);
|
|
437
|
+
return affectedRowCount(recovered) > 0;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Remove at most one bounded batch of pushers idle past the retention window. */
|
|
441
|
+
export async function purgeExpiredNotificationPushers(
|
|
442
|
+
db: Database,
|
|
443
|
+
now = new Date(),
|
|
444
|
+
): Promise<number> {
|
|
445
|
+
const cutoff = new Date(
|
|
446
|
+
now.getTime() - NOTIFICATION_PUSHER_RETENTION_DAYS * 86_400_000,
|
|
447
|
+
).toISOString();
|
|
448
|
+
const stale = await db
|
|
449
|
+
.select({ id: notificationPushers.id })
|
|
450
|
+
.from(notificationPushers)
|
|
451
|
+
.where(lte(notificationPushers.lastSeenAt, cutoff))
|
|
452
|
+
.orderBy(asc(notificationPushers.lastSeenAt), asc(notificationPushers.id))
|
|
453
|
+
.limit(MAX_NOTIFICATION_PUSHER_PURGE);
|
|
454
|
+
if (stale.length === 0) return 0;
|
|
455
|
+
const deleted = await db.delete(notificationPushers).where(
|
|
456
|
+
and(
|
|
457
|
+
inArray(
|
|
458
|
+
notificationPushers.id,
|
|
459
|
+
stale.map((row) => row.id),
|
|
460
|
+
),
|
|
461
|
+
lte(notificationPushers.lastSeenAt, cutoff),
|
|
462
|
+
),
|
|
463
|
+
);
|
|
464
|
+
return affectedRowCount(deleted);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Remove at most one bounded batch of jobs past the retention window. ANY
|
|
469
|
+
* status qualifies: terminal rows have served their idempotency window, a
|
|
470
|
+
* pending row that old belongs to a pusher-less recipient (never enqueued —
|
|
471
|
+
* see the sweep's actorHasPusher filter), and no live in-flight state survives
|
|
472
|
+
* 90 days (stale queued/processing rows are reclaimed within minutes).
|
|
473
|
+
*/
|
|
474
|
+
export async function purgeExpiredNotificationPushJobs(
|
|
475
|
+
db: Database,
|
|
476
|
+
now = new Date(),
|
|
477
|
+
): Promise<number> {
|
|
478
|
+
const cutoff = new Date(
|
|
479
|
+
now.getTime() - NOTIFICATION_PUSH_JOB_RETENTION_DAYS * 86_400_000,
|
|
480
|
+
).toISOString();
|
|
481
|
+
const expired = await db
|
|
482
|
+
.select({ id: notificationPushJobs.id })
|
|
483
|
+
.from(notificationPushJobs)
|
|
484
|
+
.where(lte(notificationPushJobs.updatedAt, cutoff))
|
|
485
|
+
.orderBy(asc(notificationPushJobs.updatedAt), asc(notificationPushJobs.id))
|
|
486
|
+
.limit(MAX_NOTIFICATION_PUSH_JOB_PURGE);
|
|
487
|
+
if (expired.length === 0) return 0;
|
|
488
|
+
|
|
489
|
+
const deleted = await db.delete(notificationPushJobs).where(
|
|
490
|
+
and(
|
|
491
|
+
inArray(
|
|
492
|
+
notificationPushJobs.id,
|
|
493
|
+
expired.map((row) => row.id),
|
|
494
|
+
),
|
|
495
|
+
lte(notificationPushJobs.updatedAt, cutoff),
|
|
496
|
+
),
|
|
497
|
+
);
|
|
498
|
+
return affectedRowCount(deleted);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export async function processNotificationPushJob(
|
|
502
|
+
env: Env,
|
|
503
|
+
body: DeliveryNotificationPushMessageV1,
|
|
504
|
+
message: Message<DeliveryQueueMessageV1>,
|
|
505
|
+
): Promise<void> {
|
|
506
|
+
const db = env.DB_INSTANCE;
|
|
507
|
+
let job = await db
|
|
508
|
+
.select()
|
|
509
|
+
.from(notificationPushJobs)
|
|
510
|
+
.where(eq(notificationPushJobs.id, body.jobId))
|
|
511
|
+
.get();
|
|
512
|
+
if (!job || job.status === "delivered" || job.status === "failed") {
|
|
513
|
+
message.ack();
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const processingAgeMs = Date.now() - Date.parse(job.updatedAt);
|
|
518
|
+
if (job.status === "processing") {
|
|
519
|
+
if (processingAgeMs < STALE_PROCESSING_MS) {
|
|
520
|
+
message.retry({ delaySeconds: 30 });
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
// Reclaim a stale processing row through an actual status transition.
|
|
524
|
+
// Updating processing -> processing lets two workers that read the same
|
|
525
|
+
// stale row both satisfy the old broad claim predicate and duplicate a
|
|
526
|
+
// push. Only the worker that wins this processing -> queued CAS may proceed.
|
|
527
|
+
const reclaimedAt = new Date().toISOString();
|
|
528
|
+
const reclaimed = await db
|
|
529
|
+
.update(notificationPushJobs)
|
|
530
|
+
.set({
|
|
531
|
+
status: "queued",
|
|
532
|
+
processingToken: null,
|
|
533
|
+
updatedAt: reclaimedAt,
|
|
534
|
+
})
|
|
535
|
+
.where(
|
|
536
|
+
and(
|
|
537
|
+
eq(notificationPushJobs.id, job.id),
|
|
538
|
+
eq(notificationPushJobs.status, "processing"),
|
|
539
|
+
eq(notificationPushJobs.updatedAt, job.updatedAt),
|
|
540
|
+
),
|
|
541
|
+
);
|
|
542
|
+
if (affectedRowCount(reclaimed) === 0) {
|
|
543
|
+
message.retry({ delaySeconds: 30 });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
job = {
|
|
547
|
+
...job,
|
|
548
|
+
status: "queued",
|
|
549
|
+
processingToken: null,
|
|
550
|
+
updatedAt: reclaimedAt,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const now = new Date().toISOString();
|
|
555
|
+
const processingToken = generateId(16);
|
|
556
|
+
const claimed = await db
|
|
557
|
+
.update(notificationPushJobs)
|
|
558
|
+
.set({ status: "processing", processingToken, updatedAt: now })
|
|
559
|
+
.where(
|
|
560
|
+
and(
|
|
561
|
+
eq(notificationPushJobs.id, job.id),
|
|
562
|
+
eq(notificationPushJobs.status, job.status),
|
|
563
|
+
eq(notificationPushJobs.updatedAt, job.updatedAt),
|
|
564
|
+
// Honor the backoff schedule: a duplicate message that survived a
|
|
565
|
+
// double-enqueue race must not claim a retry_wait row before its
|
|
566
|
+
// nextAttemptAt, which would bypass exponential backoff / Retry-After
|
|
567
|
+
// and hammer a gateway that is actively rate-limiting us.
|
|
568
|
+
lte(notificationPushJobs.nextAttemptAt, now),
|
|
569
|
+
),
|
|
570
|
+
);
|
|
571
|
+
if (affectedRowCount(claimed) === 0) {
|
|
572
|
+
// Another Queue delivery owns this job now, or it is not yet due. Do not
|
|
573
|
+
// ack the competing message permanently: a retry lets it observe the
|
|
574
|
+
// terminal row, become due, or recover if that owner crashes after claiming.
|
|
575
|
+
message.retry({ delaySeconds: 30 });
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const lease: ProcessingLease = { jobId: job.id, processingToken };
|
|
579
|
+
|
|
580
|
+
try {
|
|
581
|
+
const explicitProduct =
|
|
582
|
+
job.product === "yurucommu" || job.product === "yurume"
|
|
583
|
+
? job.product
|
|
584
|
+
: null;
|
|
585
|
+
const event = await loadPushEvent(
|
|
586
|
+
db,
|
|
587
|
+
job.actorApId,
|
|
588
|
+
job.activityApId,
|
|
589
|
+
explicitProduct,
|
|
590
|
+
);
|
|
591
|
+
if (!event) {
|
|
592
|
+
await finishJob(db, lease, "notification is no longer eligible");
|
|
593
|
+
message.ack();
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const product: SocialNotificationProduct =
|
|
598
|
+
explicitProduct ??
|
|
599
|
+
(event.visibility === "direct" ? "yurume" : "yurucommu");
|
|
600
|
+
let pendingIds = parsePendingIds(job.pendingPusherIdsJson);
|
|
601
|
+
if (pendingIds === null) {
|
|
602
|
+
// Retention purge of idle pushers is a bounded sweep in
|
|
603
|
+
// enqueuePendingNotificationPushJobs, NOT an unbounded all-actor DELETE
|
|
604
|
+
// on this hot per-job path. Stale rows simply resolve to zero deliveries
|
|
605
|
+
// here and get reaped by the sweep.
|
|
606
|
+
pendingIds = (
|
|
607
|
+
await db
|
|
608
|
+
.select({ id: notificationPushers.id })
|
|
609
|
+
.from(notificationPushers)
|
|
610
|
+
.where(
|
|
611
|
+
and(
|
|
612
|
+
eq(notificationPushers.actorApId, job.actorApId),
|
|
613
|
+
eq(notificationPushers.product, product),
|
|
614
|
+
lte(notificationPushers.createdAt, job.createdAt),
|
|
615
|
+
),
|
|
616
|
+
)
|
|
617
|
+
.orderBy(asc(notificationPushers.createdAt))
|
|
618
|
+
.limit(MAX_NOTIFICATION_PUSH_DISPATCH)
|
|
619
|
+
).map((row) => row.id);
|
|
620
|
+
const pendingIdsUpdated = await db
|
|
621
|
+
.update(notificationPushJobs)
|
|
622
|
+
.set({
|
|
623
|
+
pendingPusherIdsJson: JSON.stringify(pendingIds),
|
|
624
|
+
updatedAt: now,
|
|
625
|
+
})
|
|
626
|
+
.where(processingLeaseWhere(lease));
|
|
627
|
+
if (affectedRowCount(pendingIdsUpdated) === 0) {
|
|
628
|
+
message.ack();
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (pendingIds.length === 0) {
|
|
634
|
+
await finishJob(db, lease, null);
|
|
635
|
+
message.ack();
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const pushers = (await db
|
|
640
|
+
.select({
|
|
641
|
+
id: notificationPushers.id,
|
|
642
|
+
actorApId: notificationPushers.actorApId,
|
|
643
|
+
product: notificationPushers.product,
|
|
644
|
+
appId: notificationPushers.appId,
|
|
645
|
+
pushkey: notificationPushers.pushkey,
|
|
646
|
+
pushkeyHash: notificationPushers.pushkeyHash,
|
|
647
|
+
dataJson: notificationPushers.dataJson,
|
|
648
|
+
gatewayUrl: notificationPushers.gatewayUrl,
|
|
649
|
+
})
|
|
650
|
+
.from(notificationPushers)
|
|
651
|
+
.where(
|
|
652
|
+
and(
|
|
653
|
+
inArray(notificationPushers.id, pendingIds),
|
|
654
|
+
eq(notificationPushers.actorApId, job.actorApId),
|
|
655
|
+
eq(notificationPushers.product, product),
|
|
656
|
+
),
|
|
657
|
+
)) as StoredPusher[];
|
|
658
|
+
if (pushers.length === 0) {
|
|
659
|
+
await finishJob(db, lease, null);
|
|
660
|
+
message.ack();
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const unread = await unreadCountForProduct(db, job.actorApId, product);
|
|
665
|
+
const grouped = groupByGatewayAndFormat(pushers);
|
|
666
|
+
const retryIds: string[] = [];
|
|
667
|
+
let retryAfterSeconds = 0;
|
|
668
|
+
const errors: string[] = [];
|
|
669
|
+
for (const group of grouped.values()) {
|
|
670
|
+
// A job can fan out to sixteen different gateways. Refresh the durable
|
|
671
|
+
// lease before each bounded network call so a healthy worker cannot age
|
|
672
|
+
// past the stale-reclaim window while progressing through that fanout.
|
|
673
|
+
if (!(await refreshProcessingLease(db, lease))) {
|
|
674
|
+
message.ack();
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const outcome = await deliverGatewayGroup(
|
|
678
|
+
env,
|
|
679
|
+
db,
|
|
680
|
+
group.gatewayUrl,
|
|
681
|
+
group.pushers,
|
|
682
|
+
group.format,
|
|
683
|
+
{
|
|
684
|
+
id: event.activityApId,
|
|
685
|
+
type: event.visibility === "direct" ? "dm" : event.type.toLowerCase(),
|
|
686
|
+
sender: event.actorApId,
|
|
687
|
+
scopeId: event.objectApId,
|
|
688
|
+
unread,
|
|
689
|
+
},
|
|
690
|
+
);
|
|
691
|
+
retryIds.push(...outcome.retryIds);
|
|
692
|
+
retryAfterSeconds = Math.max(
|
|
693
|
+
retryAfterSeconds,
|
|
694
|
+
outcome.retryAfterSeconds,
|
|
695
|
+
);
|
|
696
|
+
if (outcome.error) errors.push(outcome.error);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (retryIds.length === 0) {
|
|
700
|
+
await finishJob(db, lease, errors.length > 0 ? errors.join("; ") : null);
|
|
701
|
+
message.ack();
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const attempts = job.attempts + 1;
|
|
706
|
+
const lastError =
|
|
707
|
+
errors.join("; ").slice(0, 1024) || "retryable gateway failure";
|
|
708
|
+
if (attempts >= MAX_NOTIFICATION_PUSH_ATTEMPTS) {
|
|
709
|
+
const failed = await db
|
|
710
|
+
.update(notificationPushJobs)
|
|
711
|
+
.set({
|
|
712
|
+
status: "failed",
|
|
713
|
+
processingToken: null,
|
|
714
|
+
attempts,
|
|
715
|
+
pendingPusherIdsJson: JSON.stringify([...new Set(retryIds)]),
|
|
716
|
+
lastError,
|
|
717
|
+
updatedAt: new Date().toISOString(),
|
|
718
|
+
})
|
|
719
|
+
.where(processingLeaseWhere(lease));
|
|
720
|
+
if (affectedRowCount(failed) === 0) {
|
|
721
|
+
message.ack();
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
log.error("Notification push exhausted its retry budget", {
|
|
725
|
+
event: "notification.push.exhausted",
|
|
726
|
+
jobId: job.id,
|
|
727
|
+
attempts,
|
|
728
|
+
});
|
|
729
|
+
message.ack();
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const delaySeconds = Math.max(
|
|
734
|
+
retryAfterSeconds,
|
|
735
|
+
Math.min(12 * 60 * 60, 30 * 2 ** Math.max(0, attempts - 1)),
|
|
736
|
+
);
|
|
737
|
+
const nextAttemptAt = new Date(
|
|
738
|
+
Date.now() + delaySeconds * 1000,
|
|
739
|
+
).toISOString();
|
|
740
|
+
const retryScheduled = await db
|
|
741
|
+
.update(notificationPushJobs)
|
|
742
|
+
.set({
|
|
743
|
+
status: "retry_wait",
|
|
744
|
+
processingToken: null,
|
|
745
|
+
attempts,
|
|
746
|
+
pendingPusherIdsJson: JSON.stringify([...new Set(retryIds)]),
|
|
747
|
+
nextAttemptAt,
|
|
748
|
+
lastError,
|
|
749
|
+
updatedAt: new Date().toISOString(),
|
|
750
|
+
})
|
|
751
|
+
.where(processingLeaseWhere(lease));
|
|
752
|
+
if (affectedRowCount(retryScheduled) === 0) {
|
|
753
|
+
message.ack();
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
message.retry({ delaySeconds });
|
|
757
|
+
} catch (error) {
|
|
758
|
+
const attempts = job.attempts + 1;
|
|
759
|
+
const errorText = error instanceof Error ? error.message : String(error);
|
|
760
|
+
if (attempts >= MAX_NOTIFICATION_PUSH_ATTEMPTS) {
|
|
761
|
+
const failed = await db
|
|
762
|
+
.update(notificationPushJobs)
|
|
763
|
+
.set({
|
|
764
|
+
status: "failed",
|
|
765
|
+
processingToken: null,
|
|
766
|
+
attempts,
|
|
767
|
+
lastError: errorText.slice(0, 1024),
|
|
768
|
+
updatedAt: new Date().toISOString(),
|
|
769
|
+
})
|
|
770
|
+
.where(processingLeaseWhere(lease));
|
|
771
|
+
if (affectedRowCount(failed) === 0) {
|
|
772
|
+
message.ack();
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
message.ack();
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
const delaySeconds = Math.min(12 * 60 * 60, 30 * 2 ** (attempts - 1));
|
|
779
|
+
const retryScheduled = await db
|
|
780
|
+
.update(notificationPushJobs)
|
|
781
|
+
.set({
|
|
782
|
+
status: "retry_wait",
|
|
783
|
+
processingToken: null,
|
|
784
|
+
attempts,
|
|
785
|
+
nextAttemptAt: new Date(Date.now() + delaySeconds * 1000).toISOString(),
|
|
786
|
+
lastError: errorText.slice(0, 1024),
|
|
787
|
+
updatedAt: new Date().toISOString(),
|
|
788
|
+
})
|
|
789
|
+
.where(processingLeaseWhere(lease));
|
|
790
|
+
if (affectedRowCount(retryScheduled) === 0) {
|
|
791
|
+
message.ack();
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
message.retry({ delaySeconds });
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async function deliverGatewayGroup(
|
|
799
|
+
env: Env,
|
|
800
|
+
db: Database,
|
|
801
|
+
gatewayUrl: string,
|
|
802
|
+
pushers: StoredPusher[],
|
|
803
|
+
format: NotificationPushFormat,
|
|
804
|
+
event: {
|
|
805
|
+
id: string;
|
|
806
|
+
type: string;
|
|
807
|
+
sender: string;
|
|
808
|
+
scopeId: string | null;
|
|
809
|
+
unread: number;
|
|
810
|
+
},
|
|
811
|
+
): Promise<GatewayResult> {
|
|
812
|
+
if (!isNotificationGatewayAllowed(env, gatewayUrl)) {
|
|
813
|
+
return {
|
|
814
|
+
retryIds: [],
|
|
815
|
+
retryAfterSeconds: 0,
|
|
816
|
+
error: "gateway is no longer operator-allowed",
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
const data = pushers.map((row) => normalizedStoredData(row.dataJson));
|
|
820
|
+
const eventIdOnly = format === "event_id_only";
|
|
821
|
+
const payload = {
|
|
822
|
+
notification: {
|
|
823
|
+
event_id: event.id,
|
|
824
|
+
room_id: event.scopeId ?? undefined,
|
|
825
|
+
counts: { unread: event.unread },
|
|
826
|
+
...(!eventIdOnly
|
|
827
|
+
? {
|
|
828
|
+
type: event.type,
|
|
829
|
+
sender: event.sender,
|
|
830
|
+
user_is_target: true,
|
|
831
|
+
prio: "high",
|
|
832
|
+
}
|
|
833
|
+
: {}),
|
|
834
|
+
devices: pushers.map((row, index) => ({
|
|
835
|
+
app_id: row.appId,
|
|
836
|
+
pushkey: row.pushkey,
|
|
837
|
+
pushkey_ts: Math.floor(Date.now() / 1000),
|
|
838
|
+
data: data[index],
|
|
839
|
+
})),
|
|
840
|
+
},
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
const controller = new AbortController();
|
|
844
|
+
const timeout = setTimeout(
|
|
845
|
+
() => controller.abort(),
|
|
846
|
+
parseTimeout(env.YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_TIMEOUT_MS),
|
|
847
|
+
);
|
|
848
|
+
let response: Response;
|
|
849
|
+
try {
|
|
850
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
851
|
+
const canonical = normalizeGatewayUrl(
|
|
852
|
+
env.YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_URL,
|
|
853
|
+
);
|
|
854
|
+
if (
|
|
855
|
+
canonical === normalizeGatewayUrl(gatewayUrl) &&
|
|
856
|
+
env.YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_TOKEN &&
|
|
857
|
+
new URL(gatewayUrl).protocol === "https:"
|
|
858
|
+
) {
|
|
859
|
+
headers.set(
|
|
860
|
+
"Authorization",
|
|
861
|
+
`Bearer ${env.YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_TOKEN}`,
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
response = await fetch(gatewayUrl, {
|
|
865
|
+
method: "POST",
|
|
866
|
+
headers,
|
|
867
|
+
body: JSON.stringify(payload),
|
|
868
|
+
redirect: "error",
|
|
869
|
+
signal: controller.signal,
|
|
870
|
+
});
|
|
871
|
+
} catch (error) {
|
|
872
|
+
return {
|
|
873
|
+
retryIds: pushers.map((row) => row.id),
|
|
874
|
+
retryAfterSeconds: 0,
|
|
875
|
+
error: error instanceof Error ? error.message : String(error),
|
|
876
|
+
};
|
|
877
|
+
} finally {
|
|
878
|
+
clearTimeout(timeout);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (response.status === 429 || response.status >= 500) {
|
|
882
|
+
return {
|
|
883
|
+
retryIds: pushers.map((row) => row.id),
|
|
884
|
+
retryAfterSeconds: parseRetryAfter(response.headers.get("Retry-After")),
|
|
885
|
+
error: `gateway HTTP ${response.status}`,
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
if (!response.ok) {
|
|
889
|
+
return {
|
|
890
|
+
retryIds: [],
|
|
891
|
+
retryAfterSeconds: 0,
|
|
892
|
+
error: `gateway HTTP ${response.status}`,
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
let parsed: unknown;
|
|
897
|
+
try {
|
|
898
|
+
parsed = JSON.parse(
|
|
899
|
+
await readResponseText(response, MAX_GATEWAY_RESPONSE_BYTES),
|
|
900
|
+
);
|
|
901
|
+
} catch (error) {
|
|
902
|
+
return {
|
|
903
|
+
retryIds: pushers.map((row) => row.id),
|
|
904
|
+
retryAfterSeconds: 0,
|
|
905
|
+
error:
|
|
906
|
+
error instanceof Error ? error.message : "invalid gateway response",
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
if (!parsed || typeof parsed !== "object") {
|
|
910
|
+
return {
|
|
911
|
+
retryIds: pushers.map((row) => row.id),
|
|
912
|
+
retryAfterSeconds: 0,
|
|
913
|
+
error: "invalid gateway response",
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
const responseBody = parsed as Record<string, unknown>;
|
|
917
|
+
const rejected = stringArray(responseBody.rejected);
|
|
918
|
+
const retryable = stringArray(responseBody.retryable);
|
|
919
|
+
const failed = stringArray(responseBody.failed);
|
|
920
|
+
if (!rejected || !retryable || !failed) {
|
|
921
|
+
return {
|
|
922
|
+
retryIds: pushers.map((row) => row.id),
|
|
923
|
+
retryAfterSeconds: 0,
|
|
924
|
+
error: "invalid gateway result arrays",
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const byPushkey = new Map(pushers.map((row) => [row.pushkey, row]));
|
|
929
|
+
for (const pushkey of rejected) {
|
|
930
|
+
const row = byPushkey.get(pushkey);
|
|
931
|
+
if (!row) continue;
|
|
932
|
+
await db
|
|
933
|
+
.delete(notificationPushers)
|
|
934
|
+
.where(
|
|
935
|
+
and(
|
|
936
|
+
eq(notificationPushers.id, row.id),
|
|
937
|
+
eq(notificationPushers.actorApId, row.actorApId),
|
|
938
|
+
eq(notificationPushers.product, row.product),
|
|
939
|
+
eq(notificationPushers.pushkeyHash, row.pushkeyHash),
|
|
940
|
+
eq(notificationPushers.pushkey, row.pushkey),
|
|
941
|
+
),
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
const terminal = new Set([...rejected, ...failed]);
|
|
945
|
+
const retrySet = new Set(retryable);
|
|
946
|
+
return {
|
|
947
|
+
retryIds: pushers
|
|
948
|
+
.filter((row) => retrySet.has(row.pushkey) && !terminal.has(row.pushkey))
|
|
949
|
+
.map((row) => row.id),
|
|
950
|
+
retryAfterSeconds: parseRetryAfter(response.headers.get("Retry-After")),
|
|
951
|
+
error: failed.length > 0 ? "gateway reported permanent failures" : null,
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
async function loadPushEvent(
|
|
956
|
+
db: Database,
|
|
957
|
+
actorApId: string,
|
|
958
|
+
activityApId: string,
|
|
959
|
+
explicitProduct: SocialNotificationProduct | null,
|
|
960
|
+
) {
|
|
961
|
+
const selectEvent = () =>
|
|
962
|
+
db.select({
|
|
963
|
+
activityApId: activities.apId,
|
|
964
|
+
type: activities.type,
|
|
965
|
+
actorApId: activities.actorApId,
|
|
966
|
+
objectApId: activities.objectApId,
|
|
967
|
+
objectType: objects.type,
|
|
968
|
+
visibility: objects.visibility,
|
|
969
|
+
conversation: objects.conversation,
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
if (explicitProduct !== null) {
|
|
973
|
+
// Explicit jobs currently represent community talk, which intentionally
|
|
974
|
+
// has no social-inbox row. It still observes per-recipient block/mute and
|
|
975
|
+
// self-notification rules at delivery time.
|
|
976
|
+
const currentCommunityMembership = db
|
|
977
|
+
.select({ actorApId: communityMembers.actorApId })
|
|
978
|
+
.from(objectRecipients)
|
|
979
|
+
.innerJoin(
|
|
980
|
+
communityMembers,
|
|
981
|
+
eq(communityMembers.communityApId, objectRecipients.recipientApId),
|
|
982
|
+
)
|
|
983
|
+
.where(
|
|
984
|
+
and(
|
|
985
|
+
eq(objectRecipients.objectApId, activities.objectApId),
|
|
986
|
+
eq(objectRecipients.type, "audience"),
|
|
987
|
+
eq(communityMembers.actorApId, actorApId),
|
|
988
|
+
),
|
|
989
|
+
);
|
|
990
|
+
return selectEvent()
|
|
991
|
+
.from(activities)
|
|
992
|
+
.leftJoin(objects, eq(activities.objectApId, objects.apId))
|
|
993
|
+
.where(
|
|
994
|
+
and(
|
|
995
|
+
eq(activities.apId, activityApId),
|
|
996
|
+
ne(activities.actorApId, actorApId),
|
|
997
|
+
inArray(activities.type, [...NOTIFICATION_ACTIVITY_TYPES]),
|
|
998
|
+
exists(currentCommunityMembership),
|
|
999
|
+
excludeBlockedMutedAuthors(db, actorApId, activities.actorApId),
|
|
1000
|
+
),
|
|
1001
|
+
)
|
|
1002
|
+
.get();
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// The inbox trigger intentionally captures every unread insert so it cannot
|
|
1006
|
+
// lose a notification in a route-specific crash window. Eligibility is
|
|
1007
|
+
// therefore re-checked here immediately before external delivery, using the
|
|
1008
|
+
// SAME shared predicate builder as the notification list/count. Direct
|
|
1009
|
+
// Creates remain eligible (archived DMs excepted) and route to Yurume below.
|
|
1010
|
+
return selectEvent()
|
|
1011
|
+
.from(inbox)
|
|
1012
|
+
.innerJoin(activities, eq(inbox.activityApId, activities.apId))
|
|
1013
|
+
.leftJoin(objects, eq(activities.objectApId, objects.apId))
|
|
1014
|
+
.where(
|
|
1015
|
+
and(
|
|
1016
|
+
eq(inbox.actorApId, actorApId),
|
|
1017
|
+
eq(inbox.activityApId, activityApId),
|
|
1018
|
+
...notificationEligibilityWhere(db, actorApId, {
|
|
1019
|
+
direct: "unless-dm-archived",
|
|
1020
|
+
}),
|
|
1021
|
+
),
|
|
1022
|
+
)
|
|
1023
|
+
.get();
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** Product-specific unread badge included in the event-id-only push payload. */
|
|
1027
|
+
async function unreadCountForProduct(
|
|
1028
|
+
db: Database,
|
|
1029
|
+
actorApId: string,
|
|
1030
|
+
product: SocialNotificationProduct,
|
|
1031
|
+
): Promise<number> {
|
|
1032
|
+
if (product === "yurume") {
|
|
1033
|
+
// Reuse the SAME helper as GET /api/dm/unread/count so the badge a push
|
|
1034
|
+
// sets can never drift from the badge the client computes on open.
|
|
1035
|
+
return (await yurumeUnreadCounts(db, actorApId)).total;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Reuse the SAME eligibility predicate as the notification list/badge. A
|
|
1039
|
+
// Yurume DM row must never inflate the Yurucommu app badge (direct: exclude).
|
|
1040
|
+
const row = await db
|
|
1041
|
+
.select({ count: sql<number>`COUNT(*)` })
|
|
1042
|
+
.from(inbox)
|
|
1043
|
+
.innerJoin(activities, eq(inbox.activityApId, activities.apId))
|
|
1044
|
+
.leftJoin(objects, eq(activities.objectApId, objects.apId))
|
|
1045
|
+
.where(
|
|
1046
|
+
and(
|
|
1047
|
+
eq(inbox.actorApId, actorApId),
|
|
1048
|
+
eq(inbox.read, 0),
|
|
1049
|
+
...notificationEligibilityWhere(db, actorApId, { direct: "exclude" }),
|
|
1050
|
+
),
|
|
1051
|
+
)
|
|
1052
|
+
.get();
|
|
1053
|
+
return Number(row?.count ?? 0);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
async function finishJob(
|
|
1057
|
+
db: Database,
|
|
1058
|
+
lease: ProcessingLease,
|
|
1059
|
+
lastError: string | null,
|
|
1060
|
+
): Promise<boolean> {
|
|
1061
|
+
const now = new Date().toISOString();
|
|
1062
|
+
const finished = await db
|
|
1063
|
+
.update(notificationPushJobs)
|
|
1064
|
+
.set({
|
|
1065
|
+
status: "delivered",
|
|
1066
|
+
processingToken: null,
|
|
1067
|
+
pendingPusherIdsJson: "[]",
|
|
1068
|
+
lastError,
|
|
1069
|
+
deliveredAt: now,
|
|
1070
|
+
updatedAt: now,
|
|
1071
|
+
})
|
|
1072
|
+
.where(processingLeaseWhere(lease));
|
|
1073
|
+
return affectedRowCount(finished) > 0;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function processingLeaseWhere(lease: ProcessingLease) {
|
|
1077
|
+
return and(
|
|
1078
|
+
eq(notificationPushJobs.id, lease.jobId),
|
|
1079
|
+
eq(notificationPushJobs.status, "processing"),
|
|
1080
|
+
eq(notificationPushJobs.processingToken, lease.processingToken),
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
async function refreshProcessingLease(
|
|
1085
|
+
db: Database,
|
|
1086
|
+
lease: ProcessingLease,
|
|
1087
|
+
): Promise<boolean> {
|
|
1088
|
+
const refreshed = await db
|
|
1089
|
+
.update(notificationPushJobs)
|
|
1090
|
+
.set({ updatedAt: new Date().toISOString() })
|
|
1091
|
+
.where(processingLeaseWhere(lease));
|
|
1092
|
+
return affectedRowCount(refreshed) > 0;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function groupByGatewayAndFormat(
|
|
1096
|
+
pushers: StoredPusher[],
|
|
1097
|
+
): Map<string, GatewayDispatchGroup> {
|
|
1098
|
+
const result = new Map<string, GatewayDispatchGroup>();
|
|
1099
|
+
for (const row of pushers) {
|
|
1100
|
+
const format = notificationPushFormat(parseStoredData(row.dataJson));
|
|
1101
|
+
const key = JSON.stringify([row.gatewayUrl, format]);
|
|
1102
|
+
const group = result.get(key) ?? {
|
|
1103
|
+
gatewayUrl: row.gatewayUrl,
|
|
1104
|
+
format,
|
|
1105
|
+
pushers: [],
|
|
1106
|
+
};
|
|
1107
|
+
group.pushers.push(row);
|
|
1108
|
+
result.set(key, group);
|
|
1109
|
+
}
|
|
1110
|
+
return result;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function parsePendingIds(value: string | null): string[] | null {
|
|
1114
|
+
if (value === null) return null;
|
|
1115
|
+
try {
|
|
1116
|
+
const parsed = JSON.parse(value);
|
|
1117
|
+
return Array.isArray(parsed) &&
|
|
1118
|
+
parsed.every((item) => typeof item === "string")
|
|
1119
|
+
? parsed
|
|
1120
|
+
: [];
|
|
1121
|
+
} catch {
|
|
1122
|
+
return [];
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function parseStoredData(value: string): JsonObject {
|
|
1127
|
+
try {
|
|
1128
|
+
const parsed = JSON.parse(value);
|
|
1129
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
1130
|
+
? (parsed as JsonObject)
|
|
1131
|
+
: {};
|
|
1132
|
+
} catch {
|
|
1133
|
+
return {};
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function notificationPushFormat(data: JsonObject): NotificationPushFormat {
|
|
1138
|
+
return data.format === "full" ? "full" : "event_id_only";
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function normalizedStoredData(value: string): JsonObject {
|
|
1142
|
+
const data = parseStoredData(value);
|
|
1143
|
+
return { ...data, format: notificationPushFormat(data) };
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function stringArray(value: unknown): string[] | null {
|
|
1147
|
+
if (value === undefined) return [];
|
|
1148
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
1149
|
+
return null;
|
|
1150
|
+
}
|
|
1151
|
+
return value;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function parseTimeout(value: string | undefined): number {
|
|
1155
|
+
const parsed = Number(value);
|
|
1156
|
+
if (!Number.isFinite(parsed)) return DEFAULT_GATEWAY_TIMEOUT_MS;
|
|
1157
|
+
return Math.max(250, Math.min(30_000, Math.floor(parsed)));
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function parseRetryAfter(value: string | null): number {
|
|
1161
|
+
if (!value) return 0;
|
|
1162
|
+
const seconds = Number(value);
|
|
1163
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
1164
|
+
return Math.min(12 * 60 * 60, Math.ceil(seconds));
|
|
1165
|
+
}
|
|
1166
|
+
const timestamp = Date.parse(value);
|
|
1167
|
+
if (!Number.isFinite(timestamp)) return 0;
|
|
1168
|
+
return Math.min(
|
|
1169
|
+
12 * 60 * 60,
|
|
1170
|
+
Math.max(0, Math.ceil((timestamp - Date.now()) / 1000)),
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
async function readResponseText(
|
|
1175
|
+
response: Response,
|
|
1176
|
+
maxBytes: number,
|
|
1177
|
+
): Promise<string> {
|
|
1178
|
+
if (!response.body) return "";
|
|
1179
|
+
const reader = response.body.getReader();
|
|
1180
|
+
const chunks: Uint8Array[] = [];
|
|
1181
|
+
let size = 0;
|
|
1182
|
+
while (true) {
|
|
1183
|
+
const { done, value } = await reader.read();
|
|
1184
|
+
if (done) break;
|
|
1185
|
+
size += value.byteLength;
|
|
1186
|
+
if (size > maxBytes) {
|
|
1187
|
+
await reader.cancel();
|
|
1188
|
+
throw new Error("gateway response is too large");
|
|
1189
|
+
}
|
|
1190
|
+
chunks.push(value);
|
|
1191
|
+
}
|
|
1192
|
+
const bytes = new Uint8Array(size);
|
|
1193
|
+
let offset = 0;
|
|
1194
|
+
for (const chunk of chunks) {
|
|
1195
|
+
bytes.set(chunk, offset);
|
|
1196
|
+
offset += chunk.byteLength;
|
|
1197
|
+
}
|
|
1198
|
+
return new TextDecoder().decode(bytes);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function isTruthy(value: string | undefined): boolean {
|
|
1202
|
+
return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? "");
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
async function sha256Hex(value: string): Promise<string> {
|
|
1206
|
+
const digest = await crypto.subtle.digest(
|
|
1207
|
+
"SHA-256",
|
|
1208
|
+
new TextEncoder().encode(value),
|
|
1209
|
+
);
|
|
1210
|
+
return Array.from(new Uint8Array(digest), (byte) =>
|
|
1211
|
+
byte.toString(16).padStart(2, "0"),
|
|
1212
|
+
).join("");
|
|
1213
|
+
}
|