@voxnova/sdk 0.0.0-stage → 1.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/README.md +19 -2
- package/dist/client.d.ts +269 -0
- package/dist/client.js +110 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/voice.d.ts +151 -0
- package/dist/voice.js +80 -0
- package/package.json +37 -4
package/README.md
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @voxnova/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
SDK TypeScript de la plateforme développeurs VoxNova (Node 18+, sans dépendance).
|
|
4
|
+
|
|
5
|
+
- `VoxNova` : client de l'API REST v1 (applications, endpoints SIP, numéros, appels, enregistrements, journal).
|
|
6
|
+
- `VoiceResponse` : construction des instructions renvoyées par votre webhook (dial plan en code).
|
|
7
|
+
- `verifySignature` : vérification de l'en-tête `X-VoxNova-Signature`.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { VoxNova, VoiceResponse, verifySignature } from '@voxnova/sdk';
|
|
11
|
+
|
|
12
|
+
const vx = new VoxNova({ apiKey: process.env.VX_KEY!, baseUrl: 'https://voip.exemple.fr' });
|
|
13
|
+
const ep = await vx.endpoints.create({ name: 'Accueil' });
|
|
14
|
+
|
|
15
|
+
// dans votre webhook
|
|
16
|
+
const r = new VoiceResponse().say(42).gather({ numDigits: 1, action: 'https://exemple.fr/menu' });
|
|
17
|
+
res.json(r);
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Documentation complète : chapitre 19 « Plateforme développeurs » et `GET /api/v1/openapi.json`.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client de l'API REST v1 (clé API vxk_…).
|
|
3
|
+
*/
|
|
4
|
+
export declare class VoxNovaError extends Error {
|
|
5
|
+
readonly status: number;
|
|
6
|
+
readonly body: unknown;
|
|
7
|
+
constructor(message: string, status: number, body: unknown);
|
|
8
|
+
}
|
|
9
|
+
export interface ClientOptions {
|
|
10
|
+
apiKey: string;
|
|
11
|
+
/** Adresse du panneau, ex. https://voip.example.com */
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
/** Délai par requête (15 s par défaut). */
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
fetch?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
export interface Application {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
voiceUrl: string;
|
|
21
|
+
fallbackUrl: string | null;
|
|
22
|
+
statusCallbackUrl: string | null;
|
|
23
|
+
signingSecret: string;
|
|
24
|
+
createdAt: string;
|
|
25
|
+
updatedAt: string;
|
|
26
|
+
}
|
|
27
|
+
export interface ApplicationInput {
|
|
28
|
+
name: string;
|
|
29
|
+
voiceUrl: string;
|
|
30
|
+
fallbackUrl?: string | null;
|
|
31
|
+
statusCallbackUrl?: string | null;
|
|
32
|
+
}
|
|
33
|
+
export interface Registration {
|
|
34
|
+
user: string;
|
|
35
|
+
agent: string;
|
|
36
|
+
ip: string;
|
|
37
|
+
port: string;
|
|
38
|
+
status: string;
|
|
39
|
+
contact: string;
|
|
40
|
+
}
|
|
41
|
+
export interface SipCredentials {
|
|
42
|
+
username: string;
|
|
43
|
+
authUsername: string;
|
|
44
|
+
password: string;
|
|
45
|
+
domain: string;
|
|
46
|
+
server: string;
|
|
47
|
+
port: number;
|
|
48
|
+
transports: string[];
|
|
49
|
+
outboundProxy: string;
|
|
50
|
+
voicemailNumber: string;
|
|
51
|
+
codecs: string[];
|
|
52
|
+
}
|
|
53
|
+
export interface Endpoint {
|
|
54
|
+
id: string;
|
|
55
|
+
number: string;
|
|
56
|
+
name: string;
|
|
57
|
+
enabled: boolean;
|
|
58
|
+
applicationId: string | null;
|
|
59
|
+
voicemail: boolean;
|
|
60
|
+
callerId: string | null;
|
|
61
|
+
permission: 'INTERNAL' | 'NATIONAL' | 'INTERNATIONAL';
|
|
62
|
+
recordCalls: boolean;
|
|
63
|
+
forward: {
|
|
64
|
+
always: string | null;
|
|
65
|
+
busy: string | null;
|
|
66
|
+
noAnswer: string | null;
|
|
67
|
+
noAnswerSeconds: number;
|
|
68
|
+
};
|
|
69
|
+
createdAt: string;
|
|
70
|
+
registered?: boolean;
|
|
71
|
+
sip?: SipCredentials;
|
|
72
|
+
registrations?: Registration[];
|
|
73
|
+
}
|
|
74
|
+
export interface EndpointInput {
|
|
75
|
+
number?: string;
|
|
76
|
+
name: string;
|
|
77
|
+
applicationId?: string | null;
|
|
78
|
+
password?: string;
|
|
79
|
+
voicemail?: boolean;
|
|
80
|
+
callerId?: string | null;
|
|
81
|
+
permission?: Endpoint['permission'];
|
|
82
|
+
recordCalls?: boolean;
|
|
83
|
+
enabled?: boolean;
|
|
84
|
+
forward?: Partial<Endpoint['forward']>;
|
|
85
|
+
}
|
|
86
|
+
export interface PhoneNumber {
|
|
87
|
+
number: string;
|
|
88
|
+
label: string;
|
|
89
|
+
destType: string | null;
|
|
90
|
+
routing: Record<string, string> | null;
|
|
91
|
+
monthlyPriceCents: number;
|
|
92
|
+
createdAt: string;
|
|
93
|
+
}
|
|
94
|
+
export interface CallInput {
|
|
95
|
+
to: string;
|
|
96
|
+
applicationId: string;
|
|
97
|
+
from?: string;
|
|
98
|
+
url?: string;
|
|
99
|
+
timeout?: number;
|
|
100
|
+
vars?: Record<string, string | number | boolean>;
|
|
101
|
+
}
|
|
102
|
+
export interface Page<T> {
|
|
103
|
+
items: T[];
|
|
104
|
+
total: number;
|
|
105
|
+
page: number;
|
|
106
|
+
}
|
|
107
|
+
export declare class VoxNova {
|
|
108
|
+
private readonly opts;
|
|
109
|
+
private readonly base;
|
|
110
|
+
private readonly f;
|
|
111
|
+
constructor(opts: ClientOptions);
|
|
112
|
+
/** Requête brute vers l'API (chemin commençant par /api/v1/). */
|
|
113
|
+
request<T>(method: string, path: string, body?: unknown, query?: Record<string, string | number | boolean | undefined>): Promise<T>;
|
|
114
|
+
readonly account: {
|
|
115
|
+
get: () => Promise<{
|
|
116
|
+
id: string;
|
|
117
|
+
name: string;
|
|
118
|
+
product: string;
|
|
119
|
+
status: string;
|
|
120
|
+
sipDomain: string;
|
|
121
|
+
limits: {
|
|
122
|
+
endpoints: number;
|
|
123
|
+
numbers: number;
|
|
124
|
+
channels: number | null;
|
|
125
|
+
};
|
|
126
|
+
usage: {
|
|
127
|
+
endpoints: number;
|
|
128
|
+
numbers: number;
|
|
129
|
+
applications: number;
|
|
130
|
+
};
|
|
131
|
+
}>;
|
|
132
|
+
usage: () => Promise<{
|
|
133
|
+
month: string;
|
|
134
|
+
label: string;
|
|
135
|
+
lines: {
|
|
136
|
+
label: string;
|
|
137
|
+
quantity: number;
|
|
138
|
+
unitCents: number;
|
|
139
|
+
totalCents: number;
|
|
140
|
+
}[];
|
|
141
|
+
subtotalCents: number;
|
|
142
|
+
}>;
|
|
143
|
+
};
|
|
144
|
+
readonly applications: {
|
|
145
|
+
list: () => Promise<Application[]>;
|
|
146
|
+
create: (input: ApplicationInput) => Promise<Application>;
|
|
147
|
+
get: (id: string) => Promise<Application>;
|
|
148
|
+
update: (id: string, input: Partial<ApplicationInput>) => Promise<Application>;
|
|
149
|
+
rotateSecret: (id: string) => Promise<Application>;
|
|
150
|
+
delete: (id: string) => Promise<{
|
|
151
|
+
ok: true;
|
|
152
|
+
}>;
|
|
153
|
+
};
|
|
154
|
+
readonly endpoints: {
|
|
155
|
+
list: () => Promise<Endpoint[]>;
|
|
156
|
+
create: (input: EndpointInput) => Promise<Endpoint>;
|
|
157
|
+
/** Par identifiant ou numéro interne ; inclut les identifiants SIP et les appareils enregistrés. */
|
|
158
|
+
get: (id: string) => Promise<Endpoint>;
|
|
159
|
+
update: (id: string, input: Partial<EndpointInput>) => Promise<Endpoint>;
|
|
160
|
+
registrations: (id: string) => Promise<Registration[]>;
|
|
161
|
+
resetPassword: (id: string) => Promise<Endpoint>;
|
|
162
|
+
disconnect: (id: string) => Promise<{
|
|
163
|
+
ok: true;
|
|
164
|
+
}>;
|
|
165
|
+
delete: (id: string) => Promise<{
|
|
166
|
+
ok: true;
|
|
167
|
+
}>;
|
|
168
|
+
};
|
|
169
|
+
readonly numbers: {
|
|
170
|
+
list: () => Promise<PhoneNumber[]>;
|
|
171
|
+
available: (opts?: {
|
|
172
|
+
contains?: string;
|
|
173
|
+
limit?: number;
|
|
174
|
+
}) => Promise<{
|
|
175
|
+
number: string;
|
|
176
|
+
monthlyPriceCents: number;
|
|
177
|
+
}[]>;
|
|
178
|
+
buy: (number: string, opts?: {
|
|
179
|
+
applicationId?: string;
|
|
180
|
+
label?: string;
|
|
181
|
+
}) => Promise<PhoneNumber>;
|
|
182
|
+
/** Relie le numéro à une application ou à un endpoint (null pour détacher). */
|
|
183
|
+
route: (number: string, to: {
|
|
184
|
+
applicationId?: string | null;
|
|
185
|
+
endpointId?: string | null;
|
|
186
|
+
label?: string;
|
|
187
|
+
}) => Promise<PhoneNumber>;
|
|
188
|
+
release: (number: string) => Promise<{
|
|
189
|
+
ok: true;
|
|
190
|
+
}>;
|
|
191
|
+
};
|
|
192
|
+
readonly calls: {
|
|
193
|
+
/** Appelle un endpoint ou un numéro ; au décroché, l'application prend la main (direction « api »). */
|
|
194
|
+
create: (input: CallInput) => Promise<{
|
|
195
|
+
callId: string;
|
|
196
|
+
status: string;
|
|
197
|
+
to: string;
|
|
198
|
+
from: string | null;
|
|
199
|
+
applicationId: string;
|
|
200
|
+
}>;
|
|
201
|
+
live: () => Promise<{
|
|
202
|
+
online: boolean;
|
|
203
|
+
calls: Record<string, unknown>[];
|
|
204
|
+
}>;
|
|
205
|
+
get: (id: string) => Promise<Record<string, unknown> & {
|
|
206
|
+
status: "in-progress" | "completed";
|
|
207
|
+
}>;
|
|
208
|
+
hangup: (id: string) => Promise<{
|
|
209
|
+
ok: true;
|
|
210
|
+
}>;
|
|
211
|
+
/** Reprend la main sur un appel en cours : `url` reçoit l'événement « redirect ». */
|
|
212
|
+
redirect: (id: string, opts?: {
|
|
213
|
+
url?: string;
|
|
214
|
+
applicationId?: string;
|
|
215
|
+
}) => Promise<{
|
|
216
|
+
ok: true;
|
|
217
|
+
}>;
|
|
218
|
+
};
|
|
219
|
+
readonly cdr: {
|
|
220
|
+
list: (opts?: {
|
|
221
|
+
page?: number;
|
|
222
|
+
pageSize?: number;
|
|
223
|
+
since?: string;
|
|
224
|
+
}) => Promise<Page<Record<string, unknown>>>;
|
|
225
|
+
};
|
|
226
|
+
readonly recordings: {
|
|
227
|
+
list: (opts?: {
|
|
228
|
+
page?: number;
|
|
229
|
+
pageSize?: number;
|
|
230
|
+
callId?: string;
|
|
231
|
+
}) => Promise<Page<{
|
|
232
|
+
id: string;
|
|
233
|
+
callId: string;
|
|
234
|
+
applicationId: string | null;
|
|
235
|
+
durationSec: number;
|
|
236
|
+
createdAt: string;
|
|
237
|
+
}>>;
|
|
238
|
+
/** Lien d'écoute signé (5 minutes) : le fichier reste sur la plateforme. */
|
|
239
|
+
url: (id: string) => Promise<{
|
|
240
|
+
url: string;
|
|
241
|
+
expiresIn: number;
|
|
242
|
+
}>;
|
|
243
|
+
delete: (id: string) => Promise<{
|
|
244
|
+
ok: true;
|
|
245
|
+
}>;
|
|
246
|
+
};
|
|
247
|
+
readonly audio: {
|
|
248
|
+
list: () => Promise<{
|
|
249
|
+
id: string;
|
|
250
|
+
name: string;
|
|
251
|
+
durationSec: number;
|
|
252
|
+
}[]>;
|
|
253
|
+
/** Envoie un fichier audio (MP3, WAV, AAC, M4A, OGG…) utilisable avec play({ audioId }). */
|
|
254
|
+
upload: (file: Blob, name?: string) => Promise<{
|
|
255
|
+
id: string;
|
|
256
|
+
name: string;
|
|
257
|
+
durationSec: number;
|
|
258
|
+
}>;
|
|
259
|
+
};
|
|
260
|
+
readonly logs: {
|
|
261
|
+
list: (opts?: {
|
|
262
|
+
page?: number;
|
|
263
|
+
pageSize?: number;
|
|
264
|
+
callId?: string;
|
|
265
|
+
applicationId?: string;
|
|
266
|
+
errors?: boolean;
|
|
267
|
+
}) => Promise<Page<Record<string, unknown>>>;
|
|
268
|
+
};
|
|
269
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client de l'API REST v1 (clé API vxk_…).
|
|
3
|
+
*/
|
|
4
|
+
export class VoxNovaError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
body;
|
|
7
|
+
constructor(message, status, body) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.body = body;
|
|
11
|
+
this.name = 'VoxNovaError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class VoxNova {
|
|
15
|
+
opts;
|
|
16
|
+
base;
|
|
17
|
+
f;
|
|
18
|
+
constructor(opts) {
|
|
19
|
+
this.opts = opts;
|
|
20
|
+
if (!opts.apiKey?.startsWith('vxk_'))
|
|
21
|
+
throw new Error('Clé API vxk_… requise');
|
|
22
|
+
this.base = opts.baseUrl.replace(/\/+$/, '');
|
|
23
|
+
this.f = opts.fetch ?? fetch;
|
|
24
|
+
}
|
|
25
|
+
/** Requête brute vers l'API (chemin commençant par /api/v1/). */
|
|
26
|
+
async request(method, path, body, query) {
|
|
27
|
+
const qs = query ? Object.entries(query).filter(([, v]) => v !== undefined).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join('&') : '';
|
|
28
|
+
const isForm = typeof FormData !== 'undefined' && body instanceof FormData;
|
|
29
|
+
const res = await this.f(`${this.base}${path}${qs ? `?${qs}` : ''}`, {
|
|
30
|
+
method,
|
|
31
|
+
headers: { Authorization: `Bearer ${this.opts.apiKey}`, Accept: 'application/json', ...(body && !isForm ? { 'Content-Type': 'application/json' } : {}) },
|
|
32
|
+
body: body === undefined ? undefined : isForm ? body : JSON.stringify(body),
|
|
33
|
+
signal: AbortSignal.timeout(this.opts.timeoutMs ?? 15000),
|
|
34
|
+
});
|
|
35
|
+
const text = await res.text();
|
|
36
|
+
let json = null;
|
|
37
|
+
try {
|
|
38
|
+
json = text ? JSON.parse(text) : null;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
json = text;
|
|
42
|
+
}
|
|
43
|
+
if (!res.ok)
|
|
44
|
+
throw new VoxNovaError(json?.error ?? `HTTP ${res.status}`, res.status, json);
|
|
45
|
+
return json;
|
|
46
|
+
}
|
|
47
|
+
account = {
|
|
48
|
+
get: () => this.request('GET', '/api/v1/account'),
|
|
49
|
+
usage: () => this.request('GET', '/api/v1/usage'),
|
|
50
|
+
};
|
|
51
|
+
applications = {
|
|
52
|
+
list: () => this.request('GET', '/api/v1/applications'),
|
|
53
|
+
create: (input) => this.request('POST', '/api/v1/applications', input),
|
|
54
|
+
get: (id) => this.request('GET', `/api/v1/applications/${id}`),
|
|
55
|
+
update: (id, input) => this.request('PATCH', `/api/v1/applications/${id}`, input),
|
|
56
|
+
rotateSecret: (id) => this.request('POST', `/api/v1/applications/${id}/secret`),
|
|
57
|
+
delete: (id) => this.request('DELETE', `/api/v1/applications/${id}`),
|
|
58
|
+
};
|
|
59
|
+
endpoints = {
|
|
60
|
+
list: () => this.request('GET', '/api/v1/endpoints'),
|
|
61
|
+
create: (input) => this.request('POST', '/api/v1/endpoints', input),
|
|
62
|
+
/** Par identifiant ou numéro interne ; inclut les identifiants SIP et les appareils enregistrés. */
|
|
63
|
+
get: (id) => this.request('GET', `/api/v1/endpoints/${encodeURIComponent(id)}`),
|
|
64
|
+
update: (id, input) => this.request('PATCH', `/api/v1/endpoints/${encodeURIComponent(id)}`, input),
|
|
65
|
+
registrations: (id) => this.request('GET', `/api/v1/endpoints/${encodeURIComponent(id)}/registrations`),
|
|
66
|
+
resetPassword: (id) => this.request('POST', `/api/v1/endpoints/${encodeURIComponent(id)}/password`),
|
|
67
|
+
disconnect: (id) => this.request('POST', `/api/v1/endpoints/${encodeURIComponent(id)}/disconnect`),
|
|
68
|
+
delete: (id) => this.request('DELETE', `/api/v1/endpoints/${encodeURIComponent(id)}`),
|
|
69
|
+
};
|
|
70
|
+
numbers = {
|
|
71
|
+
list: () => this.request('GET', '/api/v1/numbers'),
|
|
72
|
+
available: (opts = {}) => this.request('GET', '/api/v1/numbers/available', undefined, opts),
|
|
73
|
+
buy: (number, opts = {}) => this.request('POST', '/api/v1/numbers', { number, ...opts }),
|
|
74
|
+
/** Relie le numéro à une application ou à un endpoint (null pour détacher). */
|
|
75
|
+
route: (number, to) => this.request('PATCH', `/api/v1/numbers/${encodeURIComponent(number)}`, to),
|
|
76
|
+
release: (number) => this.request('DELETE', `/api/v1/numbers/${encodeURIComponent(number)}`),
|
|
77
|
+
};
|
|
78
|
+
calls = {
|
|
79
|
+
/** Appelle un endpoint ou un numéro ; au décroché, l'application prend la main (direction « api »). */
|
|
80
|
+
create: (input) => this.request('POST', '/api/v1/calls', input),
|
|
81
|
+
live: () => this.request('GET', '/api/v1/calls'),
|
|
82
|
+
get: (id) => this.request('GET', `/api/v1/calls/${id}`),
|
|
83
|
+
hangup: (id) => this.request('POST', `/api/v1/calls/${id}/hangup`),
|
|
84
|
+
/** Reprend la main sur un appel en cours : `url` reçoit l'événement « redirect ». */
|
|
85
|
+
redirect: (id, opts = {}) => this.request('POST', `/api/v1/calls/${id}/redirect`, opts),
|
|
86
|
+
};
|
|
87
|
+
cdr = {
|
|
88
|
+
list: (opts = {}) => this.request('GET', '/api/v1/cdr', undefined, opts),
|
|
89
|
+
};
|
|
90
|
+
recordings = {
|
|
91
|
+
list: (opts = {}) => this.request('GET', '/api/v1/recordings', undefined, opts),
|
|
92
|
+
/** Lien d'écoute signé (5 minutes) : le fichier reste sur la plateforme. */
|
|
93
|
+
url: (id) => this.request('POST', `/api/v1/recordings/${id}/url`),
|
|
94
|
+
delete: (id) => this.request('DELETE', `/api/v1/recordings/${id}`),
|
|
95
|
+
};
|
|
96
|
+
audio = {
|
|
97
|
+
list: () => this.request('GET', '/api/v1/audio'),
|
|
98
|
+
/** Envoie un fichier audio (MP3, WAV, AAC, M4A, OGG…) utilisable avec play({ audioId }). */
|
|
99
|
+
upload: (file, name) => {
|
|
100
|
+
const form = new FormData();
|
|
101
|
+
if (name)
|
|
102
|
+
form.append('name', name);
|
|
103
|
+
form.append('file', file, name ?? 'audio');
|
|
104
|
+
return this.request('POST', '/api/v1/audio', form);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
logs = {
|
|
108
|
+
list: (opts = {}) => this.request('GET', '/api/v1/logs', undefined, opts),
|
|
109
|
+
};
|
|
110
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/voice.d.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instructions vocales (dial plan en code) : votre webhook répond par une liste JSON d'instructions,
|
|
3
|
+
* construite ici avec VoiceResponse.
|
|
4
|
+
*/
|
|
5
|
+
export type Verb = {
|
|
6
|
+
verb: string;
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
};
|
|
9
|
+
export interface AudioSource {
|
|
10
|
+
/** Fichier audio distant (MP3, WAV, AAC, M4A, OGG…), converti et mis en cache par la plateforme. */
|
|
11
|
+
url?: string;
|
|
12
|
+
/** Fichier audio envoyé par l'API (/api/v1/audio). */
|
|
13
|
+
audioId?: string;
|
|
14
|
+
/** Son intégré du moteur, ex. « ivr/ivr-welcome.wav ». */
|
|
15
|
+
sound?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface GatherOptions extends AudioSource {
|
|
18
|
+
/** Nombre exact de chiffres attendus (raccourci pour minDigits = maxDigits). */
|
|
19
|
+
numDigits?: number;
|
|
20
|
+
minDigits?: number;
|
|
21
|
+
maxDigits?: number;
|
|
22
|
+
/** Délai d'attente de la première touche, en secondes (5 par défaut). */
|
|
23
|
+
timeout?: number;
|
|
24
|
+
/** Délai entre deux touches, en secondes (3 par défaut). */
|
|
25
|
+
interDigitTimeout?: number;
|
|
26
|
+
/** Touche de fin de saisie : « # » (par défaut), « * » ou « » (aucune). */
|
|
27
|
+
finishOnKey?: '#' | '*' | '';
|
|
28
|
+
/** Nombre d'essais (annonce rejouée). */
|
|
29
|
+
tries?: number;
|
|
30
|
+
/** URL rappelée avec `digits` (par défaut : l'URL qui a produit ces instructions). */
|
|
31
|
+
action?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface RecordOptions {
|
|
34
|
+
maxSeconds?: number;
|
|
35
|
+
/** Fin automatique après N secondes de silence (5 par défaut). */
|
|
36
|
+
silenceSeconds?: number;
|
|
37
|
+
beep?: boolean;
|
|
38
|
+
finishOnKey?: string;
|
|
39
|
+
/** URL rappelée avec `recording: { id, duration }`. */
|
|
40
|
+
action?: string;
|
|
41
|
+
}
|
|
42
|
+
export type DialTarget = {
|
|
43
|
+
endpoint: string;
|
|
44
|
+
} | {
|
|
45
|
+
number: string;
|
|
46
|
+
};
|
|
47
|
+
export interface DialOptions {
|
|
48
|
+
/** Délai de sonnerie en secondes (30 par défaut). */
|
|
49
|
+
timeout?: number;
|
|
50
|
+
/** Numéro présenté (l'un de vos numéros). */
|
|
51
|
+
callerId?: string;
|
|
52
|
+
/** Plusieurs destinations : en même temps (par défaut) ou l'une après l'autre. */
|
|
53
|
+
strategy?: 'simultaneous' | 'sequential';
|
|
54
|
+
/** Enregistrer la communication. */
|
|
55
|
+
record?: boolean;
|
|
56
|
+
/** URL rappelée avec `dial: { status, answered, hangupCause }` à la fin (sinon l'appel se termine s'il a abouti). */
|
|
57
|
+
action?: string;
|
|
58
|
+
}
|
|
59
|
+
export interface ConferenceOptions {
|
|
60
|
+
muted?: boolean;
|
|
61
|
+
moderator?: boolean;
|
|
62
|
+
endOnExit?: boolean;
|
|
63
|
+
waitForModerator?: boolean;
|
|
64
|
+
beep?: boolean;
|
|
65
|
+
}
|
|
66
|
+
export declare class VoiceResponse {
|
|
67
|
+
private readonly verbs;
|
|
68
|
+
private push;
|
|
69
|
+
answer(): this;
|
|
70
|
+
/** Joue un fichier audio (URL, fichier envoyé ou son intégré). */
|
|
71
|
+
play(source: string | AudioSource, opts?: {
|
|
72
|
+
loop?: number;
|
|
73
|
+
}): this;
|
|
74
|
+
/** Prononce un nombre (« quarante-deux ») ou ses chiffres un par un. */
|
|
75
|
+
say(value: string | number, opts?: {
|
|
76
|
+
as?: 'number' | 'digits';
|
|
77
|
+
language?: 'fr' | 'en';
|
|
78
|
+
}): this;
|
|
79
|
+
pause(seconds?: number): this;
|
|
80
|
+
/** Variables de l'appel, renvoyées dans chaque webhook (`vars`). */
|
|
81
|
+
set(vars: Record<string, string | number | boolean>): this;
|
|
82
|
+
gather(opts?: GatherOptions): this;
|
|
83
|
+
record(opts?: RecordOptions): this;
|
|
84
|
+
/** Appelle un endpoint SIP, un numéro, ou plusieurs destinations. */
|
|
85
|
+
dial(target: DialTarget | DialTarget[], opts?: DialOptions): this;
|
|
86
|
+
/** Redemande des instructions à une autre URL. */
|
|
87
|
+
redirect(url: string): this;
|
|
88
|
+
/** File d'attente (identifiant ou numéro interne). */
|
|
89
|
+
queue(queue: string): this;
|
|
90
|
+
/** Conférence nommée (créée à la première arrivée). */
|
|
91
|
+
conference(name: string, opts?: ConferenceOptions): this;
|
|
92
|
+
/** Messagerie vocale d'un endpoint. */
|
|
93
|
+
voicemail(endpoint: string): this;
|
|
94
|
+
/** Transfert vers un numéro interne (poste, groupe, standard vocal…) ou externe. */
|
|
95
|
+
transfer(to: string): this;
|
|
96
|
+
hangup(cause?: string): this;
|
|
97
|
+
toJSON(): Verb[];
|
|
98
|
+
toString(): string;
|
|
99
|
+
}
|
|
100
|
+
export type WebhookEvent = 'call.incoming' | 'gather' | 'dial' | 'record' | 'redirect';
|
|
101
|
+
/** Corps des requêtes envoyées à votre voiceUrl et à vos URL d'action. */
|
|
102
|
+
export interface VoiceRequest {
|
|
103
|
+
event: WebhookEvent;
|
|
104
|
+
callId: string;
|
|
105
|
+
applicationId: string;
|
|
106
|
+
accountId: string;
|
|
107
|
+
/** inbound : appel reçu sur un numéro ; endpoint : appel émis par un endpoint ; api : appel lancé par l'API. */
|
|
108
|
+
direction: 'inbound' | 'endpoint' | 'api';
|
|
109
|
+
from: string;
|
|
110
|
+
to: string;
|
|
111
|
+
callerName: string;
|
|
112
|
+
/** Endpoint à l'origine de l'appel (direction « endpoint »). */
|
|
113
|
+
endpoint?: string;
|
|
114
|
+
vars: Record<string, string>;
|
|
115
|
+
timestamp: string;
|
|
116
|
+
digits?: string;
|
|
117
|
+
dial?: {
|
|
118
|
+
status: 'completed' | 'busy' | 'no-answer' | 'canceled' | 'failed';
|
|
119
|
+
answered: boolean;
|
|
120
|
+
hangupCause: string;
|
|
121
|
+
};
|
|
122
|
+
recording?: {
|
|
123
|
+
id: string;
|
|
124
|
+
duration: number;
|
|
125
|
+
} | null;
|
|
126
|
+
/** Présent quand la requête est envoyée à l'URL de secours. */
|
|
127
|
+
fallback?: boolean;
|
|
128
|
+
error?: string;
|
|
129
|
+
}
|
|
130
|
+
/** Corps envoyé à statusCallbackUrl à la fin de l'appel. */
|
|
131
|
+
export interface StatusCallback {
|
|
132
|
+
event: 'call.completed';
|
|
133
|
+
callId: string;
|
|
134
|
+
applicationId: string;
|
|
135
|
+
accountId: string;
|
|
136
|
+
direction: string;
|
|
137
|
+
from: string;
|
|
138
|
+
to: string;
|
|
139
|
+
startedAt: string;
|
|
140
|
+
answeredAt: string | null;
|
|
141
|
+
endedAt: string;
|
|
142
|
+
duration: number;
|
|
143
|
+
billsec: number;
|
|
144
|
+
hangupCause: string | null;
|
|
145
|
+
costCents: number;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Vérifie l'en-tête X-VoxNova-Signature (« t=<horodatage>,v1=<HMAC-SHA256 hex> ») sur le corps brut de la requête.
|
|
149
|
+
* Refuse les requêtes de plus de `toleranceSec` secondes (rejeu).
|
|
150
|
+
*/
|
|
151
|
+
export declare function verifySignature(secret: string, rawBody: string | Buffer, header: string | undefined | null, toleranceSec?: number): boolean;
|
package/dist/voice.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
export class VoiceResponse {
|
|
3
|
+
verbs = [];
|
|
4
|
+
push(verb, data = {}) {
|
|
5
|
+
this.verbs.push({ verb, ...Object.fromEntries(Object.entries(data).filter(([, v]) => v !== undefined)) });
|
|
6
|
+
return this;
|
|
7
|
+
}
|
|
8
|
+
answer() {
|
|
9
|
+
return this.push('answer');
|
|
10
|
+
}
|
|
11
|
+
/** Joue un fichier audio (URL, fichier envoyé ou son intégré). */
|
|
12
|
+
play(source, opts = {}) {
|
|
13
|
+
return this.push('play', { ...(typeof source === 'string' ? { url: source } : source), ...opts });
|
|
14
|
+
}
|
|
15
|
+
/** Prononce un nombre (« quarante-deux ») ou ses chiffres un par un. */
|
|
16
|
+
say(value, opts = {}) {
|
|
17
|
+
return this.push('say', { value: String(value), ...opts });
|
|
18
|
+
}
|
|
19
|
+
pause(seconds = 1) {
|
|
20
|
+
return this.push('pause', { seconds });
|
|
21
|
+
}
|
|
22
|
+
/** Variables de l'appel, renvoyées dans chaque webhook (`vars`). */
|
|
23
|
+
set(vars) {
|
|
24
|
+
return this.push('set', { vars });
|
|
25
|
+
}
|
|
26
|
+
gather(opts = {}) {
|
|
27
|
+
return this.push('gather', { ...opts });
|
|
28
|
+
}
|
|
29
|
+
record(opts = {}) {
|
|
30
|
+
return this.push('record', { ...opts });
|
|
31
|
+
}
|
|
32
|
+
/** Appelle un endpoint SIP, un numéro, ou plusieurs destinations. */
|
|
33
|
+
dial(target, opts = {}) {
|
|
34
|
+
return this.push('dial', { ...(Array.isArray(target) ? { targets: target } : target), ...opts });
|
|
35
|
+
}
|
|
36
|
+
/** Redemande des instructions à une autre URL. */
|
|
37
|
+
redirect(url) {
|
|
38
|
+
return this.push('redirect', { url });
|
|
39
|
+
}
|
|
40
|
+
/** File d'attente (identifiant ou numéro interne). */
|
|
41
|
+
queue(queue) {
|
|
42
|
+
return this.push('queue', { queue });
|
|
43
|
+
}
|
|
44
|
+
/** Conférence nommée (créée à la première arrivée). */
|
|
45
|
+
conference(name, opts = {}) {
|
|
46
|
+
return this.push('conference', { name, ...opts });
|
|
47
|
+
}
|
|
48
|
+
/** Messagerie vocale d'un endpoint. */
|
|
49
|
+
voicemail(endpoint) {
|
|
50
|
+
return this.push('voicemail', { endpoint });
|
|
51
|
+
}
|
|
52
|
+
/** Transfert vers un numéro interne (poste, groupe, standard vocal…) ou externe. */
|
|
53
|
+
transfer(to) {
|
|
54
|
+
return this.push('transfer', { to });
|
|
55
|
+
}
|
|
56
|
+
hangup(cause) {
|
|
57
|
+
return this.push('hangup', { cause });
|
|
58
|
+
}
|
|
59
|
+
toJSON() {
|
|
60
|
+
return this.verbs;
|
|
61
|
+
}
|
|
62
|
+
toString() {
|
|
63
|
+
return JSON.stringify(this.verbs);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Vérifie l'en-tête X-VoxNova-Signature (« t=<horodatage>,v1=<HMAC-SHA256 hex> ») sur le corps brut de la requête.
|
|
68
|
+
* Refuse les requêtes de plus de `toleranceSec` secondes (rejeu).
|
|
69
|
+
*/
|
|
70
|
+
export function verifySignature(secret, rawBody, header, toleranceSec = 300) {
|
|
71
|
+
if (!header)
|
|
72
|
+
return false;
|
|
73
|
+
const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=', 2)));
|
|
74
|
+
const ts = Number(parts.t);
|
|
75
|
+
if (!ts || !parts.v1 || Math.abs(Date.now() / 1000 - ts) > toleranceSec)
|
|
76
|
+
return false;
|
|
77
|
+
const expected = Buffer.from(createHmac('sha256', secret).update(`${ts}.${rawBody.toString()}`).digest('hex'));
|
|
78
|
+
const given = Buffer.from(parts.v1);
|
|
79
|
+
return expected.length === given.length && timingSafeEqual(expected, given);
|
|
80
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voxnova/sdk",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "SDK TypeScript de la plateforme développeurs VoxNova : API REST, instructions vocales (dial plan en code), vérification des webhooks",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/damidani/voxnova.git",
|
|
29
|
+
"directory": "packages/sdk"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"voip",
|
|
33
|
+
"sip",
|
|
34
|
+
"telephony",
|
|
35
|
+
"webhooks",
|
|
36
|
+
"ivr",
|
|
37
|
+
"voxnova"
|
|
38
|
+
]
|
|
39
|
+
}
|