@fonoster/voice 0.27.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -419
- package/dist/Session.d.ts +161 -0
- package/dist/Session.js +576 -0
- package/dist/VoiceServer.d.ts +70 -5
- package/dist/VoiceServer.js +149 -43
- package/dist/{verbs/Dial.d.ts → errors.d.ts} +9 -13
- package/dist/{verbs/Answer.js → errors.js} +18 -7
- package/dist/index.d.ts +4 -6
- package/dist/index.js +4 -15
- package/dist/signature.d.ts +31 -0
- package/dist/signature.js +93 -0
- package/dist/types.d.ts +88 -10
- package/dist/types.js +0 -21
- package/package.json +13 -19
- package/session.d.ts +1 -0
- package/session.js +2 -0
- package/dist/VoiceResponse.d.ts +0 -304
- package/dist/VoiceResponse.js +0 -461
- package/dist/createSession.d.ts +0 -22
- package/dist/createSession.js +0 -66
- package/dist/defaultServerConfig.d.ts +0 -21
- package/dist/defaultServerConfig.js +0 -10
- package/dist/demo.d.ts +0 -1
- package/dist/demo.js +0 -83
- package/dist/filters.d.ts +0 -44
- package/dist/filters.js +0 -17
- package/dist/serviceDefinition.d.ts +0 -2
- package/dist/serviceDefinition.js +0 -29
- package/dist/verbs/Answer.d.ts +0 -24
- package/dist/verbs/Dial.js +0 -60
- package/dist/verbs/Gather.d.ts +0 -25
- package/dist/verbs/Gather.js +0 -57
- package/dist/verbs/Hangup.d.ts +0 -24
- package/dist/verbs/Hangup.js +0 -29
- package/dist/verbs/Mute.d.ts +0 -25
- package/dist/verbs/Mute.js +0 -36
- package/dist/verbs/Play.d.ts +0 -25
- package/dist/verbs/Play.js +0 -33
- package/dist/verbs/PlayDtmf.d.ts +0 -25
- package/dist/verbs/PlayDtmf.js +0 -32
- package/dist/verbs/PlaybackControl.d.ts +0 -25
- package/dist/verbs/PlaybackControl.js +0 -35
- package/dist/verbs/Record.d.ts +0 -25
- package/dist/verbs/Record.js +0 -49
- package/dist/verbs/Say.d.ts +0 -25
- package/dist/verbs/Say.js +0 -33
- package/dist/verbs/SetAudioFilters.d.ts +0 -25
- package/dist/verbs/SetAudioFilters.js +0 -16
- package/dist/verbs/StopSay.d.ts +0 -25
- package/dist/verbs/StopSay.js +0 -11
- package/dist/verbs/Stream.d.ts +0 -39
- package/dist/verbs/Stream.js +0 -80
- package/dist/verbs/StreamGather.d.ts +0 -37
- package/dist/verbs/StreamGather.js +0 -66
- package/dist/verbs/Unmute.d.ts +0 -22
- package/dist/verbs/Unmute.js +0 -26
- package/dist/verbs/Verb.d.ts +0 -28
- package/dist/verbs/Verb.js +0 -103
- package/dist/verbs/getExpectedContent.d.ts +0 -2
- package/dist/verbs/getExpectedContent.js +0 -32
- package/dist/verbs/index.d.ts +0 -35
- package/dist/verbs/index.js +0 -51
- package/dist/verbs/validateRequest.d.ts +0 -20
- package/dist/verbs/validateRequest.js +0 -13
package/dist/VoiceServer.js
CHANGED
|
@@ -45,6 +45,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
45
45
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
46
46
|
};
|
|
47
47
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
exports.VoiceServer = void 0;
|
|
48
49
|
/**
|
|
49
50
|
* Copyright (C) 2025 by Fonoster Inc (https://fonoster.com)
|
|
50
51
|
* http://github.com/fonoster/fonoster
|
|
@@ -63,59 +64,164 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
63
64
|
* See the License for the specific language governing permissions and
|
|
64
65
|
* limitations under the License.
|
|
65
66
|
*/
|
|
66
|
-
const
|
|
67
|
+
const http = __importStar(require("http"));
|
|
67
68
|
const logger_1 = require("@fonoster/logger");
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
const createSession_1 = require("./createSession");
|
|
72
|
-
const defaultServerConfig_1 = require("./defaultServerConfig");
|
|
73
|
-
const serviceDefinition_1 = require("./serviceDefinition");
|
|
69
|
+
const ws_1 = __importDefault(require("ws"));
|
|
70
|
+
const Session_1 = require("./Session");
|
|
71
|
+
const signature_1 = require("./signature");
|
|
74
72
|
const logger = (0, logger_1.getLogger)({ service: "voice", filePath: __filename });
|
|
73
|
+
/**
|
|
74
|
+
* Accepts AppBridge sessions over WebSocket and runs the handler once per session
|
|
75
|
+
* (voice-sdk, "VoiceServer accepts AppBridge sessions over WebSocket").
|
|
76
|
+
*/
|
|
75
77
|
class VoiceServer {
|
|
76
|
-
constructor(
|
|
77
|
-
|
|
78
|
+
constructor(options = {}) {
|
|
79
|
+
var _a, _b;
|
|
80
|
+
this.options = options;
|
|
81
|
+
this.wss = new ws_1.default.Server({ noServer: true });
|
|
82
|
+
this.secret = (_a = options.signingSecret) !== null && _a !== void 0 ? _a : process.env.APP_SIGNING_SECRET;
|
|
83
|
+
this.verify = (_b = options.verifySignature) !== null && _b !== void 0 ? _b : true;
|
|
84
|
+
if (this.verify && !this.secret) {
|
|
85
|
+
throw new Error("VoiceServer needs signingSecret (or APP_SIGNING_SECRET), or verifySignature: false");
|
|
86
|
+
}
|
|
78
87
|
}
|
|
88
|
+
/** Starts a standalone server. Resolves once it is listening. */
|
|
79
89
|
listen(handler) {
|
|
80
90
|
return __awaiter(this, void 0, void 0, function* () {
|
|
91
|
+
var _a;
|
|
92
|
+
this.handler = handler;
|
|
93
|
+
this.server = http.createServer((_req, res) => res.writeHead(426).end("WebSocket required"));
|
|
94
|
+
this.server.on("upgrade", (req, socket, head) => this.handleUpgrade(req, socket, head));
|
|
95
|
+
yield new Promise((resolve) => {
|
|
96
|
+
var _a, _b;
|
|
97
|
+
return this.server.listen((_a = this.options.port) !== null && _a !== void 0 ? _a : 3000, (_b = this.options.host) !== null && _b !== void 0 ? _b : "0.0.0.0", resolve);
|
|
98
|
+
});
|
|
99
|
+
logger.info(`voice server listening on ${(_a = this.options.port) !== null && _a !== void 0 ? _a : 3000}`);
|
|
100
|
+
return this;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/** Mounts on an existing HTTP server (Express included): sessions arrive on `path`. */
|
|
104
|
+
attach(server, handler) {
|
|
105
|
+
this.handler = handler;
|
|
106
|
+
server.on("upgrade", (req, socket, head) => {
|
|
107
|
+
var _a;
|
|
108
|
+
if (this.options.path && ((_a = req.url) === null || _a === void 0 ? void 0 : _a.split("?")[0]) !== this.options.path)
|
|
109
|
+
return;
|
|
110
|
+
this.handleUpgrade(req, socket, head);
|
|
111
|
+
});
|
|
112
|
+
return this;
|
|
113
|
+
}
|
|
114
|
+
get port() {
|
|
115
|
+
var _a;
|
|
116
|
+
const address = (_a = this.server) === null || _a === void 0 ? void 0 : _a.address();
|
|
117
|
+
return typeof address === "object" && address ? address.port : undefined;
|
|
118
|
+
}
|
|
119
|
+
close() {
|
|
120
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
121
|
+
for (const client of this.wss.clients)
|
|
122
|
+
client.terminate();
|
|
123
|
+
yield new Promise((r) => this.server ? this.server.close(() => r()) : r());
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/** Handles one upgrade request: verifies the signature, then runs a session. */
|
|
127
|
+
handleUpgrade(req, socket, head) {
|
|
128
|
+
var _a;
|
|
129
|
+
if (this.options.path && ((_a = req.url) === null || _a === void 0 ? void 0 : _a.split("?")[0]) !== this.options.path) {
|
|
130
|
+
socket.end("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
let binding;
|
|
134
|
+
if (this.verify) {
|
|
135
|
+
const urls = this.candidateUrls(req);
|
|
136
|
+
// The app-bound signature when AppBridge sent one (or it is required); a bad one is
|
|
137
|
+
// refused even if the URL-only signature matches.
|
|
138
|
+
const bound = this.options.requireAppBinding || (0, signature_1.hasAppSignature)(req.headers)
|
|
139
|
+
? (0, signature_1.verifyAppSignature)(urls, req.headers, this.secret)
|
|
140
|
+
: undefined;
|
|
141
|
+
const result = bound !== null && bound !== void 0 ? bound : (0, signature_1.verifySignature)(urls, req.headers, this.secret);
|
|
142
|
+
if (!result.ok) {
|
|
143
|
+
logger.warn(`rejected a session connection: ${bound ? "app " : ""}signature ${result.reason}`);
|
|
144
|
+
socket.end("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
binding = bound === null || bound === void 0 ? void 0 : bound.binding;
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
logger.warn("accepted a session connection without signature verification (development only)");
|
|
151
|
+
}
|
|
152
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => this.run(ws, binding));
|
|
153
|
+
}
|
|
154
|
+
candidateUrls(req) {
|
|
155
|
+
var _a;
|
|
156
|
+
const configured = this.options.publicUrl;
|
|
157
|
+
if (configured)
|
|
158
|
+
return Array.isArray(configured) ? configured : [configured];
|
|
159
|
+
const host = (_a = req.headers["x-forwarded-host"]) !== null && _a !== void 0 ? _a : req.headers.host;
|
|
160
|
+
// An app URL without a path (ws://autopilot:3000) arrives as "/": accept both spellings.
|
|
161
|
+
const paths = req.url === "/" ? ["/", ""] : [req.url];
|
|
162
|
+
return ["ws", "wss"].flatMap((scheme) => paths.map((p) => `${scheme}://${host}${p}`));
|
|
163
|
+
}
|
|
164
|
+
run(ws, binding) {
|
|
165
|
+
var _a;
|
|
166
|
+
let session;
|
|
167
|
+
let refused = false;
|
|
168
|
+
const timer = setTimeout(() => {
|
|
169
|
+
if (!session)
|
|
170
|
+
ws.close(1008, "no session.start");
|
|
171
|
+
}, (_a = this.options.startTimeoutMs) !== null && _a !== void 0 ? _a : 5000);
|
|
172
|
+
ws.on("message", (raw, isBinary) => {
|
|
173
|
+
const data = raw;
|
|
174
|
+
if (session)
|
|
175
|
+
return session.receive(data, isBinary);
|
|
176
|
+
if (isBinary || refused)
|
|
177
|
+
return;
|
|
178
|
+
let start;
|
|
81
179
|
try {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
interceptors: [authorization]
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
server.addService(serviceDefinition_1.serviceDefinition, {
|
|
99
|
-
createSession: (0, createSession_1.createSession)(handler)
|
|
180
|
+
start = JSON.parse(data.toString());
|
|
181
|
+
}
|
|
182
|
+
catch (_a) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (start.type !== "session.start")
|
|
186
|
+
return;
|
|
187
|
+
clearTimeout(timer);
|
|
188
|
+
// A signature made for one app never opens a session for another.
|
|
189
|
+
if (binding && start.appRef !== binding.appRef) {
|
|
190
|
+
logger.warn("rejected a session: session.start names another app", {
|
|
191
|
+
signedFor: binding.appRef,
|
|
192
|
+
appRef: start.appRef
|
|
100
193
|
});
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
})
|
|
194
|
+
refused = true;
|
|
195
|
+
ws.close(1008, "session.start does not match the signed app");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
session = new Session_1.Session(start, {
|
|
199
|
+
sendJson: (m) => ws.readyState === ws_1.default.OPEN && ws.send(JSON.stringify(m)),
|
|
200
|
+
sendBinary: (b) => ws.readyState === ws_1.default.OPEN && ws.send(b, { binary: true }),
|
|
201
|
+
close: () => ws.readyState === ws_1.default.OPEN && ws.close(1000)
|
|
202
|
+
}, binding);
|
|
203
|
+
void this.invoke(session);
|
|
204
|
+
});
|
|
205
|
+
ws.on("close", () => {
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
session === null || session === void 0 ? void 0 : session.closed();
|
|
208
|
+
});
|
|
209
|
+
ws.on("error", () => undefined);
|
|
210
|
+
}
|
|
211
|
+
invoke(session) {
|
|
212
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
213
|
+
const handler = this.handler;
|
|
214
|
+
try {
|
|
215
|
+
yield handler(session);
|
|
108
216
|
}
|
|
109
|
-
catch (
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
process.exit(1);
|
|
217
|
+
catch (e) {
|
|
218
|
+
if (session.ended)
|
|
219
|
+
return;
|
|
220
|
+
// An app that crashes hangs up the call rather than leaving the caller in silence.
|
|
221
|
+
logger.error(`voice handler failed: ${e.message}`);
|
|
222
|
+
yield session.hangup().catch(() => undefined);
|
|
117
223
|
}
|
|
118
224
|
});
|
|
119
225
|
}
|
|
120
226
|
}
|
|
121
|
-
exports.
|
|
227
|
+
exports.VoiceServer = VoiceServer;
|
|
@@ -16,18 +16,14 @@
|
|
|
16
16
|
* See the License for the specific language governing permissions and
|
|
17
17
|
* limitations under the License.
|
|
18
18
|
*/
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
getValidationSchema(): z.Schema;
|
|
19
|
+
/** A command failed: AppBridge sent `error` with `relatesTo` set to the command's id. */
|
|
20
|
+
export declare class SessionError extends Error {
|
|
21
|
+
readonly code: string;
|
|
22
|
+
readonly fatal: boolean;
|
|
23
|
+
constructor(code: string, message: string, fatal?: boolean);
|
|
25
24
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
on(callback: (data: DialStatus) => void): void;
|
|
31
|
-
emit(status: DialStatus): void;
|
|
25
|
+
/** The session ended (or a fatal error arrived) while the command was pending. */
|
|
26
|
+
export declare class SessionEndedError extends Error {
|
|
27
|
+
readonly reason: string;
|
|
28
|
+
constructor(reason: string);
|
|
32
29
|
}
|
|
33
|
-
export { Dial, DialStatusStream };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
3
|
+
exports.SessionEndedError = exports.SessionError = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* Copyright (C) 2025 by Fonoster Inc (https://fonoster.com)
|
|
6
6
|
* http://github.com/fonoster/fonoster
|
|
@@ -19,11 +19,22 @@ exports.Answer = void 0;
|
|
|
19
19
|
* See the License for the specific language governing permissions and
|
|
20
20
|
* limitations under the License.
|
|
21
21
|
*/
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
/** A command failed: AppBridge sent `error` with `relatesTo` set to the command's id. */
|
|
23
|
+
class SessionError extends Error {
|
|
24
|
+
constructor(code, message, fatal = false) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.fatal = fatal;
|
|
28
|
+
this.name = "SessionError";
|
|
27
29
|
}
|
|
28
30
|
}
|
|
29
|
-
exports.
|
|
31
|
+
exports.SessionError = SessionError;
|
|
32
|
+
/** The session ended (or a fatal error arrived) while the command was pending. */
|
|
33
|
+
class SessionEndedError extends Error {
|
|
34
|
+
constructor(reason) {
|
|
35
|
+
super(`the session ended (${reason})`);
|
|
36
|
+
this.reason = reason;
|
|
37
|
+
this.name = "SessionEndedError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
exports.SessionEndedError = SessionEndedError;
|
package/dist/index.d.ts
CHANGED
|
@@ -16,10 +16,8 @@
|
|
|
16
16
|
* See the License for the specific language governing permissions and
|
|
17
17
|
* limitations under the License.
|
|
18
18
|
*/
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
export
|
|
22
|
-
export * from "./VoiceResponse";
|
|
23
|
-
export * from "./filters";
|
|
19
|
+
export * from "./errors";
|
|
20
|
+
export * from "./Session";
|
|
21
|
+
export * from "./signature";
|
|
24
22
|
export * from "./types";
|
|
25
|
-
export
|
|
23
|
+
export * from "./VoiceServer";
|
package/dist/index.js
CHANGED
|
@@ -13,11 +13,7 @@ var __createBinding = (this && this.__createBinding) || (Object.create ? (functi
|
|
|
13
13
|
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
17
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
18
|
-
};
|
|
19
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
-
exports.StreamGatherSource = exports.GoogleVoiceDetails = exports.GoogleVoice = exports.GatherSource = exports.AzureVoiceDetails = exports.AzureVoice = void 0;
|
|
21
17
|
/**
|
|
22
18
|
* Copyright (C) 2025 by Fonoster Inc (https://fonoster.com)
|
|
23
19
|
* http://github.com/fonoster/fonoster
|
|
@@ -36,15 +32,8 @@ exports.StreamGatherSource = exports.GoogleVoiceDetails = exports.GoogleVoice =
|
|
|
36
32
|
* See the License for the specific language governing permissions and
|
|
37
33
|
* limitations under the License.
|
|
38
34
|
*/
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
Object.defineProperty(exports, "GatherSource", { enumerable: true, get: function () { return common_1.GatherSource; } });
|
|
43
|
-
Object.defineProperty(exports, "GoogleVoice", { enumerable: true, get: function () { return common_1.GoogleVoice; } });
|
|
44
|
-
Object.defineProperty(exports, "GoogleVoiceDetails", { enumerable: true, get: function () { return common_1.GoogleVoiceDetails; } });
|
|
45
|
-
Object.defineProperty(exports, "StreamGatherSource", { enumerable: true, get: function () { return common_1.StreamGatherSource; } });
|
|
46
|
-
const VoiceServer_1 = __importDefault(require("./VoiceServer"));
|
|
47
|
-
exports.default = VoiceServer_1.default;
|
|
48
|
-
__exportStar(require("./VoiceResponse"), exports);
|
|
49
|
-
__exportStar(require("./filters"), exports);
|
|
35
|
+
__exportStar(require("./errors"), exports);
|
|
36
|
+
__exportStar(require("./Session"), exports);
|
|
37
|
+
__exportStar(require("./signature"), exports);
|
|
50
38
|
__exportStar(require("./types"), exports);
|
|
39
|
+
__exportStar(require("./VoiceServer"), exports);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { IncomingHttpHeaders } from "http";
|
|
2
|
+
export declare const SIGNATURE_HEADER = "x-fonoster-signature";
|
|
3
|
+
export declare const TIMESTAMP_HEADER = "x-fonoster-timestamp";
|
|
4
|
+
export declare const APP_REF_HEADER = "x-fonoster-app-ref";
|
|
5
|
+
export declare const WORKSPACE_REF_HEADER = "x-fonoster-workspace-ref";
|
|
6
|
+
export declare const APP_SIGNATURE_HEADER = "x-fonoster-app-signature";
|
|
7
|
+
export declare const MAX_SKEW_SECONDS = 300;
|
|
8
|
+
export type Verification = {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
reason?: "missing" | "stale" | "mismatch";
|
|
11
|
+
};
|
|
12
|
+
export declare function sign(url: string, timestamp: number, secret: string): string;
|
|
13
|
+
/** Checks AppBridge's signature against each candidate URL (the URL as configured on the app). */
|
|
14
|
+
export declare function verifySignature(urls: string[], headers: IncomingHttpHeaders, secret: string, nowSeconds?: number): Verification;
|
|
15
|
+
/** The app and workspace a bound signature was made for. */
|
|
16
|
+
export type AppBinding = {
|
|
17
|
+
appRef: string;
|
|
18
|
+
workspaceRef: string;
|
|
19
|
+
};
|
|
20
|
+
export type AppVerification = Verification & {
|
|
21
|
+
binding?: AppBinding;
|
|
22
|
+
};
|
|
23
|
+
/** HMAC-SHA256 of `<timestamp>\n<url>\n<appRef>\n<workspaceRef>`. */
|
|
24
|
+
export declare function signApp(url: string, timestamp: number, binding: AppBinding, secret: string): string;
|
|
25
|
+
/** Whether the request carries the app-bound signature headers at all. */
|
|
26
|
+
export declare function hasAppSignature(headers: IncomingHttpHeaders): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Checks AppBridge's app-bound signature against each candidate URL and returns the app and
|
|
29
|
+
* workspace it was made for; the session must then be for that app.
|
|
30
|
+
*/
|
|
31
|
+
export declare function verifyAppSignature(urls: string[], headers: IncomingHttpHeaders, secret: string, nowSeconds?: number): AppVerification;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_SKEW_SECONDS = exports.APP_SIGNATURE_HEADER = exports.WORKSPACE_REF_HEADER = exports.APP_REF_HEADER = exports.TIMESTAMP_HEADER = exports.SIGNATURE_HEADER = void 0;
|
|
4
|
+
exports.sign = sign;
|
|
5
|
+
exports.verifySignature = verifySignature;
|
|
6
|
+
exports.signApp = signApp;
|
|
7
|
+
exports.hasAppSignature = hasAppSignature;
|
|
8
|
+
exports.verifyAppSignature = verifyAppSignature;
|
|
9
|
+
/**
|
|
10
|
+
* Copyright (C) 2025 by Fonoster Inc (https://fonoster.com)
|
|
11
|
+
* http://github.com/fonoster/fonoster
|
|
12
|
+
*
|
|
13
|
+
* This file is part of Fonoster
|
|
14
|
+
*
|
|
15
|
+
* Licensed under the MIT License (the "License");
|
|
16
|
+
* you may not use this file except in compliance with
|
|
17
|
+
* the License. You may obtain a copy of the License at
|
|
18
|
+
*
|
|
19
|
+
* https://opensource.org/licenses/MIT
|
|
20
|
+
*
|
|
21
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
22
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
23
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
24
|
+
* See the License for the specific language governing permissions and
|
|
25
|
+
* limitations under the License.
|
|
26
|
+
*/
|
|
27
|
+
const crypto_1 = require("crypto");
|
|
28
|
+
// voice-session, "Connection requests are signed".
|
|
29
|
+
exports.SIGNATURE_HEADER = "x-fonoster-signature";
|
|
30
|
+
exports.TIMESTAMP_HEADER = "x-fonoster-timestamp";
|
|
31
|
+
// voice-session, "Connection requests are bound to the app".
|
|
32
|
+
exports.APP_REF_HEADER = "x-fonoster-app-ref";
|
|
33
|
+
exports.WORKSPACE_REF_HEADER = "x-fonoster-workspace-ref";
|
|
34
|
+
exports.APP_SIGNATURE_HEADER = "x-fonoster-app-signature";
|
|
35
|
+
exports.MAX_SKEW_SECONDS = 300;
|
|
36
|
+
function sign(url, timestamp, secret) {
|
|
37
|
+
return (0, crypto_1.createHmac)("sha256", secret)
|
|
38
|
+
.update(`${timestamp}.${url}`)
|
|
39
|
+
.digest("hex");
|
|
40
|
+
}
|
|
41
|
+
/** Checks AppBridge's signature against each candidate URL (the URL as configured on the app). */
|
|
42
|
+
function verifySignature(urls, headers, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
43
|
+
const signature = headers[exports.SIGNATURE_HEADER];
|
|
44
|
+
const timestamp = Number(headers[exports.TIMESTAMP_HEADER]);
|
|
45
|
+
if (typeof signature !== "string" || !Number.isFinite(timestamp))
|
|
46
|
+
return { ok: false, reason: "missing" };
|
|
47
|
+
if (Math.abs(nowSeconds - timestamp) > exports.MAX_SKEW_SECONDS)
|
|
48
|
+
return { ok: false, reason: "stale" };
|
|
49
|
+
const given = Buffer.from(signature, "hex");
|
|
50
|
+
for (const url of urls) {
|
|
51
|
+
const expected = Buffer.from(sign(url, timestamp, secret), "hex");
|
|
52
|
+
if (given.length === expected.length && (0, crypto_1.timingSafeEqual)(given, expected))
|
|
53
|
+
return { ok: true };
|
|
54
|
+
}
|
|
55
|
+
return { ok: false, reason: "mismatch" };
|
|
56
|
+
}
|
|
57
|
+
/** HMAC-SHA256 of `<timestamp>\n<url>\n<appRef>\n<workspaceRef>`. */
|
|
58
|
+
function signApp(url, timestamp, binding, secret) {
|
|
59
|
+
return (0, crypto_1.createHmac)("sha256", secret)
|
|
60
|
+
.update([timestamp, url, binding.appRef, binding.workspaceRef].join("\n"))
|
|
61
|
+
.digest("hex");
|
|
62
|
+
}
|
|
63
|
+
const header = (headers, name) => {
|
|
64
|
+
const v = headers[name];
|
|
65
|
+
return typeof v === "string" ? v : undefined;
|
|
66
|
+
};
|
|
67
|
+
/** Whether the request carries the app-bound signature headers at all. */
|
|
68
|
+
function hasAppSignature(headers) {
|
|
69
|
+
return header(headers, exports.APP_SIGNATURE_HEADER) !== undefined;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Checks AppBridge's app-bound signature against each candidate URL and returns the app and
|
|
73
|
+
* workspace it was made for; the session must then be for that app.
|
|
74
|
+
*/
|
|
75
|
+
function verifyAppSignature(urls, headers, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
76
|
+
var _a;
|
|
77
|
+
const signature = header(headers, exports.APP_SIGNATURE_HEADER);
|
|
78
|
+
const appRef = header(headers, exports.APP_REF_HEADER);
|
|
79
|
+
const workspaceRef = (_a = header(headers, exports.WORKSPACE_REF_HEADER)) !== null && _a !== void 0 ? _a : "";
|
|
80
|
+
const timestamp = Number(headers[exports.TIMESTAMP_HEADER]);
|
|
81
|
+
if (!signature || !appRef || !Number.isFinite(timestamp))
|
|
82
|
+
return { ok: false, reason: "missing" };
|
|
83
|
+
if (Math.abs(nowSeconds - timestamp) > exports.MAX_SKEW_SECONDS)
|
|
84
|
+
return { ok: false, reason: "stale" };
|
|
85
|
+
const binding = { appRef, workspaceRef };
|
|
86
|
+
const given = Buffer.from(signature, "hex");
|
|
87
|
+
for (const url of urls) {
|
|
88
|
+
const expected = Buffer.from(signApp(url, timestamp, binding, secret), "hex");
|
|
89
|
+
if (given.length === expected.length && (0, crypto_1.timingSafeEqual)(given, expected))
|
|
90
|
+
return { ok: true, binding };
|
|
91
|
+
}
|
|
92
|
+
return { ok: false, reason: "mismatch" };
|
|
93
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -16,13 +16,91 @@
|
|
|
16
16
|
* See the License for the specific language governing permissions and
|
|
17
17
|
* limitations under the License.
|
|
18
18
|
*/
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
19
|
+
import { SessionStart } from "@fonoster/common";
|
|
20
|
+
export type SayOptions = {
|
|
21
|
+
voice?: string;
|
|
22
|
+
language?: string;
|
|
23
|
+
interruptible?: boolean;
|
|
24
|
+
preempt?: boolean;
|
|
25
|
+
};
|
|
26
|
+
export type SayResult = {
|
|
27
|
+
interrupted: boolean;
|
|
28
|
+
heardText: string;
|
|
29
|
+
heardMs: number;
|
|
30
|
+
};
|
|
31
|
+
export type PlayOptions = {
|
|
32
|
+
loop?: boolean;
|
|
33
|
+
interruptible?: boolean;
|
|
34
|
+
preempt?: boolean;
|
|
35
|
+
};
|
|
36
|
+
export type PlayResult = {
|
|
37
|
+
interrupted: boolean;
|
|
38
|
+
heardMs: number;
|
|
39
|
+
};
|
|
40
|
+
/** "speech_and_dtmf" is the new name; "speech,dtmf" is today's GatherSource value. */
|
|
41
|
+
export type GatherSourceName = "speech" | "dtmf" | "speech_and_dtmf" | "speech,dtmf";
|
|
42
|
+
export type GatherOptions = {
|
|
43
|
+
source?: GatherSourceName;
|
|
44
|
+
maxDigits?: number;
|
|
45
|
+
finishOnKey?: string;
|
|
46
|
+
/** ms to wait for the first input (default 4000). */
|
|
47
|
+
timeout?: number;
|
|
48
|
+
};
|
|
49
|
+
export type GatherResult = {
|
|
50
|
+
speech?: string;
|
|
51
|
+
digits?: string;
|
|
52
|
+
};
|
|
53
|
+
export type TransferOptions = {
|
|
54
|
+
mode?: "cold" | "warm" | "refer";
|
|
55
|
+
headers?: Record<string, string>;
|
|
56
|
+
};
|
|
57
|
+
export type SessionEventMap = {
|
|
58
|
+
"turn.user_started": Record<string, never>;
|
|
59
|
+
"turn.likely_ended": {
|
|
60
|
+
text: string;
|
|
61
|
+
};
|
|
62
|
+
"turn.ended": {
|
|
63
|
+
text: string;
|
|
64
|
+
language: string;
|
|
65
|
+
confidence: number;
|
|
66
|
+
durationMs: number;
|
|
67
|
+
};
|
|
68
|
+
"turn.resumed": Record<string, never>;
|
|
69
|
+
"turn.backchannel": {
|
|
70
|
+
text: string;
|
|
71
|
+
};
|
|
72
|
+
"transcript.partial": {
|
|
73
|
+
text: string;
|
|
74
|
+
language?: string;
|
|
75
|
+
};
|
|
76
|
+
"turn.estimate": {
|
|
77
|
+
eot: number;
|
|
78
|
+
speaking: boolean;
|
|
79
|
+
kind: string;
|
|
80
|
+
};
|
|
81
|
+
/** Answering-machine detection, once per call, for apps with `amd` (add-roomr 4.3). */
|
|
82
|
+
"amd.result": {
|
|
83
|
+
status: "human" | "voicemail" | "ivr" | "machine" | "unknown";
|
|
84
|
+
confidence: number;
|
|
85
|
+
detector: string;
|
|
86
|
+
audioMs: number;
|
|
87
|
+
reason?: string;
|
|
88
|
+
};
|
|
89
|
+
dtmf: {
|
|
90
|
+
digit: string;
|
|
91
|
+
};
|
|
92
|
+
/** A warm transfer's new party answered: completeTransfer() may follow. */
|
|
93
|
+
"transfer.answered": {
|
|
94
|
+
relatesTo: string;
|
|
95
|
+
};
|
|
96
|
+
error: {
|
|
97
|
+
code: string;
|
|
98
|
+
message: string;
|
|
99
|
+
fatal: boolean;
|
|
100
|
+
};
|
|
101
|
+
end: {
|
|
102
|
+
reason: string;
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
export type SessionEventName = keyof SessionEventMap;
|
|
106
|
+
export type SessionInfo = Pick<SessionStart, "sessionRef" | "callRef" | "appRef" | "direction" | "from" | "to" | "metadata" | "handoff" | "audio" | "tracks" | "speech">;
|
package/dist/types.js
CHANGED
|
@@ -1,23 +1,2 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.AmdStatus = void 0;
|
|
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
|
-
Object.defineProperty(exports, "AmdStatus", { enumerable: true, get: function () { return common_1.AmdStatus; } });
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonoster/voice",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.31.0",
|
|
4
|
+
"description": "Session SDK for Fonoster voice applications",
|
|
5
5
|
"author": "Pedro Sanders <psanders@fonoster.com>",
|
|
6
6
|
"homepage": "https://github.com/fonoster/fonoster#readme",
|
|
7
7
|
"license": "MIT",
|
|
@@ -14,26 +14,17 @@
|
|
|
14
14
|
"scripts": {
|
|
15
15
|
"prebuild": "rimraf ./dist tsconfig.tsbuildinfo",
|
|
16
16
|
"build": "tsc -b tsconfig.json",
|
|
17
|
-
"
|
|
18
|
-
"generate:readme": "node ../../.scripts/gen-readme.cjs",
|
|
19
|
-
"start:dev": "nodemon --exec tsx --watch src src/demo.ts"
|
|
20
|
-
},
|
|
21
|
-
"bin": {
|
|
22
|
-
"fonoster": "./dist/index.js"
|
|
17
|
+
"generate:readme": "fonoster-readme"
|
|
23
18
|
},
|
|
24
19
|
"dependencies": {
|
|
25
|
-
"@fonoster/common": "^0.
|
|
26
|
-
"@fonoster/
|
|
27
|
-
"
|
|
28
|
-
"@grpc/grpc-js": "~1.10.6",
|
|
29
|
-
"deepmerge": "^4.3.1",
|
|
30
|
-
"grpc-health-check": "^2.0.2",
|
|
31
|
-
"pb-util": "^1.0.3",
|
|
32
|
-
"zod": "^3.23.8",
|
|
33
|
-
"zod-validation-error": "^3.3.0"
|
|
20
|
+
"@fonoster/common": "^0.31.0",
|
|
21
|
+
"@fonoster/logger": "^0.31.0",
|
|
22
|
+
"ws": "^8.18.0"
|
|
34
23
|
},
|
|
35
24
|
"files": [
|
|
36
|
-
"dist"
|
|
25
|
+
"dist",
|
|
26
|
+
"session.d.ts",
|
|
27
|
+
"session.js"
|
|
37
28
|
],
|
|
38
29
|
"publishConfig": {
|
|
39
30
|
"access": "public"
|
|
@@ -45,5 +36,8 @@
|
|
|
45
36
|
"bugs": {
|
|
46
37
|
"url": "https://github.com/fonoster/fonoster/issues"
|
|
47
38
|
},
|
|
48
|
-
"gitHead": "
|
|
39
|
+
"gitHead": "f0a5102dddeea0f70f659234108805bc10690f2e",
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@fonoster/conformance": "^0.31.0"
|
|
42
|
+
}
|
|
49
43
|
}
|
package/session.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./dist";
|
package/session.js
ADDED