buzz-me-in 2.0.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/.env.example +68 -0
- package/LICENSE +21 -0
- package/README.md +452 -0
- package/homekit.js +789 -0
- package/intercom.service +48 -0
- package/package.json +73 -0
- package/server.js +420 -0
- package/src/core/config.js +133 -0
- package/src/core/log.js +132 -0
- package/src/core/media-stream.js +335 -0
- package/src/core/mulaw-audio.js +233 -0
- package/src/core/return-audio-sdp.js +95 -0
- package/src/core/safe-equal.js +22 -0
- package/src/core/state.js +149 -0
- package/src/core/stream-auth.js +130 -0
- package/src/core/twilio-ids.js +27 -0
- package/src/core/types.js +145 -0
- package/src/core/ws-events-schema.js +110 -0
- package/twilio-api.js +53 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Return-audio SDP file handling for the outbound (HomeKit → Twilio) ffmpeg
|
|
5
|
+
* pipeline. The SDP embeds the SRTP master key/salt, so files are written
|
|
6
|
+
* into a process-private 0700 temp directory with mode 0600 instead of
|
|
7
|
+
* world-readable predictable paths directly under /tmp.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
// Created lazily (not eagerly at module load) so that merely importing this
|
|
15
|
+
// module — e.g. in tests that never write an SDP — has no filesystem side
|
|
16
|
+
// effect, and so removeSdpDir() can reset it for recreation.
|
|
17
|
+
let sdpDir = null;
|
|
18
|
+
|
|
19
|
+
/** Lazily create the private per-process SDP directory (mkdtemp → 0700). */
|
|
20
|
+
function ensureSdpDir() {
|
|
21
|
+
if (!sdpDir) {
|
|
22
|
+
sdpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'intercom-'));
|
|
23
|
+
}
|
|
24
|
+
return sdpDir;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {{ port: number, payloadType: number, srtpParams: string }} input
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
function buildReturnAudioSdp({ port, payloadType, srtpParams }) {
|
|
32
|
+
return [
|
|
33
|
+
'v=0',
|
|
34
|
+
'o=- 0 0 IN IP4 127.0.0.1',
|
|
35
|
+
's=Return Audio',
|
|
36
|
+
'c=IN IP4 127.0.0.1',
|
|
37
|
+
't=0 0',
|
|
38
|
+
`m=audio ${port} RTP/SAVP ${payloadType}`,
|
|
39
|
+
`a=rtpmap:${payloadType} opus/48000/2`,
|
|
40
|
+
`a=fmtp:${payloadType} minptime=10;useinbandfec=1`,
|
|
41
|
+
`a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:${srtpParams}`,
|
|
42
|
+
'a=recvonly',
|
|
43
|
+
'',
|
|
44
|
+
].join('\r\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Write the SDP for a HAP session into the private directory.
|
|
49
|
+
*
|
|
50
|
+
* The 0700 directory (owned by this process) is what prevents another local
|
|
51
|
+
* user from reading the SRTP key or planting a symlink at the target path, so
|
|
52
|
+
* an overwrite (`w`) is safe: a re-issued START for the same session simply
|
|
53
|
+
* refreshes the file rather than throwing, keeping _startSession idempotent.
|
|
54
|
+
*
|
|
55
|
+
* @param {{ sessionID: string, port: number, payloadType: number, srtpParams: string }} input
|
|
56
|
+
* @returns {string} absolute path of the written SDP file
|
|
57
|
+
*/
|
|
58
|
+
function writeReturnAudioSdp({ sessionID, port, payloadType, srtpParams }) {
|
|
59
|
+
const safeName = String(sessionID).replace(/[^A-Za-z0-9_-]/g, '_');
|
|
60
|
+
const sdpPath = path.join(ensureSdpDir(), `return_${safeName}.sdp`);
|
|
61
|
+
fs.writeFileSync(sdpPath, buildReturnAudioSdp({ port, payloadType, srtpParams }), {
|
|
62
|
+
mode: 0o600,
|
|
63
|
+
flag: 'w',
|
|
64
|
+
});
|
|
65
|
+
return sdpPath;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Best-effort removal of a previously written SDP file.
|
|
70
|
+
* @param {string} sdpPath
|
|
71
|
+
*/
|
|
72
|
+
function removeReturnAudioSdp(sdpPath) {
|
|
73
|
+
try {
|
|
74
|
+
fs.unlinkSync(sdpPath);
|
|
75
|
+
} catch {}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Best-effort removal of the private SDP directory itself, called on graceful
|
|
80
|
+
* shutdown so the intercom-* dir does not linger. Resets the cached path so a
|
|
81
|
+
* later write (should one occur) recreates a fresh private directory.
|
|
82
|
+
*/
|
|
83
|
+
function removeSdpDir() {
|
|
84
|
+
if (!sdpDir) return;
|
|
85
|
+
try {
|
|
86
|
+
fs.rmSync(sdpDir, { recursive: true, force: true });
|
|
87
|
+
} catch {}
|
|
88
|
+
sdpDir = null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
removeReturnAudioSdp,
|
|
93
|
+
removeSdpDir,
|
|
94
|
+
writeReturnAudioSdp,
|
|
95
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Constant-time string comparison. Inputs are padded to a common length so
|
|
7
|
+
* unequal lengths do not short-circuit the comparison.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} a
|
|
10
|
+
* @param {string} b
|
|
11
|
+
* @returns {boolean}
|
|
12
|
+
*/
|
|
13
|
+
function safeEqualString(a, b) {
|
|
14
|
+
const left = Buffer.from(a, 'utf8');
|
|
15
|
+
const right = Buffer.from(b, 'utf8');
|
|
16
|
+
const len = Math.max(left.length, right.length);
|
|
17
|
+
const paddedLeft = Buffer.concat([left, Buffer.alloc(len - left.length)]);
|
|
18
|
+
const paddedRight = Buffer.concat([right, Buffer.alloc(len - right.length)]);
|
|
19
|
+
return crypto.timingSafeEqual(paddedLeft, paddedRight);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = { safeEqualString };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const config = require('./config');
|
|
4
|
+
const { createLogger } = require('./log');
|
|
5
|
+
const logger = createLogger({ component: 'state' });
|
|
6
|
+
|
|
7
|
+
/** @import { connection as WebSocketConnection } from 'websocket' */
|
|
8
|
+
/** @import { CallSession, CallStatus, StartCallResult, ClearCallResult, ClearedCallSession } from './types.js' */
|
|
9
|
+
|
|
10
|
+
class CallSessionManager {
|
|
11
|
+
/**
|
|
12
|
+
* @param {number} staleSec
|
|
13
|
+
*/
|
|
14
|
+
constructor(staleSec) {
|
|
15
|
+
/** @type {Map<string, CallSession>} */
|
|
16
|
+
this._sessions = new Map();
|
|
17
|
+
/** @type {string | null} */
|
|
18
|
+
this._activeCallSid = null;
|
|
19
|
+
this._staleMs = staleSec * 1000;
|
|
20
|
+
/** @type {((session: ClearedCallSession) => void) | null} */
|
|
21
|
+
this._onSessionStale = null;
|
|
22
|
+
|
|
23
|
+
const tickMs = Math.max(5000, Math.min(30000, Math.floor(this._staleMs / 3)));
|
|
24
|
+
this._reaper = setInterval(() => this._reapStaleSession(), tickMs);
|
|
25
|
+
if (typeof this._reaper.unref === 'function') this._reaper.unref();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {((session: ClearedCallSession) => void) | null | undefined} handler
|
|
30
|
+
*/
|
|
31
|
+
setOnSessionStale(handler) {
|
|
32
|
+
this._onSessionStale = typeof handler === 'function' ? handler : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {{ callSid: string, streamSid: string, wsConnection: WebSocketConnection }} input
|
|
37
|
+
* @returns {StartCallResult}
|
|
38
|
+
*/
|
|
39
|
+
startCall({ callSid, streamSid, wsConnection }) {
|
|
40
|
+
if (!callSid || !streamSid || !wsConnection) {
|
|
41
|
+
return { ok: false, reason: 'invalid session payload' };
|
|
42
|
+
}
|
|
43
|
+
const active = this.getActiveCall();
|
|
44
|
+
if (active && active.callSid !== callSid) {
|
|
45
|
+
return { ok: false, reason: 'another call is already active' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
/** @type {CallSession} */
|
|
50
|
+
const session = {
|
|
51
|
+
callSid,
|
|
52
|
+
streamSid,
|
|
53
|
+
wsConnection,
|
|
54
|
+
createdAtMs: now,
|
|
55
|
+
lastEventAtMs: now,
|
|
56
|
+
lastEvent: 'start',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
this._sessions.set(callSid, session);
|
|
60
|
+
this._activeCallSid = callSid;
|
|
61
|
+
return { ok: true, session };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @returns {CallSession | null}
|
|
66
|
+
*/
|
|
67
|
+
getActiveCall() {
|
|
68
|
+
if (!this._activeCallSid) return null;
|
|
69
|
+
return this._sessions.get(this._activeCallSid) || null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @returns {CallStatus}
|
|
74
|
+
*/
|
|
75
|
+
getStatus() {
|
|
76
|
+
const active = this.getActiveCall();
|
|
77
|
+
if (!active) return { active: false };
|
|
78
|
+
return { active: true, callSid: active.callSid };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {string | null | undefined} callSid
|
|
83
|
+
* @param {string | null | undefined} eventName
|
|
84
|
+
* @returns {boolean}
|
|
85
|
+
*/
|
|
86
|
+
markActivity(callSid, eventName) {
|
|
87
|
+
const session = callSid ? this._sessions.get(callSid) : this.getActiveCall();
|
|
88
|
+
if (!session) return false;
|
|
89
|
+
session.lastEventAtMs = Date.now();
|
|
90
|
+
if (eventName) session.lastEvent = eventName;
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {string | null | undefined} reason
|
|
96
|
+
* @returns {ClearCallResult}
|
|
97
|
+
*/
|
|
98
|
+
clearActiveCall(reason) {
|
|
99
|
+
const active = this.getActiveCall();
|
|
100
|
+
if (!active) return { cleared: false, session: null };
|
|
101
|
+
this._sessions.delete(active.callSid);
|
|
102
|
+
this._activeCallSid = null;
|
|
103
|
+
return { cleared: true, session: { ...active, clearedReason: reason || 'cleared' } };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {WebSocketConnection} wsConnection
|
|
108
|
+
* @param {string | null | undefined} reason
|
|
109
|
+
* @returns {ClearCallResult}
|
|
110
|
+
*/
|
|
111
|
+
clearIfConnection(wsConnection, reason) {
|
|
112
|
+
const active = this.getActiveCall();
|
|
113
|
+
if (!active || active.wsConnection !== wsConnection) {
|
|
114
|
+
return { cleared: false, session: null };
|
|
115
|
+
}
|
|
116
|
+
return this.clearActiveCall(reason || 'connection-closed');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
stop() {
|
|
120
|
+
if (this._reaper) {
|
|
121
|
+
clearInterval(this._reaper);
|
|
122
|
+
this._reaper = null;
|
|
123
|
+
}
|
|
124
|
+
this._sessions.clear();
|
|
125
|
+
this._activeCallSid = null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_reapStaleSession() {
|
|
129
|
+
const active = this.getActiveCall();
|
|
130
|
+
if (!active) return;
|
|
131
|
+
if (Date.now() - active.lastEventAtMs <= this._staleMs) return;
|
|
132
|
+
|
|
133
|
+
const { cleared, session } = this.clearActiveCall('stale-timeout');
|
|
134
|
+
if (!cleared || !this._onSessionStale) return;
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
this._onSessionStale(session);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
logger.error('stale session handler failed', {
|
|
140
|
+
event: 'stale-handler-failed',
|
|
141
|
+
reason: 'handler-throw',
|
|
142
|
+
callSid: session.callSid,
|
|
143
|
+
error: err,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = new CallSessionManager(config.callSessionStaleSec);
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const config = require('./config');
|
|
5
|
+
const { safeEqualString } = require('./safe-equal');
|
|
6
|
+
|
|
7
|
+
const STREAM_TOKEN_PARAMETER_NAME = 'token';
|
|
8
|
+
const STREAM_TOKEN_VERSION = 1;
|
|
9
|
+
const STREAM_PATH = '/media';
|
|
10
|
+
const pendingStreamNonces = new Map();
|
|
11
|
+
|
|
12
|
+
function toBase64Url(buffer) {
|
|
13
|
+
return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function fromBase64Url(value) {
|
|
17
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
18
|
+
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
|
|
19
|
+
return Buffer.from(padded, 'base64');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function pruneExpiredNonces() {
|
|
23
|
+
const now = Math.floor(Date.now() / 1000);
|
|
24
|
+
for (const [nonce, data] of pendingStreamNonces.entries()) {
|
|
25
|
+
if (data.exp <= now) pendingStreamNonces.delete(nonce);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string | null} callSid
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
function issueStreamToken(callSid) {
|
|
34
|
+
pruneExpiredNonces();
|
|
35
|
+
|
|
36
|
+
const now = Math.floor(Date.now() / 1000);
|
|
37
|
+
const exp = now + config.streamAuthTtlSec;
|
|
38
|
+
const nonce = crypto.randomBytes(16).toString('hex');
|
|
39
|
+
|
|
40
|
+
pendingStreamNonces.set(nonce, { exp, callSid });
|
|
41
|
+
|
|
42
|
+
const payload = {
|
|
43
|
+
v: STREAM_TOKEN_VERSION,
|
|
44
|
+
iat: now,
|
|
45
|
+
exp,
|
|
46
|
+
nonce,
|
|
47
|
+
callSid,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const payloadEncoded = toBase64Url(Buffer.from(JSON.stringify(payload), 'utf8'));
|
|
51
|
+
const signature = toBase64Url(
|
|
52
|
+
crypto.createHmac('sha256', config.streamAuthSecret).update(payloadEncoded).digest()
|
|
53
|
+
);
|
|
54
|
+
return `${payloadEncoded}.${signature}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} token
|
|
59
|
+
* @returns {import('./types').StreamTokenVerificationResult}
|
|
60
|
+
*/
|
|
61
|
+
function verifyAndConsumeStreamToken(token) {
|
|
62
|
+
pruneExpiredNonces();
|
|
63
|
+
if (!token) return { ok: false, reason: 'missing token' };
|
|
64
|
+
|
|
65
|
+
const pieces = token.split('.');
|
|
66
|
+
if (pieces.length !== 2) return { ok: false, reason: 'invalid token format' };
|
|
67
|
+
const [payloadEncoded, providedSig] = pieces;
|
|
68
|
+
const expectedSig = toBase64Url(
|
|
69
|
+
crypto.createHmac('sha256', config.streamAuthSecret).update(payloadEncoded).digest()
|
|
70
|
+
);
|
|
71
|
+
if (!safeEqualString(providedSig, expectedSig)) {
|
|
72
|
+
return { ok: false, reason: 'invalid token signature' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let payload;
|
|
76
|
+
try {
|
|
77
|
+
payload = JSON.parse(fromBase64Url(payloadEncoded).toString('utf8'));
|
|
78
|
+
} catch {
|
|
79
|
+
return { ok: false, reason: 'invalid token payload' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const now = Math.floor(Date.now() / 1000);
|
|
83
|
+
if (
|
|
84
|
+
payload.v !== STREAM_TOKEN_VERSION ||
|
|
85
|
+
!payload.nonce ||
|
|
86
|
+
payload.exp <= now ||
|
|
87
|
+
payload.iat > now + 30
|
|
88
|
+
) {
|
|
89
|
+
return { ok: false, reason: 'expired or malformed token' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const pending = pendingStreamNonces.get(payload.nonce);
|
|
93
|
+
if (!pending) return { ok: false, reason: 'nonce not pending' };
|
|
94
|
+
if (pending.exp !== payload.exp || pending.callSid !== payload.callSid) {
|
|
95
|
+
pendingStreamNonces.delete(payload.nonce);
|
|
96
|
+
return { ok: false, reason: 'nonce payload mismatch' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
pendingStreamNonces.delete(payload.nonce);
|
|
100
|
+
return { ok: true, callSid: payload.callSid || null };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {string | null} callSid
|
|
105
|
+
* @param {string} [tunnelHostname]
|
|
106
|
+
* @returns {string}
|
|
107
|
+
*/
|
|
108
|
+
function buildConnectStreamTwiml(callSid, tunnelHostname = config.tunnelHostname) {
|
|
109
|
+
return `<Connect><Stream url="wss://${tunnelHostname}${STREAM_PATH}"><Parameter name="${STREAM_TOKEN_PARAMETER_NAME}" value="${escapeXmlAttribute(issueStreamToken(callSid))}"/></Stream></Connect>`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {string} value
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
function escapeXmlAttribute(value) {
|
|
117
|
+
return value
|
|
118
|
+
.replace(/&/g, '&')
|
|
119
|
+
.replace(/"/g, '"')
|
|
120
|
+
.replace(/'/g, ''')
|
|
121
|
+
.replace(/</g, '<')
|
|
122
|
+
.replace(/>/g, '>');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = {
|
|
126
|
+
buildConnectStreamTwiml,
|
|
127
|
+
issueStreamToken,
|
|
128
|
+
STREAM_TOKEN_PARAMETER_NAME,
|
|
129
|
+
verifyAndConsumeStreamToken,
|
|
130
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Twilio identifier formats and normalization.
|
|
5
|
+
*
|
|
6
|
+
* Dependency-free on purpose: this module is imported by both the token layer
|
|
7
|
+
* (stream-auth.js) and the pure protocol schema (ws-events-schema.js), and the
|
|
8
|
+
* latter must not transitively pull in config.js (which validates env at load).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const CALL_SID_REGEX = /^CA[0-9a-f]{32}$/;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Normalize an untrusted CallSid value (e.g. from webhook form data or the
|
|
15
|
+
* Twilio WebSocket start event). Twilio always sends CallSid as "CA" + 32
|
|
16
|
+
* lowercase hex chars; anything else (wrong shape, duplicate form keys parsed
|
|
17
|
+
* as arrays, non-strings) is treated as absent so it never reaches logs,
|
|
18
|
+
* tokens, or REST URLs.
|
|
19
|
+
*
|
|
20
|
+
* @param {unknown} value
|
|
21
|
+
* @returns {string | null}
|
|
22
|
+
*/
|
|
23
|
+
function normalizeCallSid(value) {
|
|
24
|
+
return typeof value === 'string' && CALL_SID_REGEX.test(value) ? value : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { CALL_SID_REGEX, normalizeCallSid };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared JSDoc typedefs for runtime JS type-checking.
|
|
5
|
+
* This file is intentionally declarations-only; no runtime exports.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {'connected' | 'start' | 'media' | 'stop'} SupportedWsEventName
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {{ event: 'connected' }} ConnectedEvent
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {{
|
|
18
|
+
* event: 'start',
|
|
19
|
+
* start: { callSid: string, streamSid: string, customParameters?: Record<string, string> }
|
|
20
|
+
* }} StartEvent
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {{
|
|
25
|
+
* event: 'media',
|
|
26
|
+
* media: { payload: string }
|
|
27
|
+
* }} MediaEvent
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {{ event: 'stop' }} StopEvent
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {ConnectedEvent | StartEvent | MediaEvent | StopEvent} SupportedWsEventData
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {{
|
|
40
|
+
* ok: true,
|
|
41
|
+
* unsupported: false,
|
|
42
|
+
* event: 'connected',
|
|
43
|
+
* data: ConnectedEvent
|
|
44
|
+
* } | {
|
|
45
|
+
* ok: true,
|
|
46
|
+
* unsupported: false,
|
|
47
|
+
* event: 'start',
|
|
48
|
+
* data: StartEvent
|
|
49
|
+
* } | {
|
|
50
|
+
* ok: true,
|
|
51
|
+
* unsupported: false,
|
|
52
|
+
* event: 'media',
|
|
53
|
+
* data: MediaEvent
|
|
54
|
+
* } | {
|
|
55
|
+
* ok: true,
|
|
56
|
+
* unsupported: false,
|
|
57
|
+
* event: 'stop',
|
|
58
|
+
* data: StopEvent
|
|
59
|
+
* }} WsEventParseOkSupported
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {{
|
|
64
|
+
* ok: true,
|
|
65
|
+
* unsupported: true,
|
|
66
|
+
* event: string
|
|
67
|
+
* }} WsEventParseOkUnsupported
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @typedef {{
|
|
72
|
+
* ok: false,
|
|
73
|
+
* reason: string
|
|
74
|
+
* }} WsEventParseError
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @typedef {WsEventParseOkSupported | WsEventParseOkUnsupported | WsEventParseError} WsEventParseResult
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @typedef {{ ok: true, decoded: Buffer }} MediaPayloadParseOk
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @typedef {{ ok: false, reason: string }} MediaPayloadParseError
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @typedef {MediaPayloadParseOk | MediaPayloadParseError} MediaPayloadParseResult
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @typedef {{ ok: true, callSid: string | null }} TokenVerificationOk
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @typedef {{ ok: false, reason: string }} TokenVerificationError
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @typedef {TokenVerificationOk | TokenVerificationError} StreamTokenVerificationResult
|
|
103
|
+
*/
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @typedef {{
|
|
107
|
+
* callSid: string,
|
|
108
|
+
* streamSid: string,
|
|
109
|
+
* wsConnection: import('websocket').connection,
|
|
110
|
+
* createdAtMs: number,
|
|
111
|
+
* lastEventAtMs: number,
|
|
112
|
+
* lastEvent: string
|
|
113
|
+
* }} CallSession
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @typedef {CallSession & { clearedReason: string }} ClearedCallSession
|
|
118
|
+
*/
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @typedef {{ active: false } | { active: true, callSid: string }} CallStatus
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @typedef {{ ok: true, session: CallSession } | { ok: false, reason: string }} StartCallResult
|
|
126
|
+
*/
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @typedef {{ cleared: true, session: ClearedCallSession } | { cleared: false, session: null }} ClearCallResult
|
|
130
|
+
*/
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @typedef {{
|
|
134
|
+
* ts?: string,
|
|
135
|
+
* level?: 'debug' | 'info' | 'warn' | 'error',
|
|
136
|
+
* message?: string,
|
|
137
|
+
* component?: string,
|
|
138
|
+
* callSid?: string,
|
|
139
|
+
* event?: string,
|
|
140
|
+
* reason?: string,
|
|
141
|
+
* durationMs?: number
|
|
142
|
+
* } & Record<string, unknown>} LogFields
|
|
143
|
+
*/
|
|
144
|
+
|
|
145
|
+
module.exports = {};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { z } = require('zod');
|
|
4
|
+
const { CALL_SID_REGEX } = require('./twilio-ids');
|
|
5
|
+
/** @import { WsEventParseResult, MediaPayloadParseResult } from './types.js' */
|
|
6
|
+
|
|
7
|
+
const connectedEventSchema = z.object({
|
|
8
|
+
event: z.literal('connected'),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const startEventSchema = z.object({
|
|
12
|
+
event: z.literal('start'),
|
|
13
|
+
start: z.object({
|
|
14
|
+
callSid: z.string().regex(CALL_SID_REGEX),
|
|
15
|
+
streamSid: z.string().min(1),
|
|
16
|
+
mediaFormat: z
|
|
17
|
+
.object({
|
|
18
|
+
encoding: z.string().optional(),
|
|
19
|
+
sampleRate: z.number().optional(),
|
|
20
|
+
channels: z.number().optional(),
|
|
21
|
+
})
|
|
22
|
+
.optional(),
|
|
23
|
+
customParameters: z.record(z.string(), z.string()).optional(),
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const mediaEventSchema = z.object({
|
|
28
|
+
event: z.literal('media'),
|
|
29
|
+
media: z.object({
|
|
30
|
+
payload: z.string(),
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const stopEventSchema = z.object({
|
|
35
|
+
event: z.literal('stop'),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const supportedEventSchema = z.discriminatedUnion('event', [
|
|
39
|
+
connectedEventSchema,
|
|
40
|
+
startEventSchema,
|
|
41
|
+
mediaEventSchema,
|
|
42
|
+
stopEventSchema,
|
|
43
|
+
]);
|
|
44
|
+
/** @type {Set<string>} */
|
|
45
|
+
const SUPPORTED_EVENTS = new Set(
|
|
46
|
+
supportedEventSchema.options.map((schema) => schema.shape.event.value)
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const BASE64_PAYLOAD_REGEX = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {unknown} raw
|
|
53
|
+
* @returns {WsEventParseResult}
|
|
54
|
+
*/
|
|
55
|
+
function parseTwilioWsEvent(raw) {
|
|
56
|
+
const base = z.object({ event: z.string().min(1) }).safeParse(raw);
|
|
57
|
+
if (!base.success) {
|
|
58
|
+
return { ok: false, reason: 'missing event field' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!SUPPORTED_EVENTS.has(base.data.event)) {
|
|
62
|
+
return {
|
|
63
|
+
ok: true,
|
|
64
|
+
unsupported: true,
|
|
65
|
+
event: base.data.event,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const parsed = supportedEventSchema.safeParse(raw);
|
|
70
|
+
if (!parsed.success) {
|
|
71
|
+
return { ok: false, reason: `invalid ${base.data.event} payload` };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
switch (parsed.data.event) {
|
|
75
|
+
case 'connected':
|
|
76
|
+
return { ok: true, unsupported: false, event: 'connected', data: parsed.data };
|
|
77
|
+
case 'start':
|
|
78
|
+
return { ok: true, unsupported: false, event: 'start', data: parsed.data };
|
|
79
|
+
case 'media':
|
|
80
|
+
return { ok: true, unsupported: false, event: 'media', data: parsed.data };
|
|
81
|
+
case 'stop':
|
|
82
|
+
return { ok: true, unsupported: false, event: 'stop', data: parsed.data };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {unknown} payload
|
|
88
|
+
* @param {number} maxDecodedBytes
|
|
89
|
+
* @returns {MediaPayloadParseResult}
|
|
90
|
+
*/
|
|
91
|
+
function parseTwilioMediaPayload(payload, maxDecodedBytes) {
|
|
92
|
+
if (typeof payload !== 'string') {
|
|
93
|
+
return { ok: false, reason: 'invalid media payload encoding' };
|
|
94
|
+
}
|
|
95
|
+
if (payload.length % 4 !== 0 || !BASE64_PAYLOAD_REGEX.test(payload)) {
|
|
96
|
+
return { ok: false, reason: 'invalid media payload encoding' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const decoded = Buffer.from(payload, 'base64');
|
|
100
|
+
if (decoded.length > maxDecodedBytes) {
|
|
101
|
+
return { ok: false, reason: 'media payload too large' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { ok: true, decoded };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = {
|
|
108
|
+
parseTwilioWsEvent,
|
|
109
|
+
parseTwilioMediaPayload,
|
|
110
|
+
};
|