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