@fonoster/sipnet 0.39.0 → 0.42.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/numbers/buildService.d.ts +9 -3
- package/dist/numbers/buildService.js +30 -5
- package/dist/numbers/linkShared.d.ts +12 -0
- package/dist/numbers/linkShared.js +75 -0
- package/dist/numbers/linkTwilio.js +4 -33
- package/dist/numbers/linkWhatsApp.d.ts +40 -0
- package/dist/numbers/linkWhatsApp.js +257 -0
- package/dist/numbers/whatsAppLinkErrorToGrpc.d.ts +29 -0
- package/dist/numbers/whatsAppLinkErrorToGrpc.js +57 -0
- package/package.json +7 -6
|
@@ -19,13 +19,16 @@
|
|
|
19
19
|
import { NumberPreconditionsCheck } from "@fonoster/common";
|
|
20
20
|
import { ClientOptions } from "../types";
|
|
21
21
|
import { NumberRouting } from "./engineRouting";
|
|
22
|
+
import { SipEndpoint } from "./linkWhatsApp";
|
|
22
23
|
/**
|
|
23
|
-
* `routing` picks the peer app-linked numbers point at (default: the roomr peer). `
|
|
24
|
-
* is the install
|
|
24
|
+
* `routing` picks the peer app-linked numbers point at (default: the roomr peer). `carrierLinks`
|
|
25
|
+
* is what the carrier links read from the install: its PSTN root, its public signaling addresses
|
|
26
|
+
* (LinkTwilio) and its public SIP endpoints (LinkWhatsApp takes the TLS one).
|
|
25
27
|
*/
|
|
26
|
-
declare function buildService(clientOptions: ClientOptions, checkNumberPreconditions: NumberPreconditionsCheck, routing?: NumberRouting,
|
|
28
|
+
declare function buildService(clientOptions: ClientOptions, checkNumberPreconditions: NumberPreconditionsCheck, routing?: NumberRouting, carrierLinks?: {
|
|
27
29
|
pstnRoot: string;
|
|
28
30
|
signalingAddresses: string[];
|
|
31
|
+
sipEndpoints?: SipEndpoint[];
|
|
29
32
|
}): {
|
|
30
33
|
definition: {
|
|
31
34
|
serviceName: string;
|
|
@@ -52,6 +55,9 @@ declare function buildService(clientOptions: ClientOptions, checkNumberPrecondit
|
|
|
52
55
|
linkTwilio: (call: {
|
|
53
56
|
request: unknown;
|
|
54
57
|
}, callback: (error?: import("@fonoster/common").GrpcErrorMessage, response?: unknown) => void) => Promise<void>;
|
|
58
|
+
linkWhatsApp: (call: {
|
|
59
|
+
request: unknown;
|
|
60
|
+
}, callback: (error?: import("@fonoster/common").GrpcErrorMessage, response?: unknown) => void) => Promise<void>;
|
|
55
61
|
};
|
|
56
62
|
};
|
|
57
63
|
export { buildService };
|
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __rest = (this && this.__rest) || function (s, e) {
|
|
3
|
+
var t = {};
|
|
4
|
+
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
|
|
5
|
+
t[p] = s[p];
|
|
6
|
+
if (s != null && typeof Object.getOwnPropertySymbols === "function")
|
|
7
|
+
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
|
|
8
|
+
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
|
|
9
|
+
t[p[i]] = s[p[i]];
|
|
10
|
+
}
|
|
11
|
+
return t;
|
|
12
|
+
};
|
|
2
13
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
14
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
15
|
};
|
|
@@ -9,17 +20,21 @@ const checkRefs_1 = require("../resources/checkRefs");
|
|
|
9
20
|
const createNumber_1 = require("./createNumber");
|
|
10
21
|
const getNumber_1 = require("./getNumber");
|
|
11
22
|
const linkTwilio_1 = require("./linkTwilio");
|
|
23
|
+
const linkWhatsApp_1 = require("./linkWhatsApp");
|
|
12
24
|
const listNumbers_1 = require("./listNumbers");
|
|
13
25
|
const operations_1 = require("./operations");
|
|
14
26
|
const updateNumber_1 = require("./updateNumber");
|
|
15
27
|
/**
|
|
16
|
-
* `routing` picks the peer app-linked numbers point at (default: the roomr peer). `
|
|
17
|
-
* is the install
|
|
28
|
+
* `routing` picks the peer app-linked numbers point at (default: the roomr peer). `carrierLinks`
|
|
29
|
+
* is what the carrier links read from the install: its PSTN root, its public signaling addresses
|
|
30
|
+
* (LinkTwilio) and its public SIP endpoints (LinkWhatsApp takes the TLS one).
|
|
18
31
|
*/
|
|
19
|
-
function buildService(clientOptions, checkNumberPreconditions, routing,
|
|
32
|
+
function buildService(clientOptions, checkNumberPreconditions, routing, carrierLinks = {
|
|
20
33
|
pstnRoot: "",
|
|
21
|
-
signalingAddresses: []
|
|
34
|
+
signalingAddresses: [],
|
|
35
|
+
sipEndpoints: []
|
|
22
36
|
}) {
|
|
37
|
+
const { sipEndpoints = [] } = carrierLinks, twilioLink = __rest(carrierLinks, ["sipEndpoints"]);
|
|
23
38
|
const client = new sdk_1.default.Numbers(clientOptions);
|
|
24
39
|
const checkRefs = (0, checkRefs_1.createCheckRefs)((0, checkRefs_1.createRefLookups)(clientOptions));
|
|
25
40
|
return {
|
|
@@ -37,7 +52,17 @@ function buildService(clientOptions, checkNumberPreconditions, routing, twilioLi
|
|
|
37
52
|
deleteNumber: (0, operations_1.deleteNumber)(client),
|
|
38
53
|
linkTwilio: (0, linkTwilio_1.linkTwilio)(Object.assign({ numbers: client, trunks: new sdk_1.default.Trunks(clientOptions), credentials: new sdk_1.default.Credentials(clientOptions), checkNumberPreconditions,
|
|
39
54
|
routing,
|
|
40
|
-
checkRefs }, twilioLink))
|
|
55
|
+
checkRefs }, twilioLink)),
|
|
56
|
+
linkWhatsApp: (0, linkWhatsApp_1.linkWhatsApp)({
|
|
57
|
+
numbers: client,
|
|
58
|
+
trunks: new sdk_1.default.Trunks(clientOptions),
|
|
59
|
+
credentials: new sdk_1.default.Credentials(clientOptions),
|
|
60
|
+
checkNumberPreconditions,
|
|
61
|
+
routing,
|
|
62
|
+
checkRefs,
|
|
63
|
+
pstnRoot: twilioLink.pstnRoot,
|
|
64
|
+
sipEndpoints
|
|
65
|
+
})
|
|
41
66
|
}
|
|
42
67
|
};
|
|
43
68
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ListRequest, ListResponse } from "@fonoster/types";
|
|
2
|
+
/** A 24-character SIP password with at least one upper, one lower and one digit. */
|
|
3
|
+
declare function generatePassword(): string;
|
|
4
|
+
type Listed = {
|
|
5
|
+
extended?: Record<string, unknown>;
|
|
6
|
+
};
|
|
7
|
+
type Lister<T> = (request: ListRequest) => Promise<ListResponse<T>>;
|
|
8
|
+
/** The caller's first resource that matches. */
|
|
9
|
+
declare function findOwned<T extends Listed>(list: Lister<T>, accessKeyId: string, match: (item: T) => boolean): Promise<T | undefined>;
|
|
10
|
+
/** The first resource of any workspace that matches (Routr lists every workspace's items). */
|
|
11
|
+
declare function findAny<T extends Listed>(list: Lister<T>, match: (item: T) => boolean): Promise<T | undefined>;
|
|
12
|
+
export { findAny, findOwned, generatePassword };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.findAny = findAny;
|
|
13
|
+
exports.findOwned = findOwned;
|
|
14
|
+
exports.generatePassword = generatePassword;
|
|
15
|
+
/**
|
|
16
|
+
* Copyright (C) 2025 by Fonoster Inc (https://fonoster.com)
|
|
17
|
+
* http://github.com/fonoster/fonoster
|
|
18
|
+
*
|
|
19
|
+
* This file is part of Fonoster
|
|
20
|
+
*
|
|
21
|
+
* Licensed under the MIT License (the "License");
|
|
22
|
+
* you may not use this file except in compliance with
|
|
23
|
+
* the License. You may obtain a copy of the License at
|
|
24
|
+
*
|
|
25
|
+
* https://opensource.org/licenses/MIT
|
|
26
|
+
*
|
|
27
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
28
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
29
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
30
|
+
* See the License for the specific language governing permissions and
|
|
31
|
+
* limitations under the License.
|
|
32
|
+
*/
|
|
33
|
+
const node_crypto_1 = require("node:crypto");
|
|
34
|
+
const paginationUtils_1 = require("../resources/paginationUtils");
|
|
35
|
+
const PAGE_SIZE = 100;
|
|
36
|
+
const PASSWORD_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
37
|
+
/** A 24-character SIP password with at least one upper, one lower and one digit. */
|
|
38
|
+
function generatePassword() {
|
|
39
|
+
for (;;) {
|
|
40
|
+
const password = Array.from({ length: 24 }, () => PASSWORD_ALPHABET[(0, node_crypto_1.randomInt)(PASSWORD_ALPHABET.length)]).join("");
|
|
41
|
+
if (/[A-Z]/.test(password) &&
|
|
42
|
+
/[a-z]/.test(password) &&
|
|
43
|
+
/\d/.test(password)) {
|
|
44
|
+
return password;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** The first item that matches, through every page Routr has, with or without a workspace filter. */
|
|
49
|
+
function findWhere(list, match, accessKeyId) {
|
|
50
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
51
|
+
let pageToken;
|
|
52
|
+
for (;;) {
|
|
53
|
+
const { items = [], nextPageToken } = yield list({
|
|
54
|
+
pageSize: PAGE_SIZE,
|
|
55
|
+
pageToken
|
|
56
|
+
});
|
|
57
|
+
const candidates = accessKeyId
|
|
58
|
+
? (0, paginationUtils_1.filterByAccessKeyId)(items, accessKeyId)
|
|
59
|
+
: items;
|
|
60
|
+
const found = candidates.find(match);
|
|
61
|
+
if (found || !nextPageToken || items.length < PAGE_SIZE) {
|
|
62
|
+
return found;
|
|
63
|
+
}
|
|
64
|
+
pageToken = nextPageToken;
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/** The caller's first resource that matches. */
|
|
69
|
+
function findOwned(list, accessKeyId, match) {
|
|
70
|
+
return findWhere(list, match, accessKeyId);
|
|
71
|
+
}
|
|
72
|
+
/** The first resource of any workspace that matches (Routr lists every workspace's items). */
|
|
73
|
+
function findAny(list, match) {
|
|
74
|
+
return findWhere(list, match);
|
|
75
|
+
}
|
|
@@ -39,47 +39,18 @@ const types_1 = require("@fonoster/types");
|
|
|
39
39
|
const grpc_js_1 = require("@grpc/grpc-js");
|
|
40
40
|
const phone_1 = require("phone");
|
|
41
41
|
const checkRefs_1 = require("../resources/checkRefs");
|
|
42
|
-
const paginationUtils_1 = require("../resources/paginationUtils");
|
|
43
42
|
const operations_1 = require("../trunks/operations");
|
|
44
43
|
const convertToRoutrNumber_1 = require("./convertToRoutrNumber");
|
|
44
|
+
const linkShared_1 = require("./linkShared");
|
|
45
45
|
const twilioLinkErrorToGrpc_1 = require("./twilioLinkErrorToGrpc");
|
|
46
46
|
const logger = (0, logger_1.getLogger)({ service: "sipnet", filePath: __filename });
|
|
47
47
|
const TWILIO_PSTN_URI_BASE = "pstn.twilio.com";
|
|
48
|
-
const PAGE_SIZE = 100;
|
|
49
|
-
const PASSWORD_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
50
48
|
const isIpv4Entry = (address) => {
|
|
51
49
|
const [ip, prefix, ...rest] = address.split("/");
|
|
52
50
|
return (rest.length === 0 &&
|
|
53
51
|
node_net_1.default.isIPv4(ip) &&
|
|
54
52
|
(prefix === undefined || (/^\d{1,2}$/.test(prefix) && +prefix <= 32)));
|
|
55
53
|
};
|
|
56
|
-
function generatePassword() {
|
|
57
|
-
for (;;) {
|
|
58
|
-
const password = Array.from({ length: 24 }, () => PASSWORD_ALPHABET[(0, node_crypto_1.randomInt)(PASSWORD_ALPHABET.length)]).join("");
|
|
59
|
-
if (/[A-Z]/.test(password) &&
|
|
60
|
-
/[a-z]/.test(password) &&
|
|
61
|
-
/\d/.test(password)) {
|
|
62
|
-
return password;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
// The caller's first resource that matches, through every page Routr has.
|
|
67
|
-
function findOwned(list, accessKeyId, match) {
|
|
68
|
-
return __awaiter(this, void 0, void 0, function* () {
|
|
69
|
-
let pageToken;
|
|
70
|
-
for (;;) {
|
|
71
|
-
const { items = [], nextPageToken } = yield list({
|
|
72
|
-
pageSize: PAGE_SIZE,
|
|
73
|
-
pageToken
|
|
74
|
-
});
|
|
75
|
-
const found = (0, paginationUtils_1.filterByAccessKeyId)(items, accessKeyId).find(match);
|
|
76
|
-
if (found || !nextPageToken || items.length < PAGE_SIZE) {
|
|
77
|
-
return found;
|
|
78
|
-
}
|
|
79
|
-
pageToken = nextPageToken;
|
|
80
|
-
}
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
54
|
function linkTwilio(deps) {
|
|
84
55
|
const { numbers, trunks, credentials, checkNumberPreconditions, routing, checkRefs, pstnRoot, twilio = {
|
|
85
56
|
provisionTwilioTrunk: common_1.provisionTwilioTrunk,
|
|
@@ -129,7 +100,7 @@ function linkTwilio(deps) {
|
|
|
129
100
|
}
|
|
130
101
|
const sipCredential = {
|
|
131
102
|
username: `fonoster-${(0, node_crypto_1.randomBytes)(4).toString("hex")}`,
|
|
132
|
-
password: generatePassword()
|
|
103
|
+
password: (0, linkShared_1.generatePassword)()
|
|
133
104
|
};
|
|
134
105
|
let twilioTrunkSid;
|
|
135
106
|
try {
|
|
@@ -146,7 +117,7 @@ function linkTwilio(deps) {
|
|
|
146
117
|
return;
|
|
147
118
|
}
|
|
148
119
|
const { ref: credentialsRef } = yield credentials.createCredentials(Object.assign(Object.assign({ name: `Twilio ${label}` }, sipCredential), { extended: { accessKeyId } }));
|
|
149
|
-
const trunk = yield findOwned((r) => trunks.listTrunks(r), accessKeyId, (t) => t.inboundUri === inboundUri);
|
|
120
|
+
const trunk = yield (0, linkShared_1.findOwned)((r) => trunks.listTrunks(r), accessKeyId, (t) => t.inboundUri === inboundUri);
|
|
150
121
|
let trunkRef;
|
|
151
122
|
if (trunk) {
|
|
152
123
|
yield trunks.updateTrunk((0, operations_1.mergeTrunkUpdate)({ ref: trunk.ref, outboundCredentialsRef: credentialsRef }, trunk));
|
|
@@ -171,7 +142,7 @@ function linkTwilio(deps) {
|
|
|
171
142
|
extended: { accessKeyId }
|
|
172
143
|
}));
|
|
173
144
|
}
|
|
174
|
-
const number = yield findOwned((r) => numbers.listNumbers(r), accessKeyId, (n) => n.telUrl === telUrl);
|
|
145
|
+
const number = yield (0, linkShared_1.findOwned)((r) => numbers.listNumbers(r), accessKeyId, (n) => n.telUrl === telUrl);
|
|
175
146
|
const appPeerAor = routing && appRef ? yield routing.aorFor({ appRef, telUrl }) : undefined;
|
|
176
147
|
let ref;
|
|
177
148
|
if (number) {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { enableWhatsAppSipCalling, GrpcErrorMessage, inspectWhatsAppNumber, NumberPreconditionsCheck } from "@fonoster/common";
|
|
2
|
+
import { CredentialsApi, NumbersApi, TrunkApi } from "@fonoster/types";
|
|
3
|
+
import { CheckRefs } from "../resources/checkRefs";
|
|
4
|
+
import { NumberRouting } from "./engineRouting";
|
|
5
|
+
/** A public SIP endpoint of the install, as `regions.public[].endpoints.sip` declares it. */
|
|
6
|
+
type SipEndpoint = {
|
|
7
|
+
host: string;
|
|
8
|
+
port: number;
|
|
9
|
+
transport: string;
|
|
10
|
+
};
|
|
11
|
+
type WhatsAppLink = {
|
|
12
|
+
inspectWhatsAppNumber: typeof inspectWhatsAppNumber;
|
|
13
|
+
enableWhatsAppSipCalling: typeof enableWhatsAppSipCalling;
|
|
14
|
+
};
|
|
15
|
+
type LinkWhatsAppDeps = {
|
|
16
|
+
numbers: NumbersApi;
|
|
17
|
+
trunks: TrunkApi;
|
|
18
|
+
credentials: CredentialsApi;
|
|
19
|
+
checkNumberPreconditions: NumberPreconditionsCheck;
|
|
20
|
+
routing?: NumberRouting;
|
|
21
|
+
checkRefs?: CheckRefs;
|
|
22
|
+
/** Where carriers reach this install's trunks (`pstn.<domain>`). */
|
|
23
|
+
pstnRoot: string;
|
|
24
|
+
/** The install's public SIP endpoints; the TLS one is what the org registers in Meta. */
|
|
25
|
+
sipEndpoints: SipEndpoint[];
|
|
26
|
+
whatsapp?: WhatsAppLink;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Links a WhatsApp Business number: one credential, one TLS trunk to Meta and the Number, all per
|
|
30
|
+
* number, since Meta issues one SIP password per number and a Routr trunk holds one outbound
|
|
31
|
+
* credential. With an access token, Fonoster reads the number from Meta, turns on SIP calling
|
|
32
|
+
* pointed at this install with SDES and keeps the password Meta issues; the token is used for
|
|
33
|
+
* those calls only. Without one, Fonoster calls no Meta API: the response carries the hostname
|
|
34
|
+
* the org registers in WhatsApp Manager, and the Meta-issued password arrives as `sipPassword`,
|
|
35
|
+
* now or on a later call.
|
|
36
|
+
*/
|
|
37
|
+
declare function linkWhatsApp(deps: LinkWhatsAppDeps): (call: {
|
|
38
|
+
request: unknown;
|
|
39
|
+
}, callback: (error?: import("@fonoster/common").GrpcErrorMessage, response?: unknown) => void) => Promise<void>;
|
|
40
|
+
export { linkWhatsApp, LinkWhatsAppDeps, SipEndpoint, WhatsAppLink };
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.linkWhatsApp = linkWhatsApp;
|
|
16
|
+
/**
|
|
17
|
+
* Copyright (C) 2025 by Fonoster Inc (https://fonoster.com)
|
|
18
|
+
* http://github.com/fonoster/fonoster
|
|
19
|
+
*
|
|
20
|
+
* This file is part of Fonoster
|
|
21
|
+
*
|
|
22
|
+
* Licensed under the MIT License (the "License");
|
|
23
|
+
* you may not use this file except in compliance with
|
|
24
|
+
* the License. You may obtain a copy of the License at
|
|
25
|
+
*
|
|
26
|
+
* https://opensource.org/licenses/MIT
|
|
27
|
+
*
|
|
28
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
29
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
30
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
31
|
+
* See the License for the specific language governing permissions and
|
|
32
|
+
* limitations under the License.
|
|
33
|
+
*/
|
|
34
|
+
const node_net_1 = __importDefault(require("node:net"));
|
|
35
|
+
const common_1 = require("@fonoster/common");
|
|
36
|
+
const logger_1 = require("@fonoster/logger");
|
|
37
|
+
const types_1 = require("@fonoster/types");
|
|
38
|
+
const grpc_js_1 = require("@grpc/grpc-js");
|
|
39
|
+
const phone_1 = require("phone");
|
|
40
|
+
const checkRefs_1 = require("../resources/checkRefs");
|
|
41
|
+
const operations_1 = require("../trunks/operations");
|
|
42
|
+
const convertToRoutrNumber_1 = require("./convertToRoutrNumber");
|
|
43
|
+
const linkShared_1 = require("./linkShared");
|
|
44
|
+
const whatsAppLinkErrorToGrpc_1 = require("./whatsAppLinkErrorToGrpc");
|
|
45
|
+
const logger = (0, logger_1.getLogger)({ service: "sipnet", filePath: __filename });
|
|
46
|
+
/** Meta's SIP edge for WhatsApp Business Calling. */
|
|
47
|
+
const META_SIP_HOST = "wa.meta.vc";
|
|
48
|
+
const META_SIP_PORT = 5061;
|
|
49
|
+
/** Routr's media relay negotiates SRTP with SDES toward trunks over TLS (routr-rtprelay.yaml). */
|
|
50
|
+
const SRTP_KEY_EXCHANGE = "SDES";
|
|
51
|
+
const isTlsHostname = (endpoint) => { var _a; return ((_a = endpoint.transport) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === "tls" && node_net_1.default.isIP(endpoint.host) === 0; };
|
|
52
|
+
/**
|
|
53
|
+
* Links a WhatsApp Business number: one credential, one TLS trunk to Meta and the Number, all per
|
|
54
|
+
* number, since Meta issues one SIP password per number and a Routr trunk holds one outbound
|
|
55
|
+
* credential. With an access token, Fonoster reads the number from Meta, turns on SIP calling
|
|
56
|
+
* pointed at this install with SDES and keeps the password Meta issues; the token is used for
|
|
57
|
+
* those calls only. Without one, Fonoster calls no Meta API: the response carries the hostname
|
|
58
|
+
* the org registers in WhatsApp Manager, and the Meta-issued password arrives as `sipPassword`,
|
|
59
|
+
* now or on a later call.
|
|
60
|
+
*/
|
|
61
|
+
function linkWhatsApp(deps) {
|
|
62
|
+
const { numbers, trunks, credentials, checkNumberPreconditions, routing, checkRefs, pstnRoot, sipEndpoints, whatsapp = { inspectWhatsAppNumber: common_1.inspectWhatsAppNumber, enableWhatsAppSipCalling: common_1.enableWhatsAppSipCalling } } = deps;
|
|
63
|
+
const fn = (call, callback) => __awaiter(this, void 0, void 0, function* () {
|
|
64
|
+
var _a, _b, _c, _d, _e;
|
|
65
|
+
const { request } = call;
|
|
66
|
+
const { appRef, agentAor } = request;
|
|
67
|
+
const meta = request.accessToken && request.phoneNumberId
|
|
68
|
+
? {
|
|
69
|
+
credentials: { accessToken: request.accessToken },
|
|
70
|
+
phoneNumberId: request.phoneNumberId.trim()
|
|
71
|
+
}
|
|
72
|
+
: undefined;
|
|
73
|
+
const accessKeyId = (0, common_1.getAccessKeyIdFromCall)(call);
|
|
74
|
+
const label = accessKeyId.toLowerCase();
|
|
75
|
+
// Never the request itself: it may carry the SIP password or the access token.
|
|
76
|
+
logger.verbose("call to linkWhatsApp", {
|
|
77
|
+
phoneNumber: request.phoneNumber,
|
|
78
|
+
phoneNumberId: meta === null || meta === void 0 ? void 0 : meta.phoneNumberId,
|
|
79
|
+
label
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
// Validates that the appRef exists in the caller's workspace (RV-B4)
|
|
83
|
+
yield checkNumberPreconditions({ appRef, accessKeyId });
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
// The contract reports an app outside the workspace as NOT_FOUND, with the usual message.
|
|
87
|
+
if (e instanceof common_1.GrpcError && e.code === grpc_js_1.status.INVALID_ARGUMENT) {
|
|
88
|
+
callback({ code: grpc_js_1.status.NOT_FOUND, message: e.message });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
93
|
+
yield (checkRefs === null || checkRefs === void 0 ? void 0 : checkRefs(accessKeyId, (0, checkRefs_1.numberRefs)(request)));
|
|
94
|
+
const tlsEndpoint = sipEndpoints.find(isTlsHostname);
|
|
95
|
+
if (!tlsEndpoint) {
|
|
96
|
+
callback({
|
|
97
|
+
code: grpc_js_1.status.FAILED_PRECONDITION,
|
|
98
|
+
message: "This install has no public SIP over TLS address for WhatsApp to send calls to"
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// With a token, Meta's record of the number is the number: it's what Meta will send.
|
|
103
|
+
let phoneNumber = request.phoneNumber;
|
|
104
|
+
let verifiedName = "";
|
|
105
|
+
if (meta) {
|
|
106
|
+
try {
|
|
107
|
+
const info = yield whatsapp.inspectWhatsAppNumber(meta.credentials, {
|
|
108
|
+
phoneNumberId: meta.phoneNumberId
|
|
109
|
+
});
|
|
110
|
+
phoneNumber = info.phoneNumber;
|
|
111
|
+
verifiedName = (_a = info.verifiedName) !== null && _a !== void 0 ? _a : "";
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
callback((0, whatsAppLinkErrorToGrpc_1.whatsAppLinkErrorToGrpc)(e, {
|
|
115
|
+
phoneNumberId: meta.phoneNumberId,
|
|
116
|
+
hostname: ""
|
|
117
|
+
}));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const digits = phoneNumber.replace(/^\+/, "");
|
|
122
|
+
const inboundUri = `wa-${label}-${digits}.${pstnRoot}`;
|
|
123
|
+
const telUrl = `tel:${phoneNumber}`;
|
|
124
|
+
// Routr lists every workspace's items: a host or number another workspace owns stops the link
|
|
125
|
+
// before any write, in Meta or Routr (Routr's inbound_uri and tel_url are unique).
|
|
126
|
+
const foreignTrunk = yield (0, linkShared_1.findAny)((r) => trunks.listTrunks(r), (t) => t.inboundUri === inboundUri);
|
|
127
|
+
if (foreignTrunk && ((_b = foreignTrunk.extended) === null || _b === void 0 ? void 0 : _b.accessKeyId) !== accessKeyId) {
|
|
128
|
+
callback({
|
|
129
|
+
code: grpc_js_1.status.FAILED_PRECONDITION,
|
|
130
|
+
message: `${inboundUri} is already in use`
|
|
131
|
+
});
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const foreignNumber = yield (0, linkShared_1.findAny)((r) => numbers.listNumbers(r), (n) => n.telUrl === telUrl);
|
|
135
|
+
if (foreignNumber && ((_c = foreignNumber.extended) === null || _c === void 0 ? void 0 : _c.accessKeyId) !== accessKeyId) {
|
|
136
|
+
callback({
|
|
137
|
+
code: grpc_js_1.status.FAILED_PRECONDITION,
|
|
138
|
+
message: `${phoneNumber} is already linked in another workspace`
|
|
139
|
+
});
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// Meta first: if it refuses, nothing in Routr has changed. A trunk pointed at by Meta that
|
|
143
|
+
// Routr then fails to create is fixed by linking again.
|
|
144
|
+
let sipPassword = request.sipPassword;
|
|
145
|
+
if (meta) {
|
|
146
|
+
try {
|
|
147
|
+
({ sipPassword } = yield whatsapp.enableWhatsAppSipCalling(meta.credentials, {
|
|
148
|
+
phoneNumberId: meta.phoneNumberId,
|
|
149
|
+
sipServer: { hostname: inboundUri, port: tlsEndpoint.port },
|
|
150
|
+
srtpKeyExchange: SRTP_KEY_EXCHANGE
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
callback((0, whatsAppLinkErrorToGrpc_1.whatsAppLinkErrorToGrpc)(e, {
|
|
155
|
+
phoneNumberId: meta.phoneNumberId,
|
|
156
|
+
hostname: inboundUri
|
|
157
|
+
}));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const trunk = foreignTrunk;
|
|
162
|
+
const credentialName = `WhatsApp ${phoneNumber}`;
|
|
163
|
+
// The credential: the trunk's, updated in place, or a new one
|
|
164
|
+
let credentialsRef;
|
|
165
|
+
let sipPasswordSet;
|
|
166
|
+
if (trunk === null || trunk === void 0 ? void 0 : trunk.outboundCredentialsRef) {
|
|
167
|
+
credentialsRef = trunk.outboundCredentialsRef;
|
|
168
|
+
const current = yield credentials.getCredentials(credentialsRef);
|
|
169
|
+
const update = {
|
|
170
|
+
ref: credentialsRef,
|
|
171
|
+
name: credentialName,
|
|
172
|
+
username: digits,
|
|
173
|
+
extended: Object.assign(Object.assign(Object.assign({}, ((_d = current.extended) !== null && _d !== void 0 ? _d : {})), { accessKeyId }), (sipPassword ? { sipPasswordSet: true } : {}))
|
|
174
|
+
};
|
|
175
|
+
if (sipPassword)
|
|
176
|
+
update.password = sipPassword;
|
|
177
|
+
yield credentials.updateCredentials(update);
|
|
178
|
+
sipPasswordSet =
|
|
179
|
+
Boolean(sipPassword) || ((_e = current.extended) === null || _e === void 0 ? void 0 : _e.sipPasswordSet) === true;
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
({ ref: credentialsRef } = yield credentials.createCredentials({
|
|
183
|
+
name: credentialName,
|
|
184
|
+
username: digits,
|
|
185
|
+
password: sipPassword !== null && sipPassword !== void 0 ? sipPassword : (0, linkShared_1.generatePassword)(),
|
|
186
|
+
extended: Object.assign({ accessKeyId }, (sipPassword ? { sipPasswordSet: true } : {}))
|
|
187
|
+
}));
|
|
188
|
+
sipPasswordSet = Boolean(sipPassword);
|
|
189
|
+
}
|
|
190
|
+
const routrTrunkFields = {
|
|
191
|
+
normalizeToE164: true,
|
|
192
|
+
rewriteFromWithNumber: true,
|
|
193
|
+
fromDomain: inboundUri
|
|
194
|
+
};
|
|
195
|
+
let trunkRef;
|
|
196
|
+
if (trunk) {
|
|
197
|
+
yield trunks.updateTrunk(Object.assign(Object.assign(Object.assign({}, (0, operations_1.mergeTrunkUpdate)({ ref: trunk.ref, outboundCredentialsRef: credentialsRef }, trunk)), { inboundCredentialsRef: credentialsRef }), routrTrunkFields));
|
|
198
|
+
trunkRef = trunk.ref;
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
({ ref: trunkRef } = yield trunks.createTrunk(Object.assign(Object.assign({ name: `WhatsApp Trunk ${phoneNumber}`, inboundUri, sendRegister: false, inboundCredentialsRef: credentialsRef, outboundCredentialsRef: credentialsRef, uris: [
|
|
202
|
+
{
|
|
203
|
+
host: META_SIP_HOST,
|
|
204
|
+
port: META_SIP_PORT,
|
|
205
|
+
transport: types_1.Transport.TLS,
|
|
206
|
+
user: digits,
|
|
207
|
+
enabled: true,
|
|
208
|
+
weight: 10,
|
|
209
|
+
priority: 10
|
|
210
|
+
}
|
|
211
|
+
] }, routrTrunkFields), { extended: { accessKeyId } })));
|
|
212
|
+
}
|
|
213
|
+
const number = yield (0, linkShared_1.findOwned)((r) => numbers.listNumbers(r), accessKeyId, (n) => n.telUrl === telUrl);
|
|
214
|
+
const appPeerAor = routing && appRef ? yield routing.aorFor({ appRef, telUrl }) : undefined;
|
|
215
|
+
let ref;
|
|
216
|
+
if (number) {
|
|
217
|
+
yield numbers.updateNumber((0, convertToRoutrNumber_1.convertToRoutrNumberUpdate)({
|
|
218
|
+
ref: number.ref,
|
|
219
|
+
name: request.name || number.name,
|
|
220
|
+
trunkRef,
|
|
221
|
+
appRef,
|
|
222
|
+
agentAor
|
|
223
|
+
}, appPeerAor));
|
|
224
|
+
ref = number.ref;
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
const { countryIso2, countryIso3 } = (0, phone_1.phone)(phoneNumber);
|
|
228
|
+
({ ref } = yield numbers.createNumber((0, convertToRoutrNumber_1.convertToRoutrNumber)({
|
|
229
|
+
name: request.name || verifiedName || phoneNumber,
|
|
230
|
+
telUrl,
|
|
231
|
+
city: "unknown",
|
|
232
|
+
country: countryIso3,
|
|
233
|
+
countryIsoCode: countryIso2,
|
|
234
|
+
trunkRef,
|
|
235
|
+
appRef,
|
|
236
|
+
agentAor
|
|
237
|
+
}, accessKeyId, appPeerAor)));
|
|
238
|
+
}
|
|
239
|
+
logger.verbose("linked the WhatsApp number", {
|
|
240
|
+
phoneNumber,
|
|
241
|
+
label,
|
|
242
|
+
sipPasswordSet,
|
|
243
|
+
metaConfigured: Boolean(meta)
|
|
244
|
+
});
|
|
245
|
+
callback(null, {
|
|
246
|
+
ref,
|
|
247
|
+
trunkRef,
|
|
248
|
+
sipServerHostname: inboundUri,
|
|
249
|
+
sipServerPort: tlsEndpoint.port,
|
|
250
|
+
sipPasswordSet,
|
|
251
|
+
phoneNumber,
|
|
252
|
+
verifiedName,
|
|
253
|
+
metaConfigured: Boolean(meta)
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
return (0, common_1.withErrorHandlingAndValidation)(fn, common_1.Validators.linkWhatsAppRequestSchema);
|
|
257
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (C) 2025 by Fonoster Inc (https://fonoster.com)
|
|
3
|
+
* http://github.com/fonoster/fonoster
|
|
4
|
+
*
|
|
5
|
+
* This file is part of Fonoster
|
|
6
|
+
*
|
|
7
|
+
* Licensed under the MIT License (the "License");
|
|
8
|
+
* you may not use this file except in compliance with
|
|
9
|
+
* the License. You may obtain a copy of the License at
|
|
10
|
+
*
|
|
11
|
+
* https://opensource.org/licenses/MIT
|
|
12
|
+
*
|
|
13
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
* See the License for the specific language governing permissions and
|
|
17
|
+
* limitations under the License.
|
|
18
|
+
*/
|
|
19
|
+
import { GrpcErrorMessage } from "@fonoster/common";
|
|
20
|
+
/**
|
|
21
|
+
* The gRPC error for a failed Meta call. Clients place errors by these messages, so they are part
|
|
22
|
+
* of the contract. Meta's own text never reaches the caller except for the tier check, and
|
|
23
|
+
* anything unrecognized is reported as Meta being unavailable.
|
|
24
|
+
*/
|
|
25
|
+
declare function whatsAppLinkErrorToGrpc(error: unknown, context: {
|
|
26
|
+
phoneNumberId: string;
|
|
27
|
+
hostname: string;
|
|
28
|
+
}): GrpcErrorMessage;
|
|
29
|
+
export { whatsAppLinkErrorToGrpc };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.whatsAppLinkErrorToGrpc = whatsAppLinkErrorToGrpc;
|
|
4
|
+
/**
|
|
5
|
+
* Copyright (C) 2025 by Fonoster Inc (https://fonoster.com)
|
|
6
|
+
* http://github.com/fonoster/fonoster
|
|
7
|
+
*
|
|
8
|
+
* This file is part of Fonoster
|
|
9
|
+
*
|
|
10
|
+
* Licensed under the MIT License (the "License");
|
|
11
|
+
* you may not use this file except in compliance with
|
|
12
|
+
* the License. You may obtain a copy of the License at
|
|
13
|
+
*
|
|
14
|
+
* https://opensource.org/licenses/MIT
|
|
15
|
+
*
|
|
16
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
17
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
18
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
19
|
+
* See the License for the specific language governing permissions and
|
|
20
|
+
* limitations under the License.
|
|
21
|
+
*/
|
|
22
|
+
const common_1 = require("@fonoster/common");
|
|
23
|
+
const grpc_js_1 = require("@grpc/grpc-js");
|
|
24
|
+
const UNAVAILABLE = {
|
|
25
|
+
code: grpc_js_1.status.UNAVAILABLE,
|
|
26
|
+
message: "Meta is unavailable; try again"
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* The gRPC error for a failed Meta call. Clients place errors by these messages, so they are part
|
|
30
|
+
* of the contract. Meta's own text never reaches the caller except for the tier check, and
|
|
31
|
+
* anything unrecognized is reported as Meta being unavailable.
|
|
32
|
+
*/
|
|
33
|
+
function whatsAppLinkErrorToGrpc(error, context) {
|
|
34
|
+
if (!(error instanceof common_1.WhatsAppLinkError)) {
|
|
35
|
+
return UNAVAILABLE;
|
|
36
|
+
}
|
|
37
|
+
const failedPrecondition = (message) => ({
|
|
38
|
+
code: grpc_js_1.status.FAILED_PRECONDITION,
|
|
39
|
+
message
|
|
40
|
+
});
|
|
41
|
+
switch (error.reason) {
|
|
42
|
+
case "INVALID_CREDENTIALS":
|
|
43
|
+
return { code: grpc_js_1.status.INVALID_ARGUMENT, message: error.message };
|
|
44
|
+
case "TOKEN_REFUSED":
|
|
45
|
+
return failedPrecondition("Meta didn't accept this access token");
|
|
46
|
+
case "PERMISSION_MISSING":
|
|
47
|
+
return failedPrecondition("This access token can't manage WhatsApp calling settings; use a System User token with whatsapp_business_management");
|
|
48
|
+
case "NUMBER_NOT_FOUND":
|
|
49
|
+
return failedPrecondition(`No WhatsApp number with id ${context.phoneNumberId} is reachable with this access token`);
|
|
50
|
+
case "TIER_TOO_LOW":
|
|
51
|
+
return failedPrecondition(error.message);
|
|
52
|
+
case "SIP_SERVER_REJECTED":
|
|
53
|
+
return failedPrecondition(`Meta didn't accept ${context.hostname} as the SIP server`);
|
|
54
|
+
default:
|
|
55
|
+
return UNAVAILABLE;
|
|
56
|
+
}
|
|
57
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonoster/sipnet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.0",
|
|
4
4
|
"description": "Routr-based SIP stack",
|
|
5
5
|
"author": "Pedro Sanders <psanders@fonoster.com>",
|
|
6
6
|
"homepage": "https://github.com/fonoster/fonoster#readme",
|
|
@@ -19,11 +19,12 @@
|
|
|
19
19
|
"fonoster": "./dist/index.js"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@fonoster/common": "^0.
|
|
23
|
-
"@fonoster/identity": "^0.
|
|
24
|
-
"@fonoster/logger": "^0.
|
|
25
|
-
"@fonoster/types": "^0.
|
|
22
|
+
"@fonoster/common": "^0.42.0",
|
|
23
|
+
"@fonoster/identity": "^0.42.0",
|
|
24
|
+
"@fonoster/logger": "^0.42.0",
|
|
25
|
+
"@fonoster/types": "^0.42.0",
|
|
26
26
|
"@grpc/grpc-js": "~1.14.6",
|
|
27
|
+
"@routr/common": "2.24.0",
|
|
27
28
|
"@routr/sdk": "2.24.0",
|
|
28
29
|
"phone": "^3.1.72",
|
|
29
30
|
"zod": "^3.23.8"
|
|
@@ -41,5 +42,5 @@
|
|
|
41
42
|
"bugs": {
|
|
42
43
|
"url": "https://github.com/fonoster/fonoster/issues"
|
|
43
44
|
},
|
|
44
|
-
"gitHead": "
|
|
45
|
+
"gitHead": "672b9fbf92428854e9d4d94d0e40a3f830efd925"
|
|
45
46
|
}
|