@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.
@@ -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). `twilioLink`
24
- * is the install's PSTN root and public signaling addresses, for LinkTwilio.
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, twilioLink?: {
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). `twilioLink`
17
- * is the install's PSTN root and public signaling addresses, for LinkTwilio.
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, twilioLink = {
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.39.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.39.0",
23
- "@fonoster/identity": "^0.39.0",
24
- "@fonoster/logger": "^0.39.0",
25
- "@fonoster/types": "^0.39.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": "f5dbb1ff7c54e949ed66743d68f3faf4971a8b2a"
45
+ "gitHead": "672b9fbf92428854e9d4d94d0e40a3f830efd925"
45
46
  }