@forklaunch/core 2.1.1 → 3.0.1
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/bin/gateway-mock/routes/email.mjs +233 -0
- package/bin/gateway-mock/routes/models.mjs +192 -0
- package/bin/gateway-mock/routes/payments.mjs +541 -0
- package/bin/gateway-mock/routes/sms.mjs +209 -0
- package/bin/gateway-mock/routes/voice.mjs +237 -0
- package/bin/gateway-mock/routes/whatsapp.mjs +227 -0
- package/bin/gateway-mock/server.mjs +321 -0
- package/bin/migrate-compliant-fields.mjs +312 -0
- package/bin/model-gateway-mock.mjs +13 -0
- package/lib/http/emailClient.d.ts +102 -0
- package/lib/http/emailClient.d.ts.map +1 -0
- package/lib/http/index.d.ts +7 -0
- package/lib/http/index.d.ts.map +1 -1
- package/lib/http/index.js +850 -4
- package/lib/http/index.js.map +1 -1
- package/lib/http/index.mjs +809 -1
- package/lib/http/index.mjs.map +1 -1
- package/lib/http/instanceGateway.d.ts +113 -0
- package/lib/http/instanceGateway.d.ts.map +1 -0
- package/lib/http/modelGatewayClient.d.ts +126 -0
- package/lib/http/modelGatewayClient.d.ts.map +1 -0
- package/lib/http/paymentsClient.d.ts +123 -0
- package/lib/http/paymentsClient.d.ts.map +1 -0
- package/lib/http/smsClient.d.ts +123 -0
- package/lib/http/smsClient.d.ts.map +1 -0
- package/lib/http/voiceClient.d.ts +105 -0
- package/lib/http/voiceClient.d.ts.map +1 -0
- package/lib/http/whatsappClient.d.ts +118 -0
- package/lib/http/whatsappClient.d.ts.map +1 -0
- package/lib/objectstore/interfaces/objectstore.interface.d.ts +45 -0
- package/lib/objectstore/interfaces/objectstore.interface.d.ts.map +1 -1
- package/lib/persistence/compliancePropertyBuilder.d.ts.map +1 -1
- package/lib/persistence/complianceTypes.d.ts +10 -1
- package/lib/persistence/complianceTypes.d.ts.map +1 -1
- package/lib/persistence/compliantField.d.ts +203 -0
- package/lib/persistence/compliantField.d.ts.map +1 -0
- package/lib/persistence/defineComplianceEntity.d.ts.map +1 -1
- package/lib/persistence/encryptedType.d.ts +8 -1
- package/lib/persistence/encryptedType.d.ts.map +1 -1
- package/lib/persistence/fieldEncryptor.d.ts +47 -6
- package/lib/persistence/fieldEncryptor.d.ts.map +1 -1
- package/lib/persistence/index.d.ts +3 -2
- package/lib/persistence/index.d.ts.map +1 -1
- package/lib/persistence/index.js +987 -259
- package/lib/persistence/index.js.map +1 -1
- package/lib/persistence/index.mjs +976 -257
- package/lib/persistence/index.mjs.map +1 -1
- package/lib/persistence/keyRotation.d.ts +5 -0
- package/lib/persistence/keyRotation.d.ts.map +1 -1
- package/lib/services/index.js.map +1 -1
- package/lib/services/index.mjs.map +1 -1
- package/package.json +27 -11
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SMS: the platform's End User Messaging SMS gateway (createSmsClient), and
|
|
3
|
+
* the handset on the other end.
|
|
4
|
+
*
|
|
5
|
+
* Gateway route (HMAC-verified like every gateway route):
|
|
6
|
+
* POST /sms/send { to, body, purpose? } -> { messageId, segments }
|
|
7
|
+
* 400 `to` is not E.164, the body is empty or over 10 segments, or
|
|
8
|
+
* `purpose` is not transactional/promotional
|
|
9
|
+
* 422 the number opted out (texted STOP) — the platform refuses it too
|
|
10
|
+
* 429 the monthly segment cap or the per-minute limit is spent
|
|
11
|
+
* (with retry-after)
|
|
12
|
+
* After answering, emits `sms.delivered` — or `sms.failed` for a number
|
|
13
|
+
* ending in 0000, as a carrier would report an unreachable handset.
|
|
14
|
+
*
|
|
15
|
+
* Vendor-side routes (unauthenticated, for tests and local play):
|
|
16
|
+
* GET /__mock/sms/messages every accepted send, in order
|
|
17
|
+
* POST /__mock/sms/inbound { from, body }
|
|
18
|
+
* a text arriving from a handset: emits `sms.received`; STOP (and the
|
|
19
|
+
* other opt-out keywords) also records the opt-out and emits
|
|
20
|
+
* `sms.opted_out`; START/UNSTOP removes it again. HELP is answered by
|
|
21
|
+
* the carrier-required help reply and still relayed as received.
|
|
22
|
+
*
|
|
23
|
+
* Settings:
|
|
24
|
+
* MOCK_SMS_MONTHLY_CAP segments per calendar month, default 100
|
|
25
|
+
* MOCK_SMS_PER_MINUTE sends per minute, default 60
|
|
26
|
+
*/
|
|
27
|
+
import { randomUUID } from 'node:crypto';
|
|
28
|
+
|
|
29
|
+
const E164 = /^\+[1-9]\d{6,14}$/;
|
|
30
|
+
const MAX_SEGMENTS = 10;
|
|
31
|
+
// The keywords AWS End User Messaging treats as opt-out / opt-in by default.
|
|
32
|
+
const OPT_OUT = new Set([
|
|
33
|
+
'STOP',
|
|
34
|
+
'STOPALL',
|
|
35
|
+
'UNSUBSCRIBE',
|
|
36
|
+
'CANCEL',
|
|
37
|
+
'END',
|
|
38
|
+
'QUIT',
|
|
39
|
+
'OPTOUT',
|
|
40
|
+
'OPT-OUT',
|
|
41
|
+
'REVOKE'
|
|
42
|
+
]);
|
|
43
|
+
const OPT_IN = new Set(['START', 'UNSTOP', 'YES']);
|
|
44
|
+
const HELP = new Set(['HELP', 'INFO']);
|
|
45
|
+
|
|
46
|
+
const GSM_BASIC = new Set(
|
|
47
|
+
'@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà'
|
|
48
|
+
);
|
|
49
|
+
const GSM_EXTENDED = new Set('^{}\\[~]|€\f');
|
|
50
|
+
|
|
51
|
+
/** The same segment arithmetic as smsSegments in @forklaunch/core/http. */
|
|
52
|
+
function segmentsOf(body) {
|
|
53
|
+
let septets = 0;
|
|
54
|
+
for (const ch of body) {
|
|
55
|
+
if (GSM_BASIC.has(ch)) septets += 1;
|
|
56
|
+
else if (GSM_EXTENDED.has(ch)) septets += 2;
|
|
57
|
+
else return body.length <= 70 ? 1 : Math.ceil(body.length / 67);
|
|
58
|
+
}
|
|
59
|
+
return septets <= 160 ? 1 : Math.ceil(septets / 153);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const monthOf = (d) =>
|
|
63
|
+
`${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
|
|
64
|
+
|
|
65
|
+
function secondsUntilNextMonth(now) {
|
|
66
|
+
const next = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1);
|
|
67
|
+
return Math.max(1, Math.ceil((next - now.getTime()) / 1000));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function init(state) {
|
|
71
|
+
state.messages ??= [];
|
|
72
|
+
state.optedOut ??= new Set();
|
|
73
|
+
state.segmentsByMonth ??= {};
|
|
74
|
+
state.sendTimes ??= [];
|
|
75
|
+
return state;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const keywordOf = (body) =>
|
|
79
|
+
String(body ?? '')
|
|
80
|
+
.trim()
|
|
81
|
+
.split(/\s+/)[0]
|
|
82
|
+
?.toUpperCase() ?? '';
|
|
83
|
+
|
|
84
|
+
async function send(ctx) {
|
|
85
|
+
const state = init(ctx.state);
|
|
86
|
+
const { to, body, purpose = 'transactional' } = ctx.body ?? {};
|
|
87
|
+
if (typeof to !== 'string' || !E164.test(to)) {
|
|
88
|
+
return ctx.send(400, `'to' must be an E.164 number like +14155550123`);
|
|
89
|
+
}
|
|
90
|
+
if (typeof body !== 'string' || !body.trim()) {
|
|
91
|
+
return ctx.send(400, "'body' must be non-empty text");
|
|
92
|
+
}
|
|
93
|
+
if (purpose !== 'transactional' && purpose !== 'promotional') {
|
|
94
|
+
return ctx.send(400, "'purpose' must be 'transactional' or 'promotional'");
|
|
95
|
+
}
|
|
96
|
+
const segments = segmentsOf(body);
|
|
97
|
+
if (segments > MAX_SEGMENTS) {
|
|
98
|
+
return ctx.send(
|
|
99
|
+
400,
|
|
100
|
+
`'body' is ${segments} segments; the limit is ${MAX_SEGMENTS}`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (state.optedOut.has(to)) {
|
|
104
|
+
return ctx.send(
|
|
105
|
+
422,
|
|
106
|
+
`${to} has opted out of texts from this product (STOP)`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const now = new Date();
|
|
111
|
+
const perMinute = Number(ctx.env.MOCK_SMS_PER_MINUTE ?? 60);
|
|
112
|
+
state.sendTimes = state.sendTimes.filter((t) => now.getTime() - t < 60_000);
|
|
113
|
+
if (state.sendTimes.length >= perMinute) {
|
|
114
|
+
return ctx.send(429, `Rate limited: ${perMinute} texts per minute`, {
|
|
115
|
+
'retry-after': '60'
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const cap = Number(ctx.env.MOCK_SMS_MONTHLY_CAP ?? 100);
|
|
119
|
+
const month = monthOf(now);
|
|
120
|
+
const used = state.segmentsByMonth[month] ?? 0;
|
|
121
|
+
if (used + segments > cap) {
|
|
122
|
+
return ctx.send(
|
|
123
|
+
429,
|
|
124
|
+
`Monthly SMS cap reached: ${used} of ${cap} segments used this month`,
|
|
125
|
+
{ 'retry-after': String(secondsUntilNextMonth(now)) }
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
state.segmentsByMonth[month] = used + segments;
|
|
129
|
+
state.sendTimes.push(now.getTime());
|
|
130
|
+
|
|
131
|
+
const messageId = `mock-sms-${randomUUID()}`;
|
|
132
|
+
const failed = to.endsWith('0000');
|
|
133
|
+
state.messages.push({
|
|
134
|
+
messageId,
|
|
135
|
+
instanceId: ctx.instanceId,
|
|
136
|
+
to,
|
|
137
|
+
body,
|
|
138
|
+
purpose,
|
|
139
|
+
segments,
|
|
140
|
+
status: failed ? 'failed' : 'delivered',
|
|
141
|
+
sentAt: now.toISOString()
|
|
142
|
+
});
|
|
143
|
+
ctx.send(200, { messageId, segments });
|
|
144
|
+
|
|
145
|
+
// The carrier's receipt, after the send was accepted.
|
|
146
|
+
if (failed) {
|
|
147
|
+
await ctx.emit('sms.failed', {
|
|
148
|
+
messageId,
|
|
149
|
+
to,
|
|
150
|
+
reason: 'UNREACHABLE: the handset is not reachable'
|
|
151
|
+
});
|
|
152
|
+
} else {
|
|
153
|
+
await ctx.emit('sms.delivered', {
|
|
154
|
+
messageId,
|
|
155
|
+
to,
|
|
156
|
+
deliveredAt: new Date().toISOString()
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function inbound(ctx) {
|
|
162
|
+
const state = init(ctx.state);
|
|
163
|
+
const { from, body } = ctx.body ?? {};
|
|
164
|
+
if (typeof from !== 'string' || !E164.test(from)) {
|
|
165
|
+
return ctx.send(400, "'from' must be an E.164 number");
|
|
166
|
+
}
|
|
167
|
+
if (typeof body !== 'string') {
|
|
168
|
+
return ctx.send(400, "'body' must be text");
|
|
169
|
+
}
|
|
170
|
+
const receivedAt = new Date().toISOString();
|
|
171
|
+
const word = keywordOf(body);
|
|
172
|
+
const keyword =
|
|
173
|
+
OPT_OUT.has(word) || OPT_IN.has(word) || HELP.has(word) ? word : undefined;
|
|
174
|
+
const delivered = [];
|
|
175
|
+
delivered.push(
|
|
176
|
+
await ctx.emit('sms.received', {
|
|
177
|
+
from,
|
|
178
|
+
body,
|
|
179
|
+
receivedAt,
|
|
180
|
+
...(keyword ? { keyword } : {})
|
|
181
|
+
})
|
|
182
|
+
);
|
|
183
|
+
if (OPT_OUT.has(word)) {
|
|
184
|
+
state.optedOut.add(from);
|
|
185
|
+
delivered.push(
|
|
186
|
+
await ctx.emit('sms.opted_out', { phone: from, optedOutAt: receivedAt })
|
|
187
|
+
);
|
|
188
|
+
} else if (OPT_IN.has(word)) {
|
|
189
|
+
state.optedOut.delete(from);
|
|
190
|
+
}
|
|
191
|
+
return ctx.send(200, {
|
|
192
|
+
keyword: keyword ?? null,
|
|
193
|
+
optedOut: state.optedOut.has(from),
|
|
194
|
+
events: delivered.map((d) => ({ type: d.event.type, status: d.status }))
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export default {
|
|
199
|
+
feature: 'sms',
|
|
200
|
+
routes: [{ method: 'POST', path: '/sms/send', handler: send }],
|
|
201
|
+
vendorRoutes: [
|
|
202
|
+
{
|
|
203
|
+
method: 'GET',
|
|
204
|
+
path: '/__mock/sms/messages',
|
|
205
|
+
handler: (ctx) => ctx.send(200, init(ctx.state).messages)
|
|
206
|
+
},
|
|
207
|
+
{ method: 'POST', path: '/__mock/sms/inbound', handler: inbound }
|
|
208
|
+
]
|
|
209
|
+
};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voice: outbound calls (createVoiceClient) as the platform places them
|
|
3
|
+
* through Amazon Connect, and the call events Connect reports back.
|
|
4
|
+
*
|
|
5
|
+
* POST /voice/calls { to, flow, attributes? } -> { callId }
|
|
6
|
+
* GET /voice/calls/:id -> { status, startedAt, endedAt?, durationSeconds?, disconnectReason? }
|
|
7
|
+
* DELETE /voice/calls/:id hang up (reason `api`)
|
|
8
|
+
*
|
|
9
|
+
* A call starts `in_progress` and emits `voice.call.started` at once; after
|
|
10
|
+
* MOCK_VOICE_CALL_MS it ends by itself with `voice.call.ended`
|
|
11
|
+
* { callId, durationSeconds, disconnectReason: 'customer' }, then, with
|
|
12
|
+
* MOCK_VOICE_RECORD=1, `voice.recording.ready` { callId, recordingKey }.
|
|
13
|
+
* A number ending in 9999 is busy: it ends at once, reason `busy`, 0 seconds.
|
|
14
|
+
* Events are always delivered in that order.
|
|
15
|
+
*
|
|
16
|
+
* Refusals, as the platform answers them:
|
|
17
|
+
* 400 bad number, a flow not in the catalog, a contact-flow ARN, bad attributes
|
|
18
|
+
* 404 a call this instance did not place
|
|
19
|
+
* 409 hanging up a call that already ended; no number claimed (MOCK_VOICE_NO_NUMBER=1)
|
|
20
|
+
* 429 too many calls in progress, or the month's minutes are spent
|
|
21
|
+
*
|
|
22
|
+
* MOCK_VOICE_FLOWS comma-separated flow names, default appointment_reminder
|
|
23
|
+
* MOCK_VOICE_CALL_MS how long a call lasts in real time, default 200
|
|
24
|
+
* MOCK_VOICE_DURATION_SECONDS the duration a completed call reports, default 42
|
|
25
|
+
* MOCK_VOICE_MAX_CONCURRENT calls in progress per instance, default 2
|
|
26
|
+
* MOCK_VOICE_MONTHLY_MINUTES billed minutes per instance, default 1000
|
|
27
|
+
* MOCK_VOICE_RECORD 1: emit voice.recording.ready after each answered call
|
|
28
|
+
* MOCK_VOICE_NO_NUMBER 1: the instance has no claimed number (409)
|
|
29
|
+
*/
|
|
30
|
+
import { randomUUID } from 'node:crypto';
|
|
31
|
+
|
|
32
|
+
const env = process.env;
|
|
33
|
+
const FLOWS = (env.MOCK_VOICE_FLOWS ?? 'appointment_reminder')
|
|
34
|
+
.split(',')
|
|
35
|
+
.map((f) => f.trim())
|
|
36
|
+
.filter(Boolean);
|
|
37
|
+
const CALL_MS = Number(env.MOCK_VOICE_CALL_MS ?? 200);
|
|
38
|
+
const DURATION_SECONDS = Number(env.MOCK_VOICE_DURATION_SECONDS ?? 42);
|
|
39
|
+
const MAX_CONCURRENT = Number(env.MOCK_VOICE_MAX_CONCURRENT ?? 2);
|
|
40
|
+
const MONTHLY_MINUTES = Number(env.MOCK_VOICE_MONTHLY_MINUTES ?? 1000);
|
|
41
|
+
const RECORD = env.MOCK_VOICE_RECORD === '1';
|
|
42
|
+
const NO_NUMBER = env.MOCK_VOICE_NO_NUMBER === '1';
|
|
43
|
+
|
|
44
|
+
// The gateway's attribute limits (VOICE_ATTRIBUTE_LIMITS in the framework).
|
|
45
|
+
const MAX_KEYS = 20;
|
|
46
|
+
const MAX_KEY_LENGTH = 64;
|
|
47
|
+
const MAX_VALUE_LENGTH = 256;
|
|
48
|
+
const MAX_TOTAL_BYTES = 4096;
|
|
49
|
+
|
|
50
|
+
const timers = new Set();
|
|
51
|
+
|
|
52
|
+
function problem(body) {
|
|
53
|
+
if (typeof body.to !== 'string' || !/^\+[1-9]\d{6,14}$/.test(body.to)) {
|
|
54
|
+
return '`to` must be an E.164 number such as +15551230000';
|
|
55
|
+
}
|
|
56
|
+
if (typeof body.flow !== 'string' || body.flow.startsWith('arn:')) {
|
|
57
|
+
return '`flow` is a flow name from the product catalog, not a contact-flow ARN';
|
|
58
|
+
}
|
|
59
|
+
if (!FLOWS.includes(body.flow)) {
|
|
60
|
+
return `Unknown flow '${body.flow}'; this product's flows: ${FLOWS.join(', ')}`;
|
|
61
|
+
}
|
|
62
|
+
const attributes = body.attributes ?? {};
|
|
63
|
+
if (typeof attributes !== 'object' || Array.isArray(attributes)) {
|
|
64
|
+
return '`attributes` must be an object of strings';
|
|
65
|
+
}
|
|
66
|
+
const entries = Object.entries(attributes);
|
|
67
|
+
if (entries.length > MAX_KEYS) return `at most ${MAX_KEYS} attributes`;
|
|
68
|
+
let total = 0;
|
|
69
|
+
for (const [key, value] of entries) {
|
|
70
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(key) || key.length > MAX_KEY_LENGTH) {
|
|
71
|
+
return `attribute key '${key}' must be letters, digits or _`;
|
|
72
|
+
}
|
|
73
|
+
if (key.toLowerCase().startsWith('fl_')) {
|
|
74
|
+
return "attribute keys starting with 'fl_' are reserved for the platform";
|
|
75
|
+
}
|
|
76
|
+
if (typeof value !== 'string') return `attribute '${key}' must be a string`;
|
|
77
|
+
if (value.length > MAX_VALUE_LENGTH) {
|
|
78
|
+
return `attribute '${key}' is longer than ${MAX_VALUE_LENGTH} characters`;
|
|
79
|
+
}
|
|
80
|
+
total += Buffer.byteLength(key) + Buffer.byteLength(value);
|
|
81
|
+
}
|
|
82
|
+
if (total > MAX_TOTAL_BYTES)
|
|
83
|
+
return `attributes exceed ${MAX_TOTAL_BYTES} bytes`;
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function calls(state) {
|
|
88
|
+
state.calls ??= new Map();
|
|
89
|
+
return state.calls;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function minutesUsed(state, instanceId) {
|
|
93
|
+
state.minutes ??= new Map();
|
|
94
|
+
return state.minutes.get(instanceId) ?? 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** End a call once: record it, bill it, then emit ended (after started). */
|
|
98
|
+
function finish(ctx, call, reason, durationSeconds) {
|
|
99
|
+
if (call.status === 'ended') return;
|
|
100
|
+
if (call.timer) {
|
|
101
|
+
clearTimeout(call.timer);
|
|
102
|
+
timers.delete(call.timer);
|
|
103
|
+
}
|
|
104
|
+
call.status = 'ended';
|
|
105
|
+
call.endedAt = new Date().toISOString();
|
|
106
|
+
call.durationSeconds = durationSeconds;
|
|
107
|
+
call.disconnectReason = reason;
|
|
108
|
+
ctx.state.minutes ??= new Map();
|
|
109
|
+
ctx.state.minutes.set(
|
|
110
|
+
call.instanceId,
|
|
111
|
+
minutesUsed(ctx.state, call.instanceId) + Math.ceil(durationSeconds / 60)
|
|
112
|
+
);
|
|
113
|
+
call.events = call.events.then(async () => {
|
|
114
|
+
await ctx.emit('voice.call.ended', {
|
|
115
|
+
callId: call.id,
|
|
116
|
+
durationSeconds,
|
|
117
|
+
disconnectReason: reason
|
|
118
|
+
});
|
|
119
|
+
if (RECORD && durationSeconds > 0) {
|
|
120
|
+
await ctx.emit('voice.recording.ready', {
|
|
121
|
+
callId: call.id,
|
|
122
|
+
recordingKey: `voice/recordings/${call.id}.wav`
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function view(call) {
|
|
129
|
+
return {
|
|
130
|
+
status: call.status,
|
|
131
|
+
startedAt: call.startedAt,
|
|
132
|
+
...(call.endedAt ? { endedAt: call.endedAt } : {}),
|
|
133
|
+
...(call.durationSeconds !== undefined
|
|
134
|
+
? { durationSeconds: call.durationSeconds }
|
|
135
|
+
: {}),
|
|
136
|
+
...(call.disconnectReason
|
|
137
|
+
? { disconnectReason: call.disconnectReason }
|
|
138
|
+
: {})
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function ownCall(ctx) {
|
|
143
|
+
const call = calls(ctx.state).get(ctx.params.id);
|
|
144
|
+
return call && call.instanceId === ctx.instanceId ? call : undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function startCall(ctx) {
|
|
148
|
+
const body = ctx.body ?? {};
|
|
149
|
+
const refused = problem(body);
|
|
150
|
+
if (refused) return ctx.send(400, refused);
|
|
151
|
+
if (NO_NUMBER) {
|
|
152
|
+
return ctx.send(
|
|
153
|
+
409,
|
|
154
|
+
'No phone number is claimed for this instance; the platform claims one when voice is provisioned'
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const active = [...calls(ctx.state).values()].filter(
|
|
158
|
+
(c) => c.instanceId === ctx.instanceId && c.status !== 'ended'
|
|
159
|
+
).length;
|
|
160
|
+
if (active >= MAX_CONCURRENT) {
|
|
161
|
+
return ctx.send(
|
|
162
|
+
429,
|
|
163
|
+
`${active} calls in progress; this instance may have ${MAX_CONCURRENT} at once`,
|
|
164
|
+
{ 'retry-after': '1' }
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (minutesUsed(ctx.state, ctx.instanceId) >= MONTHLY_MINUTES) {
|
|
168
|
+
return ctx.send(
|
|
169
|
+
429,
|
|
170
|
+
`This month's ${MONTHLY_MINUTES} call minutes are spent`,
|
|
171
|
+
{
|
|
172
|
+
'retry-after': '86400'
|
|
173
|
+
}
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const call = {
|
|
178
|
+
id: `mock-call-${randomUUID()}`,
|
|
179
|
+
instanceId: ctx.instanceId,
|
|
180
|
+
flow: body.flow,
|
|
181
|
+
to: body.to,
|
|
182
|
+
status: 'in_progress',
|
|
183
|
+
startedAt: new Date().toISOString(),
|
|
184
|
+
startedMs: Date.now(),
|
|
185
|
+
events: Promise.resolve()
|
|
186
|
+
};
|
|
187
|
+
calls(ctx.state).set(call.id, call);
|
|
188
|
+
ctx.send(200, { callId: call.id });
|
|
189
|
+
|
|
190
|
+
call.events = call.events.then(() =>
|
|
191
|
+
ctx.emit('voice.call.started', { callId: call.id, flow: call.flow })
|
|
192
|
+
);
|
|
193
|
+
if (call.to.endsWith('9999')) {
|
|
194
|
+
finish(ctx, call, 'busy', 0);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
call.timer = setTimeout(() => {
|
|
198
|
+
timers.delete(call.timer);
|
|
199
|
+
call.timer = undefined;
|
|
200
|
+
finish(ctx, call, 'customer', DURATION_SECONDS);
|
|
201
|
+
}, CALL_MS);
|
|
202
|
+
timers.add(call.timer);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function endCall(ctx) {
|
|
206
|
+
const call = ownCall(ctx);
|
|
207
|
+
if (!call) return ctx.send(404, 'No such call for this instance');
|
|
208
|
+
if (call.status === 'ended') return ctx.send(409, 'The call already ended');
|
|
209
|
+
finish(
|
|
210
|
+
ctx,
|
|
211
|
+
call,
|
|
212
|
+
'api',
|
|
213
|
+
Math.max(1, Math.ceil((Date.now() - call.startedMs) / 1000))
|
|
214
|
+
);
|
|
215
|
+
return ctx.send(200, { callId: call.id, status: 'ended' });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function getCall(ctx) {
|
|
219
|
+
const call = ownCall(ctx);
|
|
220
|
+
if (!call) return ctx.send(404, 'No such call for this instance');
|
|
221
|
+
return ctx.send(200, view(call));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const CALL_ID = /^\/voice\/calls\/(?<id>[A-Za-z0-9_-]{1,128})$/;
|
|
225
|
+
|
|
226
|
+
export default {
|
|
227
|
+
feature: 'voice',
|
|
228
|
+
routes: [
|
|
229
|
+
{ method: 'POST', path: '/voice/calls', handler: startCall },
|
|
230
|
+
{ method: 'GET', path: CALL_ID, handler: getCall },
|
|
231
|
+
{ method: 'DELETE', path: CALL_ID, handler: endCall }
|
|
232
|
+
],
|
|
233
|
+
reset() {
|
|
234
|
+
for (const timer of timers) clearTimeout(timer);
|
|
235
|
+
timers.clear();
|
|
236
|
+
}
|
|
237
|
+
};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WhatsApp: the instance gateway's WhatsApp routes (createWhatsAppClient),
|
|
3
|
+
* standing in for the platform and AWS End User Messaging Social / Meta.
|
|
4
|
+
*
|
|
5
|
+
* Gateway routes (HMAC-verified like every gateway route):
|
|
6
|
+
* GET /whatsapp/templates { templates: [{ name, language, status, category }] }
|
|
7
|
+
* POST /whatsapp/messages { to, type: 'template', template, language, components? }
|
|
8
|
+
* { to, type: 'text', body }
|
|
9
|
+
* → { messageId }
|
|
10
|
+
*
|
|
11
|
+
* What it refuses, as the platform does:
|
|
12
|
+
* 400 bad recipient (not E.164), unknown template or language, a template
|
|
13
|
+
* that is not APPROVED, empty text, an unknown type
|
|
14
|
+
* 403 MOCK_WHATSAPP_HIPAA=1: a HIPAA product may not send WhatsApp at all
|
|
15
|
+
* 409 MOCK_WHATSAPP_UNCONFIGURED=1: no phone number linked for the instance
|
|
16
|
+
* 422 free-form text outside the 24-hour customer-service window
|
|
17
|
+
* 429 more than MOCK_WHATSAPP_RPM sends in a minute (retry-after set)
|
|
18
|
+
*
|
|
19
|
+
* Events (to /platform-events/whatsapp on the app):
|
|
20
|
+
* whatsapp.status { messageId, status, recipient, timestamp } — `sent`
|
|
21
|
+
* then `delivered` after every accepted send
|
|
22
|
+
* whatsapp.received { from, text, type, receivedAt, messageId } — on inbound
|
|
23
|
+
*
|
|
24
|
+
* Control (unauthenticated, for tests and local development):
|
|
25
|
+
* POST /__mock/whatsapp/inbound { from, text, receivedAt? }
|
|
26
|
+
* simulates a person writing to the business number: emits
|
|
27
|
+
* whatsapp.received and opens the 24-hour window for `from` (from
|
|
28
|
+
* `receivedAt` when given, so an expired window can be simulated).
|
|
29
|
+
* GET /__mock/whatsapp/messages the messages sent so far
|
|
30
|
+
*
|
|
31
|
+
* Environment:
|
|
32
|
+
* MOCK_WHATSAPP_TEMPLATES name:language:STATUS[:CATEGORY], comma-separated;
|
|
33
|
+
* default appointment_reminder:en_US:APPROVED:UTILITY
|
|
34
|
+
* MOCK_WHATSAPP_HIPAA 1: refuse every send with 403
|
|
35
|
+
* MOCK_WHATSAPP_UNCONFIGURED 1: refuse every call with 409
|
|
36
|
+
* MOCK_WHATSAPP_RPM sends per minute, default 60
|
|
37
|
+
*/
|
|
38
|
+
import { randomUUID } from 'node:crypto';
|
|
39
|
+
|
|
40
|
+
const WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
41
|
+
const E164 = /^\+[1-9]\d{6,14}$/;
|
|
42
|
+
|
|
43
|
+
function templatesFrom(env) {
|
|
44
|
+
return String(
|
|
45
|
+
env.MOCK_WHATSAPP_TEMPLATES ?? 'appointment_reminder:en_US:APPROVED:UTILITY'
|
|
46
|
+
)
|
|
47
|
+
.split(',')
|
|
48
|
+
.map((t) => t.trim())
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.map((t) => {
|
|
51
|
+
const [name, language = 'en_US', status = 'APPROVED', category] =
|
|
52
|
+
t.split(':');
|
|
53
|
+
return { name, language, status, ...(category ? { category } : {}) };
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const on = (value) => value === '1' || value === 'true';
|
|
58
|
+
|
|
59
|
+
function st(ctx) {
|
|
60
|
+
ctx.state.windows ??= {};
|
|
61
|
+
ctx.state.sent ??= [];
|
|
62
|
+
ctx.state.minute ??= { start: 0, count: 0 };
|
|
63
|
+
return ctx.state;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Platform-level refusals that apply to every call. */
|
|
67
|
+
function refused(ctx) {
|
|
68
|
+
if (on(ctx.env.MOCK_WHATSAPP_UNCONFIGURED)) {
|
|
69
|
+
ctx.send(
|
|
70
|
+
409,
|
|
71
|
+
'No WhatsApp phone number is linked for this instance or its product: link a WhatsApp Business Account (Meta embedded signup in AWS End User Messaging Social) and set its phone number id in the product settings'
|
|
72
|
+
);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function statusEvents(ctx, messageId, to) {
|
|
79
|
+
// After the answer, as the vendor reports: sent, then delivered.
|
|
80
|
+
return (async () => {
|
|
81
|
+
for (const status of ['sent', 'delivered']) {
|
|
82
|
+
await ctx.emit('whatsapp.status', {
|
|
83
|
+
messageId,
|
|
84
|
+
status,
|
|
85
|
+
recipient: to,
|
|
86
|
+
timestamp: new Date().toISOString()
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
})();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function sendMessage(ctx) {
|
|
93
|
+
if (refused(ctx)) return;
|
|
94
|
+
const state = st(ctx);
|
|
95
|
+
const { body, env } = ctx;
|
|
96
|
+
if (on(env.MOCK_WHATSAPP_HIPAA)) {
|
|
97
|
+
return ctx.send(
|
|
98
|
+
403,
|
|
99
|
+
'WhatsApp is disabled for HIPAA products: it is not covered by the AWS BAA and Meta signs no BAA, so no message may be sent'
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (typeof body.to !== 'string' || !E164.test(body.to)) {
|
|
103
|
+
return ctx.send(400, "'to' must be an E.164 phone number");
|
|
104
|
+
}
|
|
105
|
+
const rpm = Number(env.MOCK_WHATSAPP_RPM ?? 60);
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
if (now - state.minute.start >= 60_000)
|
|
108
|
+
state.minute = { start: now, count: 0 };
|
|
109
|
+
if (state.minute.count >= rpm) {
|
|
110
|
+
const wait = Math.max(
|
|
111
|
+
1,
|
|
112
|
+
Math.ceil((state.minute.start + 60_000 - now) / 1000)
|
|
113
|
+
);
|
|
114
|
+
return ctx.send(429, `Rate limited: ${rpm} WhatsApp sends per minute`, {
|
|
115
|
+
'retry-after': String(wait)
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let record;
|
|
120
|
+
if (body.type === 'template') {
|
|
121
|
+
const templates = templatesFrom(env);
|
|
122
|
+
const named = templates.filter((t) => t.name === body.template);
|
|
123
|
+
if (named.length === 0) {
|
|
124
|
+
return ctx.send(
|
|
125
|
+
400,
|
|
126
|
+
`Unknown template '${body.template}'; known: ${templates.map((t) => t.name).join(', ')}`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const template = named.find((t) => t.language === body.language);
|
|
130
|
+
if (!template) {
|
|
131
|
+
return ctx.send(
|
|
132
|
+
400,
|
|
133
|
+
`Template '${body.template}' has no '${body.language}' translation; it has ${named.map((t) => t.language).join(', ')}`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (template.status !== 'APPROVED') {
|
|
137
|
+
return ctx.send(
|
|
138
|
+
400,
|
|
139
|
+
`Template '${body.template}' (${body.language}) is ${template.status}, not APPROVED by Meta`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
record = {
|
|
143
|
+
type: 'template',
|
|
144
|
+
template: body.template,
|
|
145
|
+
language: body.language,
|
|
146
|
+
components: body.components ?? []
|
|
147
|
+
};
|
|
148
|
+
} else if (body.type === 'text') {
|
|
149
|
+
if (typeof body.body !== 'string' || !body.body.trim()) {
|
|
150
|
+
return ctx.send(400, 'body must be non-empty text');
|
|
151
|
+
}
|
|
152
|
+
const lastInbound = state.windows[body.to];
|
|
153
|
+
if (!lastInbound || now - lastInbound > WINDOW_MS) {
|
|
154
|
+
return ctx.send(
|
|
155
|
+
422,
|
|
156
|
+
`Free-form text is only allowed within 24 hours of ${body.to}'s last message; send an approved template instead`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
record = { type: 'text', body: body.body };
|
|
160
|
+
} else {
|
|
161
|
+
return ctx.send(400, "type must be 'template' or 'text'");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
state.minute.count += 1;
|
|
165
|
+
const messageId = `wamid.mock-${randomUUID()}`;
|
|
166
|
+
state.sent.push({
|
|
167
|
+
messageId,
|
|
168
|
+
to: body.to,
|
|
169
|
+
instanceId: ctx.instanceId,
|
|
170
|
+
...record,
|
|
171
|
+
at: new Date(now).toISOString()
|
|
172
|
+
});
|
|
173
|
+
ctx.send(200, { messageId });
|
|
174
|
+
await statusEvents(ctx, messageId, body.to);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export default {
|
|
178
|
+
feature: 'whatsapp',
|
|
179
|
+
routes: [
|
|
180
|
+
{
|
|
181
|
+
method: 'GET',
|
|
182
|
+
path: '/whatsapp/templates',
|
|
183
|
+
handler: (ctx) => {
|
|
184
|
+
if (refused(ctx)) return;
|
|
185
|
+
return ctx.send(200, { templates: templatesFrom(ctx.env) });
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
{ method: 'POST', path: '/whatsapp/messages', handler: sendMessage }
|
|
189
|
+
],
|
|
190
|
+
vendorRoutes: [
|
|
191
|
+
{
|
|
192
|
+
method: 'POST',
|
|
193
|
+
path: '/__mock/whatsapp/inbound',
|
|
194
|
+
handler: async (ctx) => {
|
|
195
|
+
const state = st(ctx);
|
|
196
|
+
const { from, text } = ctx.body ?? {};
|
|
197
|
+
if (typeof from !== 'string' || !E164.test(from)) {
|
|
198
|
+
return ctx.send(400, "'from' must be an E.164 phone number");
|
|
199
|
+
}
|
|
200
|
+
const at = ctx.body.receivedAt
|
|
201
|
+
? new Date(ctx.body.receivedAt)
|
|
202
|
+
: new Date();
|
|
203
|
+
if (Number.isNaN(at.getTime()))
|
|
204
|
+
return ctx.send(400, 'receivedAt is not a date');
|
|
205
|
+
state.windows[from] = Math.max(state.windows[from] ?? 0, at.getTime());
|
|
206
|
+
const messageId = `wamid.mock-in-${randomUUID()}`;
|
|
207
|
+
const delivery = await ctx.emit('whatsapp.received', {
|
|
208
|
+
from,
|
|
209
|
+
...(typeof text === 'string' ? { text } : {}),
|
|
210
|
+
type: typeof text === 'string' ? 'text' : 'unknown',
|
|
211
|
+
receivedAt: at.toISOString(),
|
|
212
|
+
messageId
|
|
213
|
+
});
|
|
214
|
+
return ctx.send(200, {
|
|
215
|
+
messageId,
|
|
216
|
+
delivered: delivery.status,
|
|
217
|
+
error: delivery.error
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
method: 'GET',
|
|
223
|
+
path: '/__mock/whatsapp/messages',
|
|
224
|
+
handler: (ctx) => ctx.send(200, st(ctx).sent)
|
|
225
|
+
}
|
|
226
|
+
]
|
|
227
|
+
};
|