@vex-chat/spire 2.4.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ClientManager.js +8 -1
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +3 -2
- package/dist/Database.js +7 -1
- package/dist/Database.js.map +1 -1
- package/dist/NotificationService.d.ts +2 -0
- package/dist/NotificationService.js +321 -4
- package/dist/NotificationService.js.map +1 -1
- package/dist/Spire.js +21 -5
- package/dist/Spire.js.map +1 -1
- package/dist/server/callWake.d.ts +13 -0
- package/dist/server/callWake.js +18 -0
- package/dist/server/callWake.js.map +1 -0
- package/package.json +4 -4
- package/src/ClientManager.ts +23 -9
- package/src/Database.ts +12 -3
- package/src/NotificationService.ts +428 -4
- package/src/Spire.ts +52 -21
- package/src/__tests__/notifyFanout.spec.ts +136 -0
- package/src/server/callWake.ts +30 -0
|
@@ -8,8 +8,14 @@ import type { ClientManager } from "./ClientManager.ts";
|
|
|
8
8
|
import type { Database, NotificationSubscription } from "./Database.ts";
|
|
9
9
|
import type { NotifyMsg } from "@vex-chat/types";
|
|
10
10
|
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import * as http2 from "node:http2";
|
|
13
|
+
|
|
14
|
+
import jwt from "jsonwebtoken";
|
|
15
|
+
|
|
11
16
|
const EXPO_PUSH_ENDPOINT = "https://exp.host/--/api/v2/push/send";
|
|
12
17
|
const EXPO_RECEIPT_ENDPOINT = "https://exp.host/--/api/v2/push/getReceipts";
|
|
18
|
+
const FCM_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
13
19
|
const EXPO_BATCH_SIZE = 100;
|
|
14
20
|
const EXPO_RECEIPT_DELAY_MS = 15 * 60 * 1000;
|
|
15
21
|
const EXPO_REQUEST_TIMEOUT_MS = 10_000;
|
|
@@ -27,6 +33,19 @@ export interface NotificationDispatch {
|
|
|
27
33
|
userID: string;
|
|
28
34
|
}
|
|
29
35
|
|
|
36
|
+
interface ApnsConfig {
|
|
37
|
+
environment: "production" | "sandbox";
|
|
38
|
+
keyID: string;
|
|
39
|
+
privateKey: string;
|
|
40
|
+
teamID: string;
|
|
41
|
+
topic: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ApnsResponse {
|
|
45
|
+
body: string;
|
|
46
|
+
status: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
30
49
|
type ExpoErrorDetails = {
|
|
31
50
|
[key: string]: unknown;
|
|
32
51
|
error?: string;
|
|
@@ -53,6 +72,12 @@ type ExpoPushTicket =
|
|
|
53
72
|
status: "ok";
|
|
54
73
|
};
|
|
55
74
|
|
|
75
|
+
interface FcmConfig {
|
|
76
|
+
clientEmail: string;
|
|
77
|
+
privateKey: string;
|
|
78
|
+
projectID: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
56
81
|
interface NotificationServiceOptions {
|
|
57
82
|
receiptDelayMs?: number;
|
|
58
83
|
}
|
|
@@ -209,9 +234,21 @@ export class NotificationService {
|
|
|
209
234
|
? { ...query, deviceID: dispatch.deviceID }
|
|
210
235
|
: query,
|
|
211
236
|
);
|
|
212
|
-
const expoSubscriptions = subscriptions
|
|
213
|
-
|
|
214
|
-
|
|
237
|
+
const expoSubscriptions = subscriptions.filter(
|
|
238
|
+
(sub) => sub.channel === "expo",
|
|
239
|
+
);
|
|
240
|
+
const apnsSubscriptions = subscriptions.filter(
|
|
241
|
+
(sub) => sub.channel === "apnsVoip",
|
|
242
|
+
);
|
|
243
|
+
const fcmSubscriptions = subscriptions.filter(
|
|
244
|
+
(sub) => sub.channel === "fcmCall",
|
|
245
|
+
);
|
|
246
|
+
if (
|
|
247
|
+
expoSubscriptions.length === 0 &&
|
|
248
|
+
apnsSubscriptions.length === 0 &&
|
|
249
|
+
fcmSubscriptions.length === 0
|
|
250
|
+
) {
|
|
251
|
+
console.info("[spire-notify] no push subscriptions", {
|
|
215
252
|
deviceScoped: Boolean(dispatch.deviceID),
|
|
216
253
|
event: dispatch.event,
|
|
217
254
|
transmissionID: dispatch.transmissionID,
|
|
@@ -224,6 +261,14 @@ export class NotificationService {
|
|
|
224
261
|
const batch = expoSubscriptions.slice(i, i + EXPO_BATCH_SIZE);
|
|
225
262
|
await this.sendExpoBatch(batch, dispatch);
|
|
226
263
|
}
|
|
264
|
+
if (dispatch.event === "callWake") {
|
|
265
|
+
if (apnsSubscriptions.length > 0) {
|
|
266
|
+
await this.sendApnsVoipBatch(apnsSubscriptions, dispatch);
|
|
267
|
+
}
|
|
268
|
+
if (fcmSubscriptions.length > 0) {
|
|
269
|
+
await this.sendFcmCallBatch(fcmSubscriptions, dispatch);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
227
272
|
}
|
|
228
273
|
|
|
229
274
|
private async notifyPushIfMailPending(
|
|
@@ -328,6 +373,59 @@ export class NotificationService {
|
|
|
328
373
|
(timer as { unref?: () => void }).unref?.();
|
|
329
374
|
}
|
|
330
375
|
|
|
376
|
+
private async sendApnsVoipBatch(
|
|
377
|
+
subscriptions: NotificationSubscription[],
|
|
378
|
+
dispatch: NotificationDispatch,
|
|
379
|
+
): Promise<void> {
|
|
380
|
+
const config = readApnsConfig();
|
|
381
|
+
if (!config) {
|
|
382
|
+
console.warn("[spire-notify] APNs VoIP env is not configured", {
|
|
383
|
+
event: dispatch.event,
|
|
384
|
+
size: subscriptions.length,
|
|
385
|
+
transmissionID: dispatch.transmissionID,
|
|
386
|
+
});
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const providerToken = jwt.sign({}, config.privateKey, {
|
|
391
|
+
algorithm: "ES256",
|
|
392
|
+
expiresIn: "50m",
|
|
393
|
+
header: { alg: "ES256", kid: config.keyID },
|
|
394
|
+
issuer: config.teamID,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
for (const subscription of subscriptions) {
|
|
398
|
+
const response = await sendApnsVoipNotification(
|
|
399
|
+
config,
|
|
400
|
+
providerToken,
|
|
401
|
+
subscription,
|
|
402
|
+
dispatch,
|
|
403
|
+
);
|
|
404
|
+
if (response.status >= 200 && response.status < 300) {
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
console.warn("[spire-notify] APNs VoIP delivery failed", {
|
|
409
|
+
body: response.body.slice(0, 500),
|
|
410
|
+
status: response.status,
|
|
411
|
+
subscriptionID: subscription.subscriptionID,
|
|
412
|
+
transmissionID: dispatch.transmissionID,
|
|
413
|
+
});
|
|
414
|
+
if (
|
|
415
|
+
response.status === 400 ||
|
|
416
|
+
response.status === 410 ||
|
|
417
|
+
response.body.includes("BadDeviceToken") ||
|
|
418
|
+
response.body.includes("Unregistered")
|
|
419
|
+
) {
|
|
420
|
+
await this.db.removeNotificationSubscription({
|
|
421
|
+
deviceID: subscription.deviceID,
|
|
422
|
+
subscriptionID: subscription.subscriptionID,
|
|
423
|
+
userID: subscription.userID,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
331
429
|
private async sendExpoBatch(
|
|
332
430
|
subscriptions: NotificationSubscription[],
|
|
333
431
|
dispatch: NotificationDispatch,
|
|
@@ -419,10 +517,68 @@ export class NotificationService {
|
|
|
419
517
|
}
|
|
420
518
|
}
|
|
421
519
|
|
|
520
|
+
private async sendFcmCallBatch(
|
|
521
|
+
subscriptions: NotificationSubscription[],
|
|
522
|
+
dispatch: NotificationDispatch,
|
|
523
|
+
): Promise<void> {
|
|
524
|
+
const config = readFcmConfig();
|
|
525
|
+
if (!config) {
|
|
526
|
+
console.warn("[spire-notify] FCM call env is not configured", {
|
|
527
|
+
event: dispatch.event,
|
|
528
|
+
size: subscriptions.length,
|
|
529
|
+
transmissionID: dispatch.transmissionID,
|
|
530
|
+
});
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const accessToken = await fetchFcmAccessToken(config);
|
|
535
|
+
for (const subscription of subscriptions) {
|
|
536
|
+
const res = await fetchWithTimeout(
|
|
537
|
+
`https://fcm.googleapis.com/v1/projects/${encodeURIComponent(
|
|
538
|
+
config.projectID,
|
|
539
|
+
)}/messages:send`,
|
|
540
|
+
{
|
|
541
|
+
body: JSON.stringify(
|
|
542
|
+
fcmCallMessageForSubscription(subscription, dispatch),
|
|
543
|
+
),
|
|
544
|
+
headers: {
|
|
545
|
+
Authorization: `Bearer ${accessToken}`,
|
|
546
|
+
"Content-Type": "application/json",
|
|
547
|
+
},
|
|
548
|
+
method: "POST",
|
|
549
|
+
},
|
|
550
|
+
"FCM call push request",
|
|
551
|
+
);
|
|
552
|
+
if (res.ok) {
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const body = await res.text().catch(() => "");
|
|
557
|
+
console.warn("[spire-notify] FCM call delivery failed", {
|
|
558
|
+
body: body.slice(0, 500),
|
|
559
|
+
status: res.status,
|
|
560
|
+
subscriptionID: subscription.subscriptionID,
|
|
561
|
+
transmissionID: dispatch.transmissionID,
|
|
562
|
+
});
|
|
563
|
+
if (
|
|
564
|
+
res.status === 400 ||
|
|
565
|
+
res.status === 404 ||
|
|
566
|
+
body.includes("UNREGISTERED") ||
|
|
567
|
+
body.includes("registration-token-not-registered")
|
|
568
|
+
) {
|
|
569
|
+
await this.db.removeNotificationSubscription({
|
|
570
|
+
deviceID: subscription.deviceID,
|
|
571
|
+
subscriptionID: subscription.subscriptionID,
|
|
572
|
+
userID: subscription.userID,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
422
578
|
private startPush(dispatch: NotificationDispatch): void {
|
|
423
579
|
void this.notifyPush(dispatch).catch((err: unknown) => {
|
|
424
580
|
// Push is best-effort; websocket/inbox delivery remain authoritative.
|
|
425
|
-
console.warn("[spire-notify]
|
|
581
|
+
console.warn("[spire-notify] push fanout failed", {
|
|
426
582
|
event: dispatch.event,
|
|
427
583
|
message: err instanceof Error ? err.message : String(err),
|
|
428
584
|
transmissionID: dispatch.transmissionID,
|
|
@@ -432,7 +588,19 @@ export class NotificationService {
|
|
|
432
588
|
}
|
|
433
589
|
}
|
|
434
590
|
|
|
591
|
+
function apnsExpiration(dispatch: NotificationDispatch): string {
|
|
592
|
+
const expiresAt = notifyDataFields(dispatch.data)["expiresAt"];
|
|
593
|
+
if (typeof expiresAt === "string") {
|
|
594
|
+
const epochSeconds = Math.floor(Date.parse(expiresAt) / 1000);
|
|
595
|
+
if (Number.isFinite(epochSeconds) && epochSeconds > 0) {
|
|
596
|
+
return String(epochSeconds);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return String(Math.floor((Date.now() + 60_000) / 1000));
|
|
600
|
+
}
|
|
601
|
+
|
|
435
602
|
function bodyForEvent(event: string): string | undefined {
|
|
603
|
+
if (event === "callWake") return "Incoming voice call.";
|
|
436
604
|
if (event === "callInvite") return "Incoming voice call.";
|
|
437
605
|
if (event === "mail") return undefined;
|
|
438
606
|
if (event === "deviceRequest") return "Review the device request.";
|
|
@@ -440,6 +608,18 @@ function bodyForEvent(event: string): string | undefined {
|
|
|
440
608
|
return "Open Vex for the latest update.";
|
|
441
609
|
}
|
|
442
610
|
|
|
611
|
+
function callWakeTtl(dispatch: NotificationDispatch): string {
|
|
612
|
+
const expiresAt = notifyDataFields(dispatch.data)["expiresAt"];
|
|
613
|
+
if (typeof expiresAt !== "string") {
|
|
614
|
+
return "60s";
|
|
615
|
+
}
|
|
616
|
+
const ms = Date.parse(expiresAt) - Date.now();
|
|
617
|
+
if (!Number.isFinite(ms) || ms <= 0) {
|
|
618
|
+
return "1s";
|
|
619
|
+
}
|
|
620
|
+
return `${Math.max(1, Math.ceil(ms / 1000)).toString()}s`;
|
|
621
|
+
}
|
|
622
|
+
|
|
443
623
|
function expoMessageForSubscription(
|
|
444
624
|
subscription: NotificationSubscription,
|
|
445
625
|
dispatch: NotificationDispatch,
|
|
@@ -462,6 +642,7 @@ function expoMessageForSubscription(
|
|
|
462
642
|
const title = titleForEvent(dispatch.event);
|
|
463
643
|
const body = bodyForEvent(dispatch.event);
|
|
464
644
|
const data = {
|
|
645
|
+
...notifyDataFields(dispatch.data),
|
|
465
646
|
deviceID: dispatch.deviceID ?? null,
|
|
466
647
|
event: dispatch.event,
|
|
467
648
|
title,
|
|
@@ -493,6 +674,73 @@ function expoMessageForSubscription(
|
|
|
493
674
|
return message;
|
|
494
675
|
}
|
|
495
676
|
|
|
677
|
+
function fcmCallMessageForSubscription(
|
|
678
|
+
subscription: NotificationSubscription,
|
|
679
|
+
dispatch: NotificationDispatch,
|
|
680
|
+
): Record<string, unknown> {
|
|
681
|
+
return {
|
|
682
|
+
message: {
|
|
683
|
+
android: {
|
|
684
|
+
priority: "HIGH",
|
|
685
|
+
ttl: callWakeTtl(dispatch),
|
|
686
|
+
},
|
|
687
|
+
data: stringRecord({
|
|
688
|
+
...notifyDataFields(dispatch.data),
|
|
689
|
+
deviceID: dispatch.deviceID ?? "",
|
|
690
|
+
event: dispatch.event,
|
|
691
|
+
transmissionID: dispatch.transmissionID,
|
|
692
|
+
}),
|
|
693
|
+
token: subscription.token,
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function fetchFcmAccessToken(config: FcmConfig): Promise<string> {
|
|
699
|
+
const assertion = jwt.sign(
|
|
700
|
+
{
|
|
701
|
+
scope: "https://www.googleapis.com/auth/firebase.messaging",
|
|
702
|
+
},
|
|
703
|
+
config.privateKey,
|
|
704
|
+
{
|
|
705
|
+
algorithm: "RS256",
|
|
706
|
+
audience: FCM_TOKEN_ENDPOINT,
|
|
707
|
+
expiresIn: "55m",
|
|
708
|
+
issuer: config.clientEmail,
|
|
709
|
+
subject: config.clientEmail,
|
|
710
|
+
},
|
|
711
|
+
);
|
|
712
|
+
const body = new URLSearchParams({
|
|
713
|
+
assertion,
|
|
714
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
715
|
+
});
|
|
716
|
+
const res = await fetchWithTimeout(
|
|
717
|
+
FCM_TOKEN_ENDPOINT,
|
|
718
|
+
{
|
|
719
|
+
body,
|
|
720
|
+
headers: {
|
|
721
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
722
|
+
},
|
|
723
|
+
method: "POST",
|
|
724
|
+
},
|
|
725
|
+
"FCM OAuth token request",
|
|
726
|
+
);
|
|
727
|
+
if (!res.ok) {
|
|
728
|
+
const responseBody = await res.text().catch(() => "");
|
|
729
|
+
throw new Error(
|
|
730
|
+
`FCM OAuth token request failed with status ${res.status.toString()}${
|
|
731
|
+
responseBody ? `: ${responseBody.slice(0, 500)}` : ""
|
|
732
|
+
}`,
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
const raw = await res.json();
|
|
736
|
+
if (!isRecord(raw) || typeof raw["access_token"] !== "string") {
|
|
737
|
+
throw new Error(
|
|
738
|
+
"FCM OAuth token response did not include access_token",
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
return raw["access_token"];
|
|
742
|
+
}
|
|
743
|
+
|
|
496
744
|
async function fetchWithTimeout(
|
|
497
745
|
input: string,
|
|
498
746
|
init: RequestInit,
|
|
@@ -547,6 +795,24 @@ function normalizeExpoTickets(data: unknown): ExpoPushTicket[] {
|
|
|
547
795
|
return tickets.filter(isExpoPushTicket);
|
|
548
796
|
}
|
|
549
797
|
|
|
798
|
+
function normalizePrivateKey(value: string): string {
|
|
799
|
+
return value.replaceAll("\\n", "\n");
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function notifyDataFields(data: unknown): Record<string, unknown> {
|
|
803
|
+
if (!isRecord(data)) {
|
|
804
|
+
return {};
|
|
805
|
+
}
|
|
806
|
+
const out: Record<string, unknown> = {};
|
|
807
|
+
for (const key of ["callID", "expiresAt", "mailID", "mailNonce"]) {
|
|
808
|
+
const value = data[key];
|
|
809
|
+
if (typeof value === "string") {
|
|
810
|
+
out[key] = value;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return out;
|
|
814
|
+
}
|
|
815
|
+
|
|
550
816
|
function parseExpoPushResponse(raw: unknown): ParsedExpoPushResponse {
|
|
551
817
|
if (!isRecord(raw)) {
|
|
552
818
|
return { errors: [], tickets: [] };
|
|
@@ -578,6 +844,153 @@ function parseExpoReceipts(data: unknown): Record<string, ExpoPushReceipt> {
|
|
|
578
844
|
return receipts;
|
|
579
845
|
}
|
|
580
846
|
|
|
847
|
+
function readApnsConfig(): ApnsConfig | null {
|
|
848
|
+
const teamID = readEnv("SPIRE_APNS_TEAM_ID");
|
|
849
|
+
const keyID = readEnv("SPIRE_APNS_KEY_ID");
|
|
850
|
+
const bundleID = readEnv("SPIRE_APNS_BUNDLE_ID");
|
|
851
|
+
const topic = readEnv("SPIRE_APNS_TOPIC") ?? `${bundleID ?? ""}.voip`;
|
|
852
|
+
const privateKey = readPrivateKeyEnv(
|
|
853
|
+
"SPIRE_APNS_PRIVATE_KEY",
|
|
854
|
+
"SPIRE_APNS_PRIVATE_KEY_FILE",
|
|
855
|
+
);
|
|
856
|
+
if (!teamID || !keyID || !bundleID || !topic || !privateKey) {
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
const environment =
|
|
860
|
+
readEnv("SPIRE_APNS_ENV") === "sandbox" ? "sandbox" : "production";
|
|
861
|
+
return {
|
|
862
|
+
environment,
|
|
863
|
+
keyID,
|
|
864
|
+
privateKey,
|
|
865
|
+
teamID,
|
|
866
|
+
topic,
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function readEnv(name: string): null | string {
|
|
871
|
+
const value = process.env[name]?.trim();
|
|
872
|
+
return value && value.length > 0 ? value : null;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function readFcmConfig(): FcmConfig | null {
|
|
876
|
+
const json = readEnv("SPIRE_FCM_SERVICE_ACCOUNT_JSON");
|
|
877
|
+
if (json) {
|
|
878
|
+
try {
|
|
879
|
+
const raw = JSON.parse(json) as unknown;
|
|
880
|
+
if (
|
|
881
|
+
isRecord(raw) &&
|
|
882
|
+
typeof raw["client_email"] === "string" &&
|
|
883
|
+
typeof raw["private_key"] === "string" &&
|
|
884
|
+
typeof raw["project_id"] === "string"
|
|
885
|
+
) {
|
|
886
|
+
return {
|
|
887
|
+
clientEmail: raw["client_email"],
|
|
888
|
+
privateKey: normalizePrivateKey(raw["private_key"]),
|
|
889
|
+
projectID: raw["project_id"],
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
} catch {
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const clientEmail = readEnv("SPIRE_FCM_CLIENT_EMAIL");
|
|
898
|
+
const projectID = readEnv("SPIRE_FCM_PROJECT_ID");
|
|
899
|
+
const privateKey = readPrivateKeyEnv(
|
|
900
|
+
"SPIRE_FCM_PRIVATE_KEY",
|
|
901
|
+
"SPIRE_FCM_PRIVATE_KEY_FILE",
|
|
902
|
+
);
|
|
903
|
+
if (!clientEmail || !projectID || !privateKey) {
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
return { clientEmail, privateKey, projectID };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function readPrivateKeyEnv(valueName: string, fileName: string): null | string {
|
|
910
|
+
const inline = readEnv(valueName);
|
|
911
|
+
if (inline) {
|
|
912
|
+
return normalizePrivateKey(inline);
|
|
913
|
+
}
|
|
914
|
+
const file = readEnv(fileName);
|
|
915
|
+
if (!file) {
|
|
916
|
+
return null;
|
|
917
|
+
}
|
|
918
|
+
try {
|
|
919
|
+
return normalizePrivateKey(readFileSync(file, "utf8"));
|
|
920
|
+
} catch {
|
|
921
|
+
return null;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function sendApnsVoipNotification(
|
|
926
|
+
config: ApnsConfig,
|
|
927
|
+
providerToken: string,
|
|
928
|
+
subscription: NotificationSubscription,
|
|
929
|
+
dispatch: NotificationDispatch,
|
|
930
|
+
): Promise<ApnsResponse> {
|
|
931
|
+
const host =
|
|
932
|
+
config.environment === "sandbox"
|
|
933
|
+
? "api.sandbox.push.apple.com"
|
|
934
|
+
: "api.push.apple.com";
|
|
935
|
+
const payload = JSON.stringify({
|
|
936
|
+
aps: {
|
|
937
|
+
"content-available": 1,
|
|
938
|
+
},
|
|
939
|
+
...notifyDataFields(dispatch.data),
|
|
940
|
+
event: dispatch.event,
|
|
941
|
+
transmissionID: dispatch.transmissionID,
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
return new Promise<ApnsResponse>((resolve, reject) => {
|
|
945
|
+
const client = http2.connect(`https://${host}`);
|
|
946
|
+
const timer = setTimeout(() => {
|
|
947
|
+
client.close();
|
|
948
|
+
reject(
|
|
949
|
+
new Error(
|
|
950
|
+
`APNs VoIP request timed out after ${EXPO_REQUEST_TIMEOUT_MS.toString()}ms`,
|
|
951
|
+
),
|
|
952
|
+
);
|
|
953
|
+
}, EXPO_REQUEST_TIMEOUT_MS);
|
|
954
|
+
(timer as { unref?: () => void }).unref?.();
|
|
955
|
+
|
|
956
|
+
client.once("error", (err) => {
|
|
957
|
+
clearTimeout(timer);
|
|
958
|
+
reject(err);
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
const req = client.request({
|
|
962
|
+
":method": "POST",
|
|
963
|
+
":path": `/3/device/${subscription.token}`,
|
|
964
|
+
"apns-expiration": apnsExpiration(dispatch),
|
|
965
|
+
"apns-priority": "10",
|
|
966
|
+
"apns-push-type": "voip",
|
|
967
|
+
"apns-topic": config.topic,
|
|
968
|
+
authorization: `bearer ${providerToken}`,
|
|
969
|
+
});
|
|
970
|
+
let status = 0;
|
|
971
|
+
let body = "";
|
|
972
|
+
req.setEncoding("utf8");
|
|
973
|
+
req.on("response", (headers) => {
|
|
974
|
+
const rawStatus = headers[":status"];
|
|
975
|
+
status = typeof rawStatus === "number" ? rawStatus : 0;
|
|
976
|
+
});
|
|
977
|
+
req.on("data", (chunk: string) => {
|
|
978
|
+
body += chunk;
|
|
979
|
+
});
|
|
980
|
+
req.on("error", (err) => {
|
|
981
|
+
clearTimeout(timer);
|
|
982
|
+
client.close();
|
|
983
|
+
reject(err);
|
|
984
|
+
});
|
|
985
|
+
req.on("end", () => {
|
|
986
|
+
clearTimeout(timer);
|
|
987
|
+
client.close();
|
|
988
|
+
resolve({ body, status });
|
|
989
|
+
});
|
|
990
|
+
req.end(payload);
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
|
|
581
994
|
function shouldDeferMailPush(
|
|
582
995
|
dispatch: NotificationDispatch,
|
|
583
996
|
websocketDeliveries: number,
|
|
@@ -600,7 +1013,18 @@ function shouldSendHeadlessPush(dispatch: NotificationDispatch): boolean {
|
|
|
600
1013
|
);
|
|
601
1014
|
}
|
|
602
1015
|
|
|
1016
|
+
function stringRecord(input: Record<string, unknown>): Record<string, string> {
|
|
1017
|
+
const out: Record<string, string> = {};
|
|
1018
|
+
for (const [key, value] of Object.entries(input)) {
|
|
1019
|
+
if (typeof value === "string") {
|
|
1020
|
+
out[key] = value;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return out;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
603
1026
|
function titleForEvent(event: string): string {
|
|
1027
|
+
if (event === "callWake") return "Incoming Vex call";
|
|
604
1028
|
if (event === "callInvite") return "Incoming Vex call";
|
|
605
1029
|
if (event === "mail") return "New Message";
|
|
606
1030
|
if (event === "deviceRequest") return "Device approval request";
|
package/src/Spire.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { ClientManager } from "./ClientManager.ts";
|
|
|
48
48
|
import { Database, hashPasswordArgon2, verifyPassword } from "./Database.ts";
|
|
49
49
|
import { resolveIceServersFromEnv } from "./IceServers.ts";
|
|
50
50
|
import { NotificationService } from "./NotificationService.ts";
|
|
51
|
+
import { callWakeDispatchData } from "./server/callWake.ts";
|
|
51
52
|
import { initApp, protect } from "./server/index.ts";
|
|
52
53
|
import {
|
|
53
54
|
MailIngressValidationError,
|
|
@@ -133,8 +134,8 @@ interface ValidatedMailBatchEntry {
|
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
const notificationSubscribePayload = z.object({
|
|
136
|
-
channel: z.
|
|
137
|
-
events: z.array(z.string().min(1)).
|
|
137
|
+
channel: z.enum(["apnsVoip", "expo", "fcmCall"]),
|
|
138
|
+
events: z.array(z.string().min(1)).optional(),
|
|
138
139
|
platform: z.enum(["android", "ios", "web"]).optional(),
|
|
139
140
|
token: z.string().min(1),
|
|
140
141
|
});
|
|
@@ -871,15 +872,28 @@ export class Spire extends EventEmitter {
|
|
|
871
872
|
);
|
|
872
873
|
|
|
873
874
|
res.sendStatus(200);
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
875
|
+
const callWake = callWakeDispatchData(mail);
|
|
876
|
+
if (callWake) {
|
|
877
|
+
this.notify(
|
|
878
|
+
recipientDeviceDetails.owner,
|
|
879
|
+
"callWake",
|
|
880
|
+
crypto.randomUUID(),
|
|
881
|
+
callWake,
|
|
882
|
+
mail.recipient,
|
|
883
|
+
undefined,
|
|
884
|
+
mail.nonce,
|
|
885
|
+
);
|
|
886
|
+
} else {
|
|
887
|
+
this.notify(
|
|
888
|
+
recipientDeviceDetails.owner,
|
|
889
|
+
"mail",
|
|
890
|
+
crypto.randomUUID(),
|
|
891
|
+
null,
|
|
892
|
+
mail.recipient,
|
|
893
|
+
mail.authorID,
|
|
894
|
+
mail.nonce,
|
|
895
|
+
);
|
|
896
|
+
}
|
|
883
897
|
});
|
|
884
898
|
|
|
885
899
|
this.api.post("/mail/batch", protect, async (req, res) => {
|
|
@@ -989,15 +1003,28 @@ export class Spire extends EventEmitter {
|
|
|
989
1003
|
|
|
990
1004
|
res.send(msgpack.encode({ results }));
|
|
991
1005
|
for (const entry of deliveredEntries) {
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1006
|
+
const callWake = callWakeDispatchData(entry.mail);
|
|
1007
|
+
if (callWake) {
|
|
1008
|
+
this.notify(
|
|
1009
|
+
entry.recipientDevice.owner,
|
|
1010
|
+
"callWake",
|
|
1011
|
+
crypto.randomUUID(),
|
|
1012
|
+
callWake,
|
|
1013
|
+
entry.mail.recipient,
|
|
1014
|
+
undefined,
|
|
1015
|
+
entry.mail.nonce,
|
|
1016
|
+
);
|
|
1017
|
+
} else {
|
|
1018
|
+
this.notify(
|
|
1019
|
+
entry.recipientDevice.owner,
|
|
1020
|
+
"mail",
|
|
1021
|
+
crypto.randomUUID(),
|
|
1022
|
+
null,
|
|
1023
|
+
entry.mail.recipient,
|
|
1024
|
+
entry.mail.authorID,
|
|
1025
|
+
entry.mail.nonce,
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1001
1028
|
}
|
|
1002
1029
|
});
|
|
1003
1030
|
|
|
@@ -1043,7 +1070,11 @@ export class Spire extends EventEmitter {
|
|
|
1043
1070
|
{
|
|
1044
1071
|
channel: parsed.data.channel,
|
|
1045
1072
|
deviceID: device.deviceID,
|
|
1046
|
-
events:
|
|
1073
|
+
events:
|
|
1074
|
+
parsed.data.events ??
|
|
1075
|
+
(parsed.data.channel === "expo"
|
|
1076
|
+
? ["mail"]
|
|
1077
|
+
: ["callWake"]),
|
|
1047
1078
|
platform: parsed.data.platform ?? null,
|
|
1048
1079
|
token: parsed.data.token,
|
|
1049
1080
|
userID: getUser(req).userID,
|