@labyrinthe/connect 0.0.0-stage → 1.2.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/CHANGELOG.md +101 -0
- package/LICENSE +21 -0
- package/README.fr.md +819 -0
- package/README.md +808 -2
- package/dist/express.cjs +87 -0
- package/dist/express.d.cts +49 -0
- package/dist/express.d.ts +49 -0
- package/dist/express.js +85 -0
- package/dist/index.cjs +1721 -0
- package/dist/index.d.cts +943 -0
- package/dist/index.d.ts +943 -0
- package/dist/index.js +1667 -0
- package/dist/next.cjs +48 -0
- package/dist/next.d.cts +31 -0
- package/dist/next.d.ts +31 -0
- package/dist/next.js +46 -0
- package/package.json +119 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,1667 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var LabyrintheError = class extends Error {
|
|
3
|
+
/** Stable machine-readable code, e.g. "INSUFFICIENT_CREDITS" or "TIMEOUT". */
|
|
4
|
+
code;
|
|
5
|
+
/** HTTP status of the response; null when there was no response (network, timeout, local validation…). */
|
|
6
|
+
status;
|
|
7
|
+
/** Error details, depending on the code (`{}` when there are none). */
|
|
8
|
+
details;
|
|
9
|
+
constructor(message, code, status = null, details = {}) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = new.target.name;
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.details = details;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
var ApiError = class extends LabyrintheError {
|
|
18
|
+
/** Response headers (lower-case names). */
|
|
19
|
+
headers;
|
|
20
|
+
constructor(message, code, status, details = {}, headers = {}) {
|
|
21
|
+
super(message, code, status, details);
|
|
22
|
+
this.headers = headers;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Request identifier (`X-Request-Id` response header): give it to the
|
|
26
|
+
* Labyrinthe support. Yours when you passed `requestId`, otherwise
|
|
27
|
+
* generated by the API; null only when the response had none.
|
|
28
|
+
*/
|
|
29
|
+
get requestId() {
|
|
30
|
+
return this.headers["x-request-id"] ?? null;
|
|
31
|
+
}
|
|
32
|
+
/** Major API version that served the response (`X-Api-Version`, e.g. "1"); null when absent. */
|
|
33
|
+
get apiVersion() {
|
|
34
|
+
return this.headers["x-api-version"] ?? null;
|
|
35
|
+
}
|
|
36
|
+
detailInt(key) {
|
|
37
|
+
return toInt(this.details[key]);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
var AuthenticationError = class extends ApiError {
|
|
41
|
+
};
|
|
42
|
+
var PermissionError = class extends ApiError {
|
|
43
|
+
};
|
|
44
|
+
var AccountSuspendedError = class extends PermissionError {
|
|
45
|
+
};
|
|
46
|
+
var NotFoundError = class extends ApiError {
|
|
47
|
+
};
|
|
48
|
+
var ValidationError = class extends ApiError {
|
|
49
|
+
/** Error messages per field, e.g. `{ body: ["Le champ body est obligatoire."] }`. */
|
|
50
|
+
get fieldErrors() {
|
|
51
|
+
const errors = {};
|
|
52
|
+
for (const [field, messages] of Object.entries(this.details)) {
|
|
53
|
+
const list = Array.isArray(messages) ? messages : [messages];
|
|
54
|
+
errors[field] = list.filter((m) => ["string", "number", "boolean"].includes(typeof m)).map(String);
|
|
55
|
+
}
|
|
56
|
+
return errors;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
var RecipientNotSendableError = class extends ApiError {
|
|
60
|
+
/** Reason of the first rejection. */
|
|
61
|
+
get reason() {
|
|
62
|
+
return rejectionReason(this.details["reason"]);
|
|
63
|
+
}
|
|
64
|
+
/** Every rejected recipient (`sms.send` / `sms.sendMany`). Empty for OTP sends. */
|
|
65
|
+
get rejected() {
|
|
66
|
+
const rejected = this.details["rejected"];
|
|
67
|
+
if (!Array.isArray(rejected)) {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
const items = [];
|
|
71
|
+
for (const item of rejected) {
|
|
72
|
+
if (isRecord(item) && typeof item["recipient"] === "string") {
|
|
73
|
+
items.push({ recipient: item["recipient"], reason: rejectionReason(item["reason"]) });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return items;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
var SenderIdNotApprovedError = class extends ApiError {
|
|
80
|
+
};
|
|
81
|
+
var InvalidPhoneError = class extends ApiError {
|
|
82
|
+
};
|
|
83
|
+
var InsufficientCreditsError = class extends ApiError {
|
|
84
|
+
/** Credits available on the account. */
|
|
85
|
+
get available() {
|
|
86
|
+
return this.detailInt("available");
|
|
87
|
+
}
|
|
88
|
+
/** Credits needed for this send. */
|
|
89
|
+
get required() {
|
|
90
|
+
return this.detailInt("required");
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
var IdempotencyKeyReusedError = class extends ApiError {
|
|
94
|
+
};
|
|
95
|
+
var IdempotencyKeyUnresolvedError = class extends ApiError {
|
|
96
|
+
};
|
|
97
|
+
var IdempotencyRequestInProgressError = class extends ApiError {
|
|
98
|
+
/** Milliseconds to wait (Retry-After header or `details.retry_after_seconds`); null when the API does not say. */
|
|
99
|
+
get retryAfterMs() {
|
|
100
|
+
return retryAfterMsOf(this.headers, this.details);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
var RateLimitError = class extends ApiError {
|
|
104
|
+
/**
|
|
105
|
+
* Milliseconds to wait before retrying, from the Retry-After header or
|
|
106
|
+
* `details.retry_after_seconds`; null when the API does not say.
|
|
107
|
+
*/
|
|
108
|
+
get retryAfterMs() {
|
|
109
|
+
return retryAfterMsOf(this.headers, this.details);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
var OtpResendTooSoonError = class extends RateLimitError {
|
|
113
|
+
};
|
|
114
|
+
var OtpRateLimitedError = class extends RateLimitError {
|
|
115
|
+
get scope() {
|
|
116
|
+
const scope = this.details["scope"];
|
|
117
|
+
return scope === "phone" || scope === "account" ? scope : "unknown";
|
|
118
|
+
}
|
|
119
|
+
/** The hourly cap that was reached. */
|
|
120
|
+
get limit() {
|
|
121
|
+
return this.detailInt("limit");
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
var ServiceUnavailableError = class extends ApiError {
|
|
125
|
+
};
|
|
126
|
+
var FeatureDisabledError = class extends ServiceUnavailableError {
|
|
127
|
+
/** Feature requested by the call ("api", "api.sms_send", "api.otp_send", "api.otp_verify", "otp"… open list). */
|
|
128
|
+
get feature() {
|
|
129
|
+
return this.detailString("feature");
|
|
130
|
+
}
|
|
131
|
+
/** Feature actually shut down: `feature` itself or a parent (e.g. "api" when the whole API is off). */
|
|
132
|
+
get blockedBy() {
|
|
133
|
+
return this.detailString("blocked_by");
|
|
134
|
+
}
|
|
135
|
+
/** Message written by the administrator (`details.message`), null when there is none. */
|
|
136
|
+
get adminMessage() {
|
|
137
|
+
return this.detailString("message");
|
|
138
|
+
}
|
|
139
|
+
/** Scheduled automatic re-enable (`details.until`), null when unknown or invalid. */
|
|
140
|
+
get until() {
|
|
141
|
+
const value = this.details["until"];
|
|
142
|
+
if (typeof value !== "string" || value === "") {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const date = new Date(value);
|
|
146
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
147
|
+
}
|
|
148
|
+
/** Seconds until the scheduled re-enable (`Retry-After` header), null when the API does not say. */
|
|
149
|
+
get retryAfter() {
|
|
150
|
+
const ms = this.retryAfterMs;
|
|
151
|
+
return ms === null ? null : Math.ceil(ms / 1e3);
|
|
152
|
+
}
|
|
153
|
+
/** Same as `retryAfter`, in milliseconds (`Retry-After` header only), null when absent. */
|
|
154
|
+
get retryAfterMs() {
|
|
155
|
+
return retryAfterMsOf(this.headers, {});
|
|
156
|
+
}
|
|
157
|
+
detailString(key) {
|
|
158
|
+
const value = this.details[key];
|
|
159
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
var ConnectionError = class extends LabyrintheError {
|
|
163
|
+
constructor(message, code = "CONNECTION_ERROR") {
|
|
164
|
+
super(message, code);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
var TimeoutError = class extends ConnectionError {
|
|
168
|
+
constructor(message) {
|
|
169
|
+
super(message, "TIMEOUT");
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
var RequestAbortedError = class extends LabyrintheError {
|
|
173
|
+
constructor(message = "The request was aborted.") {
|
|
174
|
+
super(message, "REQUEST_ABORTED");
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
var InvalidArgumentError = class extends LabyrintheError {
|
|
178
|
+
constructor(message) {
|
|
179
|
+
super(message, "INVALID_ARGUMENT");
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
var UnexpectedResponseError = class extends LabyrintheError {
|
|
183
|
+
constructor(message, status = null) {
|
|
184
|
+
super(message, "UNEXPECTED_RESPONSE", status);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
var SignatureVerificationError = class extends LabyrintheError {
|
|
188
|
+
constructor(message) {
|
|
189
|
+
super(message, "SIGNATURE_VERIFICATION_FAILED");
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
var InvalidWebhookPayloadError = class extends LabyrintheError {
|
|
193
|
+
constructor(message) {
|
|
194
|
+
super(message, "INVALID_WEBHOOK_PAYLOAD");
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
var ERROR_CLASSES_BY_CODE = {
|
|
198
|
+
UNAUTHENTICATED: AuthenticationError,
|
|
199
|
+
FORBIDDEN: PermissionError,
|
|
200
|
+
ACCOUNT_SUSPENDED: AccountSuspendedError,
|
|
201
|
+
OTP_DISABLED_FOR_ACCOUNT: PermissionError,
|
|
202
|
+
NOT_FOUND: NotFoundError,
|
|
203
|
+
VALIDATION_ERROR: ValidationError,
|
|
204
|
+
RECIPIENT_NOT_SENDABLE: RecipientNotSendableError,
|
|
205
|
+
SENDER_ID_NOT_APPROVED: SenderIdNotApprovedError,
|
|
206
|
+
INVALID_PHONE: InvalidPhoneError,
|
|
207
|
+
INSUFFICIENT_CREDITS: InsufficientCreditsError,
|
|
208
|
+
IDEMPOTENCY_KEY_REUSED: IdempotencyKeyReusedError,
|
|
209
|
+
IDEMPOTENCY_KEY_UNRESOLVED: IdempotencyKeyUnresolvedError,
|
|
210
|
+
IDEMPOTENCY_REQUEST_IN_PROGRESS: IdempotencyRequestInProgressError,
|
|
211
|
+
TOO_MANY_REQUESTS: RateLimitError,
|
|
212
|
+
OTP_RESEND_TOO_SOON: OtpResendTooSoonError,
|
|
213
|
+
OTP_RATE_LIMITED: OtpRateLimitedError,
|
|
214
|
+
OTP_SERVICE_DISABLED: FeatureDisabledError,
|
|
215
|
+
FEATURE_DISABLED: FeatureDisabledError
|
|
216
|
+
};
|
|
217
|
+
function errorFromResponse(status, rawBody, headers) {
|
|
218
|
+
let code = null;
|
|
219
|
+
let message = null;
|
|
220
|
+
let details = {};
|
|
221
|
+
let decoded = null;
|
|
222
|
+
try {
|
|
223
|
+
decoded = JSON.parse(rawBody);
|
|
224
|
+
} catch {
|
|
225
|
+
decoded = null;
|
|
226
|
+
}
|
|
227
|
+
if (isRecord(decoded) && isRecord(decoded["error"])) {
|
|
228
|
+
const error = decoded["error"];
|
|
229
|
+
code = typeof error["code"] === "string" ? error["code"] : null;
|
|
230
|
+
message = typeof error["message"] === "string" ? error["message"] : null;
|
|
231
|
+
details = isRecord(error["details"]) ? error["details"] : {};
|
|
232
|
+
}
|
|
233
|
+
code ??= status === 429 ? "TOO_MANY_REQUESTS" : "HTTP_ERROR";
|
|
234
|
+
message ??= `Labyrinthe Connect API returned HTTP ${String(status)}.`;
|
|
235
|
+
const ErrorClass = ERROR_CLASSES_BY_CODE[code] ?? classForStatus(status);
|
|
236
|
+
return new ErrorClass(message, code, status, details, headers);
|
|
237
|
+
}
|
|
238
|
+
function classForStatus(status) {
|
|
239
|
+
if (status === 401) return AuthenticationError;
|
|
240
|
+
if (status === 403) return PermissionError;
|
|
241
|
+
if (status === 404) return NotFoundError;
|
|
242
|
+
if (status === 429) return RateLimitError;
|
|
243
|
+
if (status >= 500) return ServiceUnavailableError;
|
|
244
|
+
return ApiError;
|
|
245
|
+
}
|
|
246
|
+
function rejectionReason(value) {
|
|
247
|
+
return value === "invalid_phone" || value === "blacklisted" || value === "opted_out" ? value : "unknown";
|
|
248
|
+
}
|
|
249
|
+
function retryAfterMsOf(headers, details) {
|
|
250
|
+
const header = headers["retry-after"]?.trim();
|
|
251
|
+
if (header !== void 0 && header !== "") {
|
|
252
|
+
if (/^\d+(\.\d+)?$/.test(header)) {
|
|
253
|
+
return Math.round(Number(header) * 1e3);
|
|
254
|
+
}
|
|
255
|
+
const date = Date.parse(header);
|
|
256
|
+
if (!Number.isNaN(date)) {
|
|
257
|
+
return Math.max(0, date - Date.now());
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const seconds = toInt(details["retry_after_seconds"]);
|
|
261
|
+
return seconds === null || seconds < 0 ? null : seconds * 1e3;
|
|
262
|
+
}
|
|
263
|
+
function isRecord(value) {
|
|
264
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
265
|
+
}
|
|
266
|
+
function toInt(value) {
|
|
267
|
+
if (typeof value === "number" && Number.isInteger(value)) {
|
|
268
|
+
return value;
|
|
269
|
+
}
|
|
270
|
+
if (typeof value === "string" && /^-?\d+$/.test(value)) {
|
|
271
|
+
return Number(value);
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/phone.ts
|
|
277
|
+
var DEFAULT_COUNTRY_CODE = "243";
|
|
278
|
+
var PhoneNumber = Object.freeze({
|
|
279
|
+
/** Normalises a number, or returns null when it cannot be a valid phone number. */
|
|
280
|
+
normalize(raw, defaultCountryCode = DEFAULT_COUNTRY_CODE) {
|
|
281
|
+
assertCountryCode(defaultCountryCode);
|
|
282
|
+
const input = raw.trim();
|
|
283
|
+
if (input === "") {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
const explicitInternational = input.startsWith("+") || input.startsWith("00");
|
|
287
|
+
let digits = input.replace(/\D/g, "");
|
|
288
|
+
if (explicitInternational) {
|
|
289
|
+
digits = input.startsWith("00") ? digits.slice(2) : digits;
|
|
290
|
+
} else if (digits.startsWith(defaultCountryCode) && digits.length > 10) ; else if (digits.length >= 11 && !digits.startsWith("0")) ; else {
|
|
291
|
+
digits = defaultCountryCode + digits.replace(/^0+/, "");
|
|
292
|
+
}
|
|
293
|
+
return digits.length >= 8 && digits.length <= 15 ? digits : null;
|
|
294
|
+
},
|
|
295
|
+
/** Normalises a number or throws an InvalidArgumentError (the number itself is never part of the message). */
|
|
296
|
+
normalizeOrThrow(raw, defaultCountryCode = DEFAULT_COUNTRY_CODE, field = "phone") {
|
|
297
|
+
const normalized = PhoneNumber.normalize(raw, defaultCountryCode);
|
|
298
|
+
if (normalized === null) {
|
|
299
|
+
throw new InvalidArgumentError(
|
|
300
|
+
`Invalid phone number in "${field}": expected an international number (e.g. 243812345678) or a local number.`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
return normalized;
|
|
304
|
+
},
|
|
305
|
+
isValid(raw, defaultCountryCode = DEFAULT_COUNTRY_CODE) {
|
|
306
|
+
return PhoneNumber.normalize(raw, defaultCountryCode) !== null;
|
|
307
|
+
},
|
|
308
|
+
/** Masks a number for logs: "243812345678" → "24381*****78". */
|
|
309
|
+
mask(phone) {
|
|
310
|
+
const digits = phone.replace(/\D/g, "");
|
|
311
|
+
const length = digits.length;
|
|
312
|
+
if (length <= 4) {
|
|
313
|
+
return "*".repeat(length);
|
|
314
|
+
}
|
|
315
|
+
const visibleStart = Math.min(5, Math.max(1, length - 7));
|
|
316
|
+
return digits.slice(0, visibleStart) + "*".repeat(length - visibleStart - 2) + digits.slice(-2);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
function toApiNumber(digits) {
|
|
320
|
+
return "+" + digits;
|
|
321
|
+
}
|
|
322
|
+
function assertCountryCode(countryCode) {
|
|
323
|
+
if (!/^[1-9]\d{0,2}$/.test(countryCode)) {
|
|
324
|
+
throw new InvalidArgumentError('The default country code must be 1 to 3 digits without "+" (e.g. "243").');
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// src/internal/redact.ts
|
|
329
|
+
var PHONE_PARAMS = ["recipient", "phone"];
|
|
330
|
+
function redactApiKey(apiKey) {
|
|
331
|
+
const match = /^(lsk_(?:live|test)_)/.exec(apiKey);
|
|
332
|
+
return match?.[1] !== void 0 ? `${match[1]}****` : "****";
|
|
333
|
+
}
|
|
334
|
+
function redactPath(path, query) {
|
|
335
|
+
if ([...query.keys()].length === 0) {
|
|
336
|
+
return path;
|
|
337
|
+
}
|
|
338
|
+
const parts = [];
|
|
339
|
+
for (const [name, value] of query) {
|
|
340
|
+
const shown = PHONE_PARAMS.includes(name) ? PhoneNumber.mask(value) : encodeURIComponent(value);
|
|
341
|
+
parts.push(`${encodeURIComponent(name)}=${shown}`);
|
|
342
|
+
}
|
|
343
|
+
return `${path}?${parts.join("&")}`;
|
|
344
|
+
}
|
|
345
|
+
function stripQueryStrings(text) {
|
|
346
|
+
return text.replace(/\?[^\s"']*/g, "?\u2026");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// src/version.ts
|
|
350
|
+
var VERSION = "1.2.0";
|
|
351
|
+
|
|
352
|
+
// src/internal/runtime.ts
|
|
353
|
+
var g = globalThis;
|
|
354
|
+
function env(name) {
|
|
355
|
+
try {
|
|
356
|
+
const value = g.process?.env?.[name];
|
|
357
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
|
|
358
|
+
} catch {
|
|
359
|
+
return void 0;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function isBrowser() {
|
|
363
|
+
return g.window?.document !== void 0;
|
|
364
|
+
}
|
|
365
|
+
function runtimeName() {
|
|
366
|
+
if (typeof g.Bun?.version === "string") return `bun/${g.Bun.version}`;
|
|
367
|
+
if (typeof g.Deno?.version?.deno === "string") return `deno/${g.Deno.version.deno}`;
|
|
368
|
+
if (typeof g.EdgeRuntime === "string") return "edge-runtime";
|
|
369
|
+
const node = g.process?.versions?.["node"];
|
|
370
|
+
if (typeof node === "string") return `node/${node}`;
|
|
371
|
+
if (g.navigator?.userAgent === "Cloudflare-Workers") return "workerd";
|
|
372
|
+
return "unknown";
|
|
373
|
+
}
|
|
374
|
+
function clientIdentifier() {
|
|
375
|
+
return `labyrinthe-node/${VERSION} ${runtimeName()}`;
|
|
376
|
+
}
|
|
377
|
+
var cryptoPromise;
|
|
378
|
+
function getCrypto() {
|
|
379
|
+
if (g.crypto?.subtle !== void 0 && typeof g.crypto.randomUUID === "function") {
|
|
380
|
+
return Promise.resolve(g.crypto);
|
|
381
|
+
}
|
|
382
|
+
cryptoPromise ??= loadNodeCrypto();
|
|
383
|
+
return cryptoPromise;
|
|
384
|
+
}
|
|
385
|
+
async function loadNodeCrypto() {
|
|
386
|
+
if (typeof g.process?.versions?.["node"] === "string") {
|
|
387
|
+
const specifier = ["node", "crypto"].join(":");
|
|
388
|
+
try {
|
|
389
|
+
const mod = await import(
|
|
390
|
+
/* webpackIgnore: true */
|
|
391
|
+
/* @vite-ignore */
|
|
392
|
+
specifier
|
|
393
|
+
);
|
|
394
|
+
if (mod.webcrypto?.subtle !== void 0) {
|
|
395
|
+
return mod.webcrypto;
|
|
396
|
+
}
|
|
397
|
+
} catch {
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
cryptoPromise = void 0;
|
|
401
|
+
throw new Error("Web Crypto (globalThis.crypto) is not available in this runtime.");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// src/internal/transport.ts
|
|
405
|
+
var REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
406
|
+
var BASE_DELAY_MS = 500;
|
|
407
|
+
var MAX_BACKOFF_MS = 8e3;
|
|
408
|
+
var defaultSleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
409
|
+
if (signal?.aborted === true) {
|
|
410
|
+
reject(new RequestAbortedError());
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const onAbort = () => {
|
|
414
|
+
clearTimeout(timer);
|
|
415
|
+
reject(new RequestAbortedError());
|
|
416
|
+
};
|
|
417
|
+
const timer = setTimeout(() => {
|
|
418
|
+
signal?.removeEventListener("abort", onAbort);
|
|
419
|
+
resolve();
|
|
420
|
+
}, ms);
|
|
421
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
422
|
+
});
|
|
423
|
+
var Transport = class {
|
|
424
|
+
#apiKey;
|
|
425
|
+
#config;
|
|
426
|
+
#identityHeader;
|
|
427
|
+
constructor(config) {
|
|
428
|
+
const { apiKey, ...rest } = config;
|
|
429
|
+
this.#apiKey = apiKey;
|
|
430
|
+
this.#config = rest;
|
|
431
|
+
this.#identityHeader = [isBrowser() ? "X-Labyrinthe-Client" : "User-Agent", clientIdentifier()];
|
|
432
|
+
}
|
|
433
|
+
get baseUrl() {
|
|
434
|
+
return this.#config.baseUrl;
|
|
435
|
+
}
|
|
436
|
+
async request(method, path, mode, params = {}) {
|
|
437
|
+
const { fetch, logger, maxRetries, sleep } = this.#config;
|
|
438
|
+
const query = new URLSearchParams();
|
|
439
|
+
for (const [name, value] of Object.entries(params.query ?? {})) {
|
|
440
|
+
if (value !== void 0) query.append(name, String(value));
|
|
441
|
+
}
|
|
442
|
+
const queryString = query.toString();
|
|
443
|
+
const url = this.#config.baseUrl + path + (queryString === "" ? "" : `?${queryString}`);
|
|
444
|
+
const logPath = redactPath(path, query);
|
|
445
|
+
const payload = params.body === void 0 ? void 0 : JSON.stringify(params.body);
|
|
446
|
+
const timeoutMs = params.timeoutMs ?? this.#config.timeoutMs;
|
|
447
|
+
const signal = params.signal;
|
|
448
|
+
const requestId = params.requestId;
|
|
449
|
+
if (requestId !== void 0 && (typeof requestId !== "string" || !REQUEST_ID_PATTERN.test(requestId))) {
|
|
450
|
+
throw new InvalidArgumentError("requestId must be 1 to 128 characters among A-Z a-z 0-9 . _ : -");
|
|
451
|
+
}
|
|
452
|
+
const headers = {
|
|
453
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
454
|
+
Accept: "application/json",
|
|
455
|
+
[this.#identityHeader[0]]: this.#identityHeader[1],
|
|
456
|
+
...params.headers
|
|
457
|
+
};
|
|
458
|
+
if (payload !== void 0) {
|
|
459
|
+
headers["Content-Type"] = "application/json";
|
|
460
|
+
}
|
|
461
|
+
if (requestId !== void 0) {
|
|
462
|
+
headers["X-Request-Id"] = requestId;
|
|
463
|
+
}
|
|
464
|
+
const idempotent = headers["Idempotency-Key"] !== void 0;
|
|
465
|
+
for (let attempt = 0; ; attempt++) {
|
|
466
|
+
if (signal?.aborted === true) {
|
|
467
|
+
throw new RequestAbortedError();
|
|
468
|
+
}
|
|
469
|
+
log(logger, "debug", "Labyrinthe Connect request", { method, path: logPath, attempt: attempt + 1 });
|
|
470
|
+
const startedAt = Date.now();
|
|
471
|
+
const result = await this.#attempt(fetch, url, { method, headers, body: payload }, timeoutMs, signal);
|
|
472
|
+
if ("kind" in result) {
|
|
473
|
+
const timeout = result.kind === "timeout";
|
|
474
|
+
if (mode === "safe" && attempt < maxRetries) {
|
|
475
|
+
const delay = this.#backoff(attempt);
|
|
476
|
+
log(logger, "warn", "Labyrinthe Connect network error, retrying", {
|
|
477
|
+
method,
|
|
478
|
+
path: logPath,
|
|
479
|
+
attempt: attempt + 1,
|
|
480
|
+
delayMs: Math.round(delay),
|
|
481
|
+
timeout
|
|
482
|
+
});
|
|
483
|
+
await sleep(delay, signal);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
log(logger, "error", "Labyrinthe Connect network error", { method, path: logPath, timeout });
|
|
487
|
+
const suffix = mode === "throttled-only" ? " The request may have been processed; it was not retried automatically." : idempotent ? " Calling again with the same idempotencyKey is safe (nothing is sent twice)." : "";
|
|
488
|
+
if (result.kind === "timeout") {
|
|
489
|
+
throw new TimeoutError(
|
|
490
|
+
`The Labyrinthe Connect API did not answer within ${String(timeoutMs)} ms (${method} ${path}).${suffix}`
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
throw new ConnectionError(
|
|
494
|
+
`Could not reach the Labyrinthe Connect API (${method} ${path}): ${result.reason}.${suffix}`
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
const { status, headers: responseHeaders, text } = result;
|
|
498
|
+
log(logger, "debug", "Labyrinthe Connect response", {
|
|
499
|
+
method,
|
|
500
|
+
path: logPath,
|
|
501
|
+
status,
|
|
502
|
+
durationMs: Date.now() - startedAt,
|
|
503
|
+
replayed: responseHeaders["idempotent-replayed"] === "true",
|
|
504
|
+
requestId: responseHeaders["x-request-id"] ?? null
|
|
505
|
+
});
|
|
506
|
+
if (status >= 200 && status < 300) {
|
|
507
|
+
const body = decode(text, status);
|
|
508
|
+
if (params.onResponse !== void 0) {
|
|
509
|
+
try {
|
|
510
|
+
params.onResponse(responseInfo(status, responseHeaders));
|
|
511
|
+
} catch (error2) {
|
|
512
|
+
log(logger, "warn", "Labyrinthe Connect onResponse callback threw", {
|
|
513
|
+
method,
|
|
514
|
+
path: logPath,
|
|
515
|
+
error: error2 instanceof Error ? error2.name : typeof error2
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return { status, body, headers: responseHeaders };
|
|
520
|
+
}
|
|
521
|
+
const error = errorFromResponse(status, text, responseHeaders);
|
|
522
|
+
if (attempt < maxRetries && isRetryable(mode, error)) {
|
|
523
|
+
const delay = this.#retryDelay(error, attempt);
|
|
524
|
+
if (delay !== null) {
|
|
525
|
+
log(logger, "warn", "Labyrinthe Connect request failed, retrying", {
|
|
526
|
+
method,
|
|
527
|
+
path: logPath,
|
|
528
|
+
status,
|
|
529
|
+
code: error.code,
|
|
530
|
+
requestId: error.requestId,
|
|
531
|
+
attempt: attempt + 1,
|
|
532
|
+
delayMs: Math.round(delay)
|
|
533
|
+
});
|
|
534
|
+
await sleep(delay, signal);
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
log(logger, "info", "Labyrinthe Connect API error", {
|
|
539
|
+
method,
|
|
540
|
+
path: logPath,
|
|
541
|
+
status,
|
|
542
|
+
code: error.code,
|
|
543
|
+
requestId: error.requestId
|
|
544
|
+
});
|
|
545
|
+
throw error;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
async #attempt(fetch, url, init, timeoutMs, signal) {
|
|
549
|
+
const controller = new AbortController();
|
|
550
|
+
const state = { timedOut: false };
|
|
551
|
+
const timer = setTimeout(() => {
|
|
552
|
+
state.timedOut = true;
|
|
553
|
+
controller.abort();
|
|
554
|
+
}, timeoutMs);
|
|
555
|
+
const onAbort = () => {
|
|
556
|
+
controller.abort();
|
|
557
|
+
};
|
|
558
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
559
|
+
try {
|
|
560
|
+
const response = await fetch(url, {
|
|
561
|
+
method: init.method,
|
|
562
|
+
headers: init.headers,
|
|
563
|
+
...init.body === void 0 ? {} : { body: init.body },
|
|
564
|
+
signal: controller.signal,
|
|
565
|
+
redirect: "manual"
|
|
566
|
+
});
|
|
567
|
+
const headers = {};
|
|
568
|
+
response.headers.forEach((value, key) => {
|
|
569
|
+
headers[key.toLowerCase()] = value;
|
|
570
|
+
});
|
|
571
|
+
const text = await response.text();
|
|
572
|
+
return { status: response.status, headers, text };
|
|
573
|
+
} catch (error) {
|
|
574
|
+
if (signal?.aborted === true) {
|
|
575
|
+
throw new RequestAbortedError();
|
|
576
|
+
}
|
|
577
|
+
if (state.timedOut) {
|
|
578
|
+
return { kind: "timeout" };
|
|
579
|
+
}
|
|
580
|
+
return { kind: "network", reason: describeNetworkError(error) };
|
|
581
|
+
} finally {
|
|
582
|
+
clearTimeout(timer);
|
|
583
|
+
signal?.removeEventListener("abort", onAbort);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/** Délai avant la prochaine tentative, ou null pour abandonner (l'API demande d'attendre plus que le maximum). */
|
|
587
|
+
#retryDelay(error, attempt) {
|
|
588
|
+
if (error instanceof FeatureDisabledError) {
|
|
589
|
+
const retryAfter = error.retryAfterMs;
|
|
590
|
+
return retryAfter !== null && retryAfter <= this.#config.maxRetryWaitMs ? retryAfter : null;
|
|
591
|
+
}
|
|
592
|
+
if (error.status === 429 || error.code === IN_PROGRESS) {
|
|
593
|
+
const retryAfter = retryAfterMsOf(error.headers, error.details);
|
|
594
|
+
if (retryAfter !== null) {
|
|
595
|
+
return retryAfter <= this.#config.maxRetryWaitMs ? retryAfter : null;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return this.#backoff(attempt);
|
|
599
|
+
}
|
|
600
|
+
/** Backoff exponentiel avec jitter : 0,5 s, 1 s, 2 s… (50 à 100 %), plafonné. */
|
|
601
|
+
#backoff(attempt) {
|
|
602
|
+
const delay = Math.min(MAX_BACKOFF_MS, BASE_DELAY_MS * 2 ** attempt);
|
|
603
|
+
const jitter = 0.5 + Math.random() * 0.5;
|
|
604
|
+
return Math.min(this.#config.maxRetryWaitMs, delay * jitter);
|
|
605
|
+
}
|
|
606
|
+
toJSON() {
|
|
607
|
+
return { apiKey: redactApiKey(this.#apiKey), baseUrl: this.#config.baseUrl, maxRetries: this.#config.maxRetries };
|
|
608
|
+
}
|
|
609
|
+
[/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
|
|
610
|
+
return this.toJSON();
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
var IN_PROGRESS = "IDEMPOTENCY_REQUEST_IN_PROGRESS";
|
|
614
|
+
function isRetryable(mode, error) {
|
|
615
|
+
if (error instanceof FeatureDisabledError) {
|
|
616
|
+
return true;
|
|
617
|
+
}
|
|
618
|
+
if (error.status === 429) {
|
|
619
|
+
return error.code === "TOO_MANY_REQUESTS";
|
|
620
|
+
}
|
|
621
|
+
if (mode !== "safe") {
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
return error.code === IN_PROGRESS || error.status >= 500;
|
|
625
|
+
}
|
|
626
|
+
function responseInfo(status, headers) {
|
|
627
|
+
return {
|
|
628
|
+
status,
|
|
629
|
+
requestId: headers["x-request-id"] ?? null,
|
|
630
|
+
apiVersion: headers["x-api-version"] ?? null,
|
|
631
|
+
replayed: headers["idempotent-replayed"] === "true",
|
|
632
|
+
rateLimit: { limit: toInt(headers["x-ratelimit-limit"]), remaining: toInt(headers["x-ratelimit-remaining"]) },
|
|
633
|
+
headers
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
function decode(text, status) {
|
|
637
|
+
if (text.trim() === "") {
|
|
638
|
+
return {};
|
|
639
|
+
}
|
|
640
|
+
let decoded;
|
|
641
|
+
try {
|
|
642
|
+
decoded = JSON.parse(text);
|
|
643
|
+
} catch {
|
|
644
|
+
throw new UnexpectedResponseError("Unexpected API response: the body is not valid JSON.", status);
|
|
645
|
+
}
|
|
646
|
+
if (!isRecord(decoded)) {
|
|
647
|
+
throw new UnexpectedResponseError("Unexpected API response: a JSON object was expected.", status);
|
|
648
|
+
}
|
|
649
|
+
return decoded;
|
|
650
|
+
}
|
|
651
|
+
function describeNetworkError(error) {
|
|
652
|
+
if (!(error instanceof Error)) {
|
|
653
|
+
return "network error";
|
|
654
|
+
}
|
|
655
|
+
const cause = error.cause;
|
|
656
|
+
const code = isRecord(cause) && typeof cause["code"] === "string" ? cause["code"] : void 0;
|
|
657
|
+
const message = stripQueryStrings(error.message);
|
|
658
|
+
return code === void 0 ? message : `${message} (${code})`;
|
|
659
|
+
}
|
|
660
|
+
function log(logger, level, message, context) {
|
|
661
|
+
try {
|
|
662
|
+
logger?.[level]?.(message, context);
|
|
663
|
+
} catch {
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// src/internal/data.ts
|
|
668
|
+
function characterCount(value) {
|
|
669
|
+
let count = 0;
|
|
670
|
+
for (const _char of value) count++;
|
|
671
|
+
return count;
|
|
672
|
+
}
|
|
673
|
+
var MESSAGE_STATUSES = [
|
|
674
|
+
"queued",
|
|
675
|
+
"submitted",
|
|
676
|
+
"sent",
|
|
677
|
+
"delivered",
|
|
678
|
+
"failed",
|
|
679
|
+
"rejected",
|
|
680
|
+
"expired",
|
|
681
|
+
"undeliverable"
|
|
682
|
+
];
|
|
683
|
+
var SENDER_ID_STATUSES = ["pending", "pending_provider", "approved", "rejected", "suspended"];
|
|
684
|
+
var OTP_FAILURE_REASONS = ["invalid_code", "expired", "already_used", "max_attempts_reached"];
|
|
685
|
+
var Reader = class {
|
|
686
|
+
constructor(context = "Unexpected API payload") {
|
|
687
|
+
this.context = context;
|
|
688
|
+
}
|
|
689
|
+
context;
|
|
690
|
+
invalid(key, expected) {
|
|
691
|
+
return new UnexpectedResponseError(`${this.context}: field "${key}" should be ${expected}.`);
|
|
692
|
+
}
|
|
693
|
+
string(data, key) {
|
|
694
|
+
const value = data[key];
|
|
695
|
+
if (typeof value === "string") return value;
|
|
696
|
+
if (typeof value === "number") return String(value);
|
|
697
|
+
throw this.invalid(key, "a string");
|
|
698
|
+
}
|
|
699
|
+
nullableString(data, key) {
|
|
700
|
+
return data[key] === void 0 || data[key] === null ? null : this.string(data, key);
|
|
701
|
+
}
|
|
702
|
+
int(data, key) {
|
|
703
|
+
const value = toInt(data[key]);
|
|
704
|
+
if (value === null) throw this.invalid(key, "an integer");
|
|
705
|
+
return value;
|
|
706
|
+
}
|
|
707
|
+
nullableInt(data, key) {
|
|
708
|
+
return data[key] === void 0 || data[key] === null ? null : this.int(data, key);
|
|
709
|
+
}
|
|
710
|
+
bool(data, key) {
|
|
711
|
+
const value = data[key];
|
|
712
|
+
if (typeof value === "boolean") return value;
|
|
713
|
+
if (value === 0 || value === 1) return value === 1;
|
|
714
|
+
throw this.invalid(key, "a boolean");
|
|
715
|
+
}
|
|
716
|
+
object(data, key) {
|
|
717
|
+
const value = data[key];
|
|
718
|
+
if (isRecord(value)) return value;
|
|
719
|
+
throw this.invalid(key, "an object");
|
|
720
|
+
}
|
|
721
|
+
/** Objet facultatif : absent, nul ou `[]` (PHP) donnent `{}`. */
|
|
722
|
+
optionalObject(data, key) {
|
|
723
|
+
const value = data[key];
|
|
724
|
+
if (value === void 0 || value === null || Array.isArray(value) && value.length === 0) return {};
|
|
725
|
+
return this.object(data, key);
|
|
726
|
+
}
|
|
727
|
+
objects(data, key) {
|
|
728
|
+
const value = data[key];
|
|
729
|
+
if (!Array.isArray(value)) throw this.invalid(key, "an array");
|
|
730
|
+
return value.map((item) => {
|
|
731
|
+
if (!isRecord(item)) throw this.invalid(`${key}[]`, "an object");
|
|
732
|
+
return item;
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
date(data, key) {
|
|
736
|
+
const value = data[key];
|
|
737
|
+
const date = typeof value === "string" ? new Date(value) : null;
|
|
738
|
+
if (date === null || Number.isNaN(date.getTime())) throw this.invalid(key, "an ISO 8601 date-time");
|
|
739
|
+
return date;
|
|
740
|
+
}
|
|
741
|
+
nullableDate(data, key) {
|
|
742
|
+
return data[key] === void 0 || data[key] === null ? null : this.date(data, key);
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
var api = new Reader();
|
|
746
|
+
function messageStatus(value) {
|
|
747
|
+
return MESSAGE_STATUSES.includes(value) ? value : "unknown";
|
|
748
|
+
}
|
|
749
|
+
function encoding(value) {
|
|
750
|
+
return value === "gsm7" || value === "ucs2" ? value : null;
|
|
751
|
+
}
|
|
752
|
+
function toMessage(data) {
|
|
753
|
+
return {
|
|
754
|
+
id: api.string(data, "id"),
|
|
755
|
+
reference: api.nullableString(data, "reference"),
|
|
756
|
+
recipient: api.string(data, "recipient"),
|
|
757
|
+
sender: api.nullableString(data, "sender"),
|
|
758
|
+
// Jamais null depuis la spec 1.2.0 (BACKEND-TODO n° 9) ; null toléré pour un backend plus ancien.
|
|
759
|
+
encoding: encoding(api.nullableString(data, "encoding")),
|
|
760
|
+
status: messageStatus(api.string(data, "status")),
|
|
761
|
+
segments: api.nullableInt(data, "segments") ?? 0,
|
|
762
|
+
creditsCharged: api.nullableInt(data, "credits_charged") ?? 0,
|
|
763
|
+
campaignId: api.nullableString(data, "campaign_id"),
|
|
764
|
+
providerMessageId: api.nullableString(data, "provider_message_id"),
|
|
765
|
+
errorCode: api.nullableString(data, "error_code"),
|
|
766
|
+
errorMessage: api.nullableString(data, "error_message"),
|
|
767
|
+
createdAt: api.nullableDate(data, "created_at"),
|
|
768
|
+
submittedAt: api.nullableDate(data, "submitted_at"),
|
|
769
|
+
deliveredAt: api.nullableDate(data, "delivered_at")
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
function toEstimate(data) {
|
|
773
|
+
const balanceAfter = api.nullableInt(data, "balance_after");
|
|
774
|
+
return {
|
|
775
|
+
encoding: encoding(api.string(data, "encoding")) ?? "ucs2",
|
|
776
|
+
characters: api.int(data, "characters"),
|
|
777
|
+
segmentsPerMessage: api.int(data, "segments_per_message"),
|
|
778
|
+
recipientsCount: api.int(data, "recipients_count"),
|
|
779
|
+
totalCredits: api.int(data, "total_credits"),
|
|
780
|
+
balanceAfter,
|
|
781
|
+
isAffordable: balanceAfter !== null && balanceAfter >= 0
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
function toBalance(data) {
|
|
785
|
+
return { balance: api.int(data, "balance"), held: api.int(data, "held") };
|
|
786
|
+
}
|
|
787
|
+
function toSenderId(data) {
|
|
788
|
+
const status = api.string(data, "status");
|
|
789
|
+
return {
|
|
790
|
+
id: api.string(data, "id"),
|
|
791
|
+
value: api.string(data, "value"),
|
|
792
|
+
status: SENDER_ID_STATUSES.includes(status) ? status : "unknown",
|
|
793
|
+
purpose: api.nullableString(data, "purpose"),
|
|
794
|
+
rejectionReason: api.nullableString(data, "rejection_reason"),
|
|
795
|
+
isDefault: data["is_default"] !== void 0 && data["is_default"] !== null && api.bool(data, "is_default"),
|
|
796
|
+
createdAt: api.nullableDate(data, "created_at")
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
function toOtpVerifyResult(data) {
|
|
800
|
+
if (api.bool(data, "verified")) {
|
|
801
|
+
return { verified: true, reason: null, attemptsRemaining: null };
|
|
802
|
+
}
|
|
803
|
+
const reason = api.nullableString(data, "reason") ?? "unknown";
|
|
804
|
+
const remaining = api.nullableInt(data, "attempts_remaining");
|
|
805
|
+
return {
|
|
806
|
+
verified: false,
|
|
807
|
+
reason: OTP_FAILURE_REASONS.includes(reason) ? reason : "unknown",
|
|
808
|
+
attemptsRemaining: remaining === null ? null : Math.max(0, remaining)
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// src/resources/account.ts
|
|
813
|
+
var BalanceResource = class {
|
|
814
|
+
#transport;
|
|
815
|
+
/** @internal */
|
|
816
|
+
constructor(transport) {
|
|
817
|
+
this.#transport = transport;
|
|
818
|
+
}
|
|
819
|
+
async get(options = {}) {
|
|
820
|
+
const response = await this.#transport.request("GET", "/balance", "safe", options);
|
|
821
|
+
return toBalance(api.object(response.body, "data"));
|
|
822
|
+
}
|
|
823
|
+
toJSON() {
|
|
824
|
+
return { transport: this.#transport.toJSON() };
|
|
825
|
+
}
|
|
826
|
+
[/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
|
|
827
|
+
return this.toJSON();
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
var SenderIds = class {
|
|
831
|
+
#transport;
|
|
832
|
+
/** @internal */
|
|
833
|
+
constructor(transport) {
|
|
834
|
+
this.#transport = transport;
|
|
835
|
+
}
|
|
836
|
+
/** Sender IDs with the `approved` status, usable as `sender`. */
|
|
837
|
+
async list(options = {}) {
|
|
838
|
+
const response = await this.#transport.request("GET", "/sender-ids", "safe", options);
|
|
839
|
+
return api.objects(response.body, "data").map(toSenderId);
|
|
840
|
+
}
|
|
841
|
+
toJSON() {
|
|
842
|
+
return { transport: this.#transport.toJSON() };
|
|
843
|
+
}
|
|
844
|
+
[/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
|
|
845
|
+
return this.toJSON();
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
// src/internal/idempotency.ts
|
|
850
|
+
var MAX_IDEMPOTENCY_KEY_LENGTH = 255;
|
|
851
|
+
async function resolveIdempotencyKey(value) {
|
|
852
|
+
if (value === void 0) {
|
|
853
|
+
return (await getCrypto()).randomUUID();
|
|
854
|
+
}
|
|
855
|
+
if (typeof value !== "string" || value === "" || value.length > MAX_IDEMPOTENCY_KEY_LENGTH) {
|
|
856
|
+
throw new InvalidArgumentError(`idempotencyKey must be 1 to ${String(MAX_IDEMPOTENCY_KEY_LENGTH)} characters.`);
|
|
857
|
+
}
|
|
858
|
+
return value;
|
|
859
|
+
}
|
|
860
|
+
function assertStripAccents(value) {
|
|
861
|
+
if (value !== void 0 && typeof value !== "boolean") {
|
|
862
|
+
throw new InvalidArgumentError("stripAccents must be a boolean.");
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// src/types.ts
|
|
867
|
+
var MessageStatus = {
|
|
868
|
+
/** Queued at Labyrinthe Connect. */
|
|
869
|
+
Queued: "queued",
|
|
870
|
+
/** Handed over to the SMS provider. */
|
|
871
|
+
Submitted: "submitted",
|
|
872
|
+
/** Accepted by the mobile operator. */
|
|
873
|
+
Sent: "sent",
|
|
874
|
+
/** Delivered to the handset (final). */
|
|
875
|
+
Delivered: "delivered",
|
|
876
|
+
/** Failed (final). */
|
|
877
|
+
Failed: "failed",
|
|
878
|
+
/** Rejected by the provider or the operator (final). */
|
|
879
|
+
Rejected: "rejected",
|
|
880
|
+
/** Expired before delivery (final). */
|
|
881
|
+
Expired: "expired",
|
|
882
|
+
/** Cannot be delivered to this number (final). */
|
|
883
|
+
Undeliverable: "undeliverable",
|
|
884
|
+
/** Status not known by this SDK version. */
|
|
885
|
+
Unknown: "unknown"
|
|
886
|
+
};
|
|
887
|
+
var PENDING_STATUSES = ["queued", "submitted", "sent"];
|
|
888
|
+
var FAILED_STATUSES = ["failed", "rejected", "expired", "undeliverable"];
|
|
889
|
+
function isFinalStatus(status) {
|
|
890
|
+
return status === "delivered" || FAILED_STATUSES.includes(status);
|
|
891
|
+
}
|
|
892
|
+
function isFailedStatus(status) {
|
|
893
|
+
return FAILED_STATUSES.includes(status);
|
|
894
|
+
}
|
|
895
|
+
function isPendingStatus(status) {
|
|
896
|
+
return PENDING_STATUSES.includes(status);
|
|
897
|
+
}
|
|
898
|
+
var Encoding = {
|
|
899
|
+
Gsm7: "gsm7",
|
|
900
|
+
Ucs2: "ucs2"
|
|
901
|
+
};
|
|
902
|
+
var SenderIdStatus = {
|
|
903
|
+
Pending: "pending",
|
|
904
|
+
PendingProvider: "pending_provider",
|
|
905
|
+
Approved: "approved",
|
|
906
|
+
Rejected: "rejected",
|
|
907
|
+
Suspended: "suspended",
|
|
908
|
+
/** Status not known by this SDK version. */
|
|
909
|
+
Unknown: "unknown"
|
|
910
|
+
};
|
|
911
|
+
var RejectionReason = {
|
|
912
|
+
InvalidPhone: "invalid_phone",
|
|
913
|
+
Blacklisted: "blacklisted",
|
|
914
|
+
OptedOut: "opted_out",
|
|
915
|
+
/** Reason not known by this SDK version. */
|
|
916
|
+
Unknown: "unknown"
|
|
917
|
+
};
|
|
918
|
+
var OtpFailureReason = {
|
|
919
|
+
/** Wrong code: one attempt was consumed, the user may try again. */
|
|
920
|
+
InvalidCode: "invalid_code",
|
|
921
|
+
/** The code expired: send a new one. */
|
|
922
|
+
Expired: "expired",
|
|
923
|
+
/** The code was already verified (each code is single-use). */
|
|
924
|
+
AlreadyUsed: "already_used",
|
|
925
|
+
/** Too many wrong attempts: the code is permanently blocked. */
|
|
926
|
+
MaxAttemptsReached: "max_attempts_reached",
|
|
927
|
+
/** Reason not known by this SDK version. */
|
|
928
|
+
Unknown: "unknown"
|
|
929
|
+
};
|
|
930
|
+
var OtpRateLimitScope = {
|
|
931
|
+
Phone: "phone",
|
|
932
|
+
Account: "account",
|
|
933
|
+
/** Scope not known by this SDK version. */
|
|
934
|
+
Unknown: "unknown"
|
|
935
|
+
};
|
|
936
|
+
var OtpCodeType = {
|
|
937
|
+
/** Digits 0-9. */
|
|
938
|
+
Numeric: "numeric",
|
|
939
|
+
/** Upper-case letters and digits without ambiguous characters; typing is case-insensitive. */
|
|
940
|
+
Alphanumeric: "alphanumeric",
|
|
941
|
+
/** Alphabet not known by this SDK version. */
|
|
942
|
+
Unknown: "unknown"
|
|
943
|
+
};
|
|
944
|
+
var OtpVerificationStatus = {
|
|
945
|
+
/** The right code was typed (`otp.verified`). */
|
|
946
|
+
Verified: "verified",
|
|
947
|
+
/** Too many wrong attempts (`otp.failed`, reason `max_attempts_reached`). */
|
|
948
|
+
Failed: "failed",
|
|
949
|
+
/** The code expired (`otp.failed`, reason `expired`). */
|
|
950
|
+
Expired: "expired",
|
|
951
|
+
/** Status not known by this SDK version. */
|
|
952
|
+
Unknown: "unknown"
|
|
953
|
+
};
|
|
954
|
+
var WebhookEventType = {
|
|
955
|
+
MessageStatusChanged: "message.status_changed",
|
|
956
|
+
CampaignCompleted: "campaign.completed",
|
|
957
|
+
BalanceLow: "balance.low",
|
|
958
|
+
OtpVerified: "otp.verified",
|
|
959
|
+
OtpFailed: "otp.failed",
|
|
960
|
+
TestPing: "test.ping"
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
// src/resources/otp.ts
|
|
964
|
+
var MAX_TEMPLATE_LENGTH = 1600;
|
|
965
|
+
var MAX_CODE_LENGTH = 20;
|
|
966
|
+
var WEB_OTP_DOMAIN_PATTERN = /^(?=.{1,253}$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
|
|
967
|
+
var INSPECT = /* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom");
|
|
968
|
+
var DENO_INSPECT = /* @__PURE__ */ Symbol.for("Deno.customInspect");
|
|
969
|
+
var CODE_TYPES = [OtpCodeType.Numeric, OtpCodeType.Alphanumeric];
|
|
970
|
+
var OtpSendResult = class {
|
|
971
|
+
otpId;
|
|
972
|
+
expiresAt;
|
|
973
|
+
maxAttempts;
|
|
974
|
+
/** Number of characters of the code (4 to 8, account's OTP settings); null only with an API older than 1.2.0. */
|
|
975
|
+
codeLength;
|
|
976
|
+
/** `numeric` (0-9) or `alphanumeric` (case-insensitive); null only with an API older than 1.2.0. */
|
|
977
|
+
codeType;
|
|
978
|
+
/**
|
|
979
|
+
* Seconds before a new send to this number is accepted while this code is
|
|
980
|
+
* not verified: show a "Resend the code" countdown. Null only with an API older than 1.2.0.
|
|
981
|
+
*/
|
|
982
|
+
resendAfterSeconds;
|
|
983
|
+
/** The Idempotency-Key of this send (yours, or the one generated by the SDK). */
|
|
984
|
+
idempotencyKey;
|
|
985
|
+
/** True when the API replayed an earlier send with the same Idempotency-Key: no new SMS, no new charge. */
|
|
986
|
+
replayed;
|
|
987
|
+
/** `X-Request-Id` of the response, for the support; null when absent. */
|
|
988
|
+
requestId;
|
|
989
|
+
#testCode;
|
|
990
|
+
/** @internal */
|
|
991
|
+
constructor(init) {
|
|
992
|
+
this.otpId = init.otpId;
|
|
993
|
+
this.expiresAt = init.expiresAt;
|
|
994
|
+
this.maxAttempts = init.maxAttempts;
|
|
995
|
+
this.codeLength = init.codeLength;
|
|
996
|
+
this.codeType = init.codeType;
|
|
997
|
+
this.resendAfterSeconds = init.resendAfterSeconds;
|
|
998
|
+
this.idempotencyKey = init.idempotencyKey;
|
|
999
|
+
this.replayed = init.replayed;
|
|
1000
|
+
this.requestId = init.requestId;
|
|
1001
|
+
this.#testCode = init.testCode;
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* The code, ONLY with a test key (`lsk_test_…`): nothing is sent by SMS in
|
|
1005
|
+
* test mode, so the API returns the code to let you test `verify()`. Always
|
|
1006
|
+
* null with a live key. Never log it, never show it to end users. Hidden
|
|
1007
|
+
* from `JSON.stringify()` and `console.log()`.
|
|
1008
|
+
*/
|
|
1009
|
+
get testCode() {
|
|
1010
|
+
return this.#testCode;
|
|
1011
|
+
}
|
|
1012
|
+
toJSON() {
|
|
1013
|
+
return {
|
|
1014
|
+
otpId: this.otpId,
|
|
1015
|
+
expiresAt: this.expiresAt.toISOString(),
|
|
1016
|
+
maxAttempts: this.maxAttempts,
|
|
1017
|
+
codeLength: this.codeLength,
|
|
1018
|
+
codeType: this.codeType,
|
|
1019
|
+
resendAfterSeconds: this.resendAfterSeconds,
|
|
1020
|
+
replayed: this.replayed,
|
|
1021
|
+
requestId: this.requestId
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
[INSPECT]() {
|
|
1025
|
+
return this.#redacted();
|
|
1026
|
+
}
|
|
1027
|
+
[DENO_INSPECT]() {
|
|
1028
|
+
return `OtpSendResult ${JSON.stringify(this.#redacted())}`;
|
|
1029
|
+
}
|
|
1030
|
+
#redacted() {
|
|
1031
|
+
return {
|
|
1032
|
+
...this.toJSON(),
|
|
1033
|
+
expiresAt: this.expiresAt,
|
|
1034
|
+
testCode: this.#testCode === null ? null : "[redacted]"
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
};
|
|
1038
|
+
var Otp = class {
|
|
1039
|
+
#transport;
|
|
1040
|
+
#defaultCountryCode;
|
|
1041
|
+
/** @internal */
|
|
1042
|
+
constructor(transport, defaultCountryCode) {
|
|
1043
|
+
this.#transport = transport;
|
|
1044
|
+
this.#defaultCountryCode = defaultCountryCode;
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Generates a code and sends it by SMS (billed like any SMS). A new send to
|
|
1048
|
+
* the same number invalidates the previous code.
|
|
1049
|
+
*
|
|
1050
|
+
* An Idempotency-Key is always sent (generated when you give none), so the
|
|
1051
|
+
* SDK retries network errors, 5xx, 429 TOO_MANY_REQUESTS and 409
|
|
1052
|
+
* IDEMPOTENCY_REQUEST_IN_PROGRESS with the same key: a retry never sends a
|
|
1053
|
+
* second SMS. The business limits (OTP_RESEND_TOO_SOON, OTP_RATE_LIMITED)
|
|
1054
|
+
* are never retried. 503 FEATURE_DISABLED / OTP_SERVICE_DISABLED
|
|
1055
|
+
* (`FeatureDisabledError`) is retried only after a `Retry-After` no longer
|
|
1056
|
+
* than `maxRetryWaitMs`.
|
|
1057
|
+
*/
|
|
1058
|
+
async send(params, options = {}) {
|
|
1059
|
+
const body = {
|
|
1060
|
+
phone: toApiNumber(PhoneNumber.normalizeOrThrow(params.phone, this.#defaultCountryCode, "phone"))
|
|
1061
|
+
};
|
|
1062
|
+
const template = params.template;
|
|
1063
|
+
const locale = params.locale;
|
|
1064
|
+
const webOtpDomain = params.webOtpDomain;
|
|
1065
|
+
const stripAccents = params.stripAccents;
|
|
1066
|
+
if (template !== void 0) {
|
|
1067
|
+
if (typeof template !== "string" || !template.includes("{code}")) {
|
|
1068
|
+
throw new InvalidArgumentError("The OTP template must contain the {code} token.");
|
|
1069
|
+
}
|
|
1070
|
+
if (characterCount(template) > MAX_TEMPLATE_LENGTH) {
|
|
1071
|
+
throw new InvalidArgumentError(`The OTP template must be at most ${String(MAX_TEMPLATE_LENGTH)} characters.`);
|
|
1072
|
+
}
|
|
1073
|
+
body["template"] = template;
|
|
1074
|
+
}
|
|
1075
|
+
if (locale !== void 0) {
|
|
1076
|
+
if (locale !== "fr" && locale !== "en") {
|
|
1077
|
+
throw new InvalidArgumentError('locale must be "fr" or "en".');
|
|
1078
|
+
}
|
|
1079
|
+
body["locale"] = locale;
|
|
1080
|
+
}
|
|
1081
|
+
if (webOtpDomain !== void 0) {
|
|
1082
|
+
if (typeof webOtpDomain !== "string" || !WEB_OTP_DOMAIN_PATTERN.test(webOtpDomain)) {
|
|
1083
|
+
throw new InvalidArgumentError(
|
|
1084
|
+
'webOtpDomain must be a host name with at least two labels, without scheme, port or path (e.g. "app.example.com").'
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
body["web_otp_domain"] = webOtpDomain.toLowerCase();
|
|
1088
|
+
}
|
|
1089
|
+
assertStripAccents(stripAccents);
|
|
1090
|
+
if (stripAccents !== void 0) {
|
|
1091
|
+
body["strip_accents"] = stripAccents;
|
|
1092
|
+
}
|
|
1093
|
+
const idempotencyKey = await resolveIdempotencyKey(params.idempotencyKey);
|
|
1094
|
+
const response = await this.#transport.request("POST", "/otp/send", "safe", {
|
|
1095
|
+
body,
|
|
1096
|
+
...options,
|
|
1097
|
+
headers: { "Idempotency-Key": idempotencyKey }
|
|
1098
|
+
});
|
|
1099
|
+
const data = api.object(response.body, "data");
|
|
1100
|
+
const codeType = api.nullableString(data, "code_type");
|
|
1101
|
+
return new OtpSendResult({
|
|
1102
|
+
otpId: api.string(data, "otp_id"),
|
|
1103
|
+
expiresAt: api.date(data, "expires_at"),
|
|
1104
|
+
maxAttempts: api.int(data, "max_attempts"),
|
|
1105
|
+
// Champs de la spec 1.2.0 : null avec un backend plus ancien (l'envoi a réussi, on ne le fait pas échouer).
|
|
1106
|
+
codeLength: api.nullableInt(data, "code_length"),
|
|
1107
|
+
codeType: codeType === null ? null : CODE_TYPES.includes(codeType) ? codeType : "unknown",
|
|
1108
|
+
resendAfterSeconds: api.nullableInt(data, "resend_after_seconds"),
|
|
1109
|
+
testCode: api.nullableString(data, "test_code"),
|
|
1110
|
+
idempotencyKey,
|
|
1111
|
+
replayed: response.headers["idempotent-replayed"] === "true",
|
|
1112
|
+
requestId: response.headers["x-request-id"] ?? null
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Checks a code. A wrong code is NOT an error: the result says
|
|
1117
|
+
* `verified: false` with a `reason` and `attemptsRemaining`, and one attempt
|
|
1118
|
+
* is consumed. Free. Throws `NotFoundError` for an unknown `otpId` (or one
|
|
1119
|
+
* from another account).
|
|
1120
|
+
*
|
|
1121
|
+
* Retried only on 429 TOO_MANY_REQUESTS and on 503 FEATURE_DISABLED with a
|
|
1122
|
+
* `Retry-After` no longer than `maxRetryWaitMs` (both refused before
|
|
1123
|
+
* processing): after a network error or another 5xx, an attempt may have
|
|
1124
|
+
* been consumed.
|
|
1125
|
+
*/
|
|
1126
|
+
async verify(params, options = {}) {
|
|
1127
|
+
const { otpId, code } = params;
|
|
1128
|
+
if (typeof otpId !== "string" || otpId.trim() === "") {
|
|
1129
|
+
throw new InvalidArgumentError("otpId cannot be empty.");
|
|
1130
|
+
}
|
|
1131
|
+
if (typeof code !== "string" || code.trim() === "" || code.length > MAX_CODE_LENGTH) {
|
|
1132
|
+
throw new InvalidArgumentError(`The code must be 1 to ${String(MAX_CODE_LENGTH)} characters.`);
|
|
1133
|
+
}
|
|
1134
|
+
const response = await this.#transport.request("POST", "/otp/verify", "throttled-only", {
|
|
1135
|
+
body: { otp_id: otpId, code },
|
|
1136
|
+
...options
|
|
1137
|
+
});
|
|
1138
|
+
return toOtpVerifyResult(api.object(response.body, "data"));
|
|
1139
|
+
}
|
|
1140
|
+
toJSON() {
|
|
1141
|
+
return { transport: this.#transport.toJSON(), defaultCountryCode: this.#defaultCountryCode };
|
|
1142
|
+
}
|
|
1143
|
+
[INSPECT]() {
|
|
1144
|
+
return this.toJSON();
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
|
|
1148
|
+
// src/pagination.ts
|
|
1149
|
+
var Page = class {
|
|
1150
|
+
data;
|
|
1151
|
+
currentPage;
|
|
1152
|
+
lastPage;
|
|
1153
|
+
perPage;
|
|
1154
|
+
total;
|
|
1155
|
+
#fetchPage;
|
|
1156
|
+
/** @internal */
|
|
1157
|
+
constructor(meta, fetchPage) {
|
|
1158
|
+
this.data = meta.data;
|
|
1159
|
+
this.currentPage = meta.currentPage;
|
|
1160
|
+
this.lastPage = meta.lastPage;
|
|
1161
|
+
this.perPage = meta.perPage;
|
|
1162
|
+
this.total = meta.total;
|
|
1163
|
+
this.#fetchPage = fetchPage;
|
|
1164
|
+
}
|
|
1165
|
+
get hasMore() {
|
|
1166
|
+
return this.currentPage < this.lastPage;
|
|
1167
|
+
}
|
|
1168
|
+
/** The next page, or null on the last one. */
|
|
1169
|
+
async nextPage() {
|
|
1170
|
+
return this.hasMore ? this.#fetchPage(this.currentPage + 1) : null;
|
|
1171
|
+
}
|
|
1172
|
+
/** Every item from this page to the last one (pages are fetched lazily). */
|
|
1173
|
+
async *[Symbol.asyncIterator]() {
|
|
1174
|
+
yield* this.data;
|
|
1175
|
+
let page = await this.nextPage();
|
|
1176
|
+
while (page !== null) {
|
|
1177
|
+
yield* page.data;
|
|
1178
|
+
page = await page.nextPage();
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
toJSON() {
|
|
1182
|
+
return {
|
|
1183
|
+
data: this.data,
|
|
1184
|
+
currentPage: this.currentPage,
|
|
1185
|
+
lastPage: this.lastPage,
|
|
1186
|
+
perPage: this.perPage,
|
|
1187
|
+
total: this.total
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
};
|
|
1191
|
+
var PagePromise = class {
|
|
1192
|
+
#load;
|
|
1193
|
+
#promise;
|
|
1194
|
+
/** @internal */
|
|
1195
|
+
constructor(load) {
|
|
1196
|
+
this.#load = load;
|
|
1197
|
+
}
|
|
1198
|
+
#first() {
|
|
1199
|
+
this.#promise ??= this.#load();
|
|
1200
|
+
return this.#promise;
|
|
1201
|
+
}
|
|
1202
|
+
then(onFulfilled, onRejected) {
|
|
1203
|
+
return this.#first().then(onFulfilled, onRejected);
|
|
1204
|
+
}
|
|
1205
|
+
catch(onRejected) {
|
|
1206
|
+
return this.#first().catch(onRejected);
|
|
1207
|
+
}
|
|
1208
|
+
finally(onFinally) {
|
|
1209
|
+
return this.#first().finally(onFinally);
|
|
1210
|
+
}
|
|
1211
|
+
async *[Symbol.asyncIterator]() {
|
|
1212
|
+
yield* await this.#first();
|
|
1213
|
+
}
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
// src/resources/sms.ts
|
|
1217
|
+
var MAX_BODY_LENGTH = 1600;
|
|
1218
|
+
var MAX_RECIPIENTS = 1e3;
|
|
1219
|
+
var MAX_SENDER_LENGTH = 11;
|
|
1220
|
+
var MAX_REFERENCE_LENGTH = 64;
|
|
1221
|
+
var MAX_PER_PAGE = 100;
|
|
1222
|
+
var Sms = class {
|
|
1223
|
+
#transport;
|
|
1224
|
+
#defaultCountryCode;
|
|
1225
|
+
#defaultSender;
|
|
1226
|
+
/** @internal */
|
|
1227
|
+
constructor(transport, defaultCountryCode, defaultSender) {
|
|
1228
|
+
this.#transport = transport;
|
|
1229
|
+
this.#defaultCountryCode = defaultCountryCode;
|
|
1230
|
+
this.#defaultSender = defaultSender;
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Sends one SMS. Asynchronous: the returned message is `queued`; follow its
|
|
1234
|
+
* status with webhooks (`message.status_changed`) or `get()`.
|
|
1235
|
+
*
|
|
1236
|
+
* An Idempotency-Key is always sent, so network errors are retried safely:
|
|
1237
|
+
* the SMS is never sent twice.
|
|
1238
|
+
*/
|
|
1239
|
+
async send(params, options = {}) {
|
|
1240
|
+
const recipient = toApiNumber(PhoneNumber.normalizeOrThrow(params.to, this.#defaultCountryCode, "to"));
|
|
1241
|
+
const batch = await this.#dispatch({ recipient }, params, options);
|
|
1242
|
+
const message = batch.messages[0];
|
|
1243
|
+
if (message === void 0) {
|
|
1244
|
+
throw new UnexpectedResponseError("Unexpected API response: no message was returned.");
|
|
1245
|
+
}
|
|
1246
|
+
return message;
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Sends the same SMS to several recipients (up to 1000), all or nothing: if
|
|
1250
|
+
* a single recipient is refused, nothing is sent and a
|
|
1251
|
+
* `RecipientNotSendableError` lists every refused number. Duplicates are
|
|
1252
|
+
* merged by the API (one message per distinct number).
|
|
1253
|
+
*/
|
|
1254
|
+
async sendMany(params, options = {}) {
|
|
1255
|
+
const recipients = params.recipients;
|
|
1256
|
+
if (!Array.isArray(recipients) || recipients.length === 0) {
|
|
1257
|
+
throw new InvalidArgumentError("At least one recipient is required.");
|
|
1258
|
+
}
|
|
1259
|
+
if (recipients.length > MAX_RECIPIENTS) {
|
|
1260
|
+
throw new InvalidArgumentError(`At most ${String(MAX_RECIPIENTS)} recipients per request.`);
|
|
1261
|
+
}
|
|
1262
|
+
const normalized = [];
|
|
1263
|
+
const invalid = [];
|
|
1264
|
+
recipients.forEach((recipient, index) => {
|
|
1265
|
+
const number = typeof recipient === "string" ? PhoneNumber.normalize(recipient, this.#defaultCountryCode) : null;
|
|
1266
|
+
if (number === null) {
|
|
1267
|
+
invalid.push(index);
|
|
1268
|
+
} else {
|
|
1269
|
+
normalized.push(toApiNumber(number));
|
|
1270
|
+
}
|
|
1271
|
+
});
|
|
1272
|
+
if (invalid.length > 0) {
|
|
1273
|
+
const shown = invalid.slice(0, 20).join(", ") + (invalid.length > 20 ? "\u2026" : "");
|
|
1274
|
+
throw new InvalidArgumentError(`Invalid phone number(s) at recipients index ${shown}. Nothing was sent.`);
|
|
1275
|
+
}
|
|
1276
|
+
return this.#dispatch({ recipients: normalized }, params, options);
|
|
1277
|
+
}
|
|
1278
|
+
/** Computes encoding, segments and credits without sending anything. */
|
|
1279
|
+
async estimate(params, options = {}) {
|
|
1280
|
+
assertBody(params.body);
|
|
1281
|
+
const list = typeof params.recipients === "string" ? [params.recipients] : [...params.recipients];
|
|
1282
|
+
if (list.length === 0) {
|
|
1283
|
+
throw new InvalidArgumentError("At least one recipient is required.");
|
|
1284
|
+
}
|
|
1285
|
+
const recipients = list.map((r) => toApiNumberOrRaw(r, this.#defaultCountryCode));
|
|
1286
|
+
const stripAccents = params.stripAccents;
|
|
1287
|
+
assertStripAccents(stripAccents);
|
|
1288
|
+
const body = { body: params.body, recipients };
|
|
1289
|
+
if (stripAccents !== void 0) body["strip_accents"] = stripAccents;
|
|
1290
|
+
const response = await this.#transport.request("POST", "/sms/estimate", "safe", { body, ...options });
|
|
1291
|
+
return toEstimate(api.object(response.body, "data"));
|
|
1292
|
+
}
|
|
1293
|
+
/** One message of the account, by id. Throws `NotFoundError` when unknown. */
|
|
1294
|
+
async get(id, options = {}) {
|
|
1295
|
+
if (typeof id !== "string" || id.trim() === "") {
|
|
1296
|
+
throw new InvalidArgumentError("The message id cannot be empty.");
|
|
1297
|
+
}
|
|
1298
|
+
const response = await this.#transport.request("GET", `/sms/${encodeURIComponent(id)}`, "safe", options);
|
|
1299
|
+
return toMessage(api.object(response.body, "data"));
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* Messages sent with this API key, newest first.
|
|
1303
|
+
*
|
|
1304
|
+
* const page = await client.sms.list({ status: 'failed' }); // one page
|
|
1305
|
+
* for await (const message of client.sms.list()) { … } // every page
|
|
1306
|
+
*/
|
|
1307
|
+
list(params = {}, options = {}) {
|
|
1308
|
+
const page = params.page ?? 1;
|
|
1309
|
+
const perPage = params.perPage ?? 15;
|
|
1310
|
+
if (!Number.isInteger(page) || page < 1) {
|
|
1311
|
+
throw new InvalidArgumentError("page must be an integer >= 1.");
|
|
1312
|
+
}
|
|
1313
|
+
if (!Number.isInteger(perPage) || perPage < 1 || perPage > MAX_PER_PAGE) {
|
|
1314
|
+
throw new InvalidArgumentError(`perPage must be an integer between 1 and ${String(MAX_PER_PAGE)}.`);
|
|
1315
|
+
}
|
|
1316
|
+
const query = {
|
|
1317
|
+
status: params.status,
|
|
1318
|
+
recipient: params.recipient === void 0 ? void 0 : toApiNumberOrRaw(params.recipient, this.#defaultCountryCode),
|
|
1319
|
+
reference: params.reference,
|
|
1320
|
+
from: formatDate(params.from),
|
|
1321
|
+
to: formatDate(params.to),
|
|
1322
|
+
per_page: perPage
|
|
1323
|
+
};
|
|
1324
|
+
return new PagePromise(() => this.#fetchPage(query, page, options));
|
|
1325
|
+
}
|
|
1326
|
+
async #fetchPage(query, page, options) {
|
|
1327
|
+
const response = await this.#transport.request("GET", "/sms", "safe", { query: { ...query, page }, ...options });
|
|
1328
|
+
const meta = api.optionalObject(response.body, "meta");
|
|
1329
|
+
const data = api.objects(response.body, "data").map(toMessage);
|
|
1330
|
+
return new Page(
|
|
1331
|
+
{
|
|
1332
|
+
data,
|
|
1333
|
+
currentPage: api.nullableInt(meta, "current_page") ?? page,
|
|
1334
|
+
lastPage: api.nullableInt(meta, "last_page") ?? page,
|
|
1335
|
+
perPage: api.nullableInt(meta, "per_page") ?? data.length,
|
|
1336
|
+
total: api.nullableInt(meta, "total") ?? data.length
|
|
1337
|
+
},
|
|
1338
|
+
(next) => this.#fetchPage(query, next, options)
|
|
1339
|
+
);
|
|
1340
|
+
}
|
|
1341
|
+
async #dispatch(target, params, options) {
|
|
1342
|
+
assertBody(params.body);
|
|
1343
|
+
const sender = params.sender ?? this.#defaultSender;
|
|
1344
|
+
if (sender !== void 0 && (typeof sender !== "string" || sender === "" || characterCount(sender) > MAX_SENDER_LENGTH)) {
|
|
1345
|
+
throw new InvalidArgumentError(`sender must be 1 to ${String(MAX_SENDER_LENGTH)} characters.`);
|
|
1346
|
+
}
|
|
1347
|
+
const reference = params.reference;
|
|
1348
|
+
if (reference !== void 0 && (typeof reference !== "string" || characterCount(reference) > MAX_REFERENCE_LENGTH)) {
|
|
1349
|
+
throw new InvalidArgumentError(`reference must be at most ${String(MAX_REFERENCE_LENGTH)} characters.`);
|
|
1350
|
+
}
|
|
1351
|
+
const stripAccents = params.stripAccents;
|
|
1352
|
+
assertStripAccents(stripAccents);
|
|
1353
|
+
const idempotencyKey = await resolveIdempotencyKey(params.idempotencyKey);
|
|
1354
|
+
const body = { ...target, body: params.body };
|
|
1355
|
+
if (sender !== void 0) body["sender"] = sender;
|
|
1356
|
+
if (reference !== void 0 && reference !== "") body["reference"] = reference;
|
|
1357
|
+
if (stripAccents !== void 0) body["strip_accents"] = stripAccents;
|
|
1358
|
+
const response = await this.#transport.request("POST", "/sms", "safe", {
|
|
1359
|
+
body,
|
|
1360
|
+
...options,
|
|
1361
|
+
headers: { "Idempotency-Key": idempotencyKey }
|
|
1362
|
+
});
|
|
1363
|
+
return {
|
|
1364
|
+
messages: api.objects(response.body, "data").map(toMessage),
|
|
1365
|
+
idempotencyKey,
|
|
1366
|
+
replayed: response.headers["idempotent-replayed"] === "true",
|
|
1367
|
+
requestId: response.headers["x-request-id"] ?? null
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
toJSON() {
|
|
1371
|
+
return { transport: this.#transport.toJSON(), defaultCountryCode: this.#defaultCountryCode };
|
|
1372
|
+
}
|
|
1373
|
+
[/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
|
|
1374
|
+
return this.toJSON();
|
|
1375
|
+
}
|
|
1376
|
+
};
|
|
1377
|
+
function assertBody(body) {
|
|
1378
|
+
if (typeof body !== "string" || body === "") {
|
|
1379
|
+
throw new InvalidArgumentError("The SMS body cannot be empty.");
|
|
1380
|
+
}
|
|
1381
|
+
if (characterCount(body) > MAX_BODY_LENGTH) {
|
|
1382
|
+
throw new InvalidArgumentError(`The SMS body must be at most ${String(MAX_BODY_LENGTH)} characters.`);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
function formatDate(date) {
|
|
1386
|
+
if (date instanceof Date) {
|
|
1387
|
+
if (Number.isNaN(date.getTime())) {
|
|
1388
|
+
throw new InvalidArgumentError("Invalid date in the list filters.");
|
|
1389
|
+
}
|
|
1390
|
+
return date.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
1391
|
+
}
|
|
1392
|
+
return date;
|
|
1393
|
+
}
|
|
1394
|
+
function toApiNumberOrRaw(raw, defaultCountryCode) {
|
|
1395
|
+
const number = PhoneNumber.normalize(raw, defaultCountryCode);
|
|
1396
|
+
return number === null ? raw : toApiNumber(number);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// src/client.ts
|
|
1400
|
+
var DEFAULT_BASE_URL = "https://api.labyrinthe-rdc.com/api/public/v1";
|
|
1401
|
+
var API_PATH = "/api/public/v1";
|
|
1402
|
+
var INSPECT2 = /* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom");
|
|
1403
|
+
var DENO_INSPECT2 = /* @__PURE__ */ Symbol.for("Deno.customInspect");
|
|
1404
|
+
var Labyrinthe = class {
|
|
1405
|
+
/** Send and track SMS. */
|
|
1406
|
+
sms;
|
|
1407
|
+
/** Credit balance. */
|
|
1408
|
+
balance;
|
|
1409
|
+
/** Approved Sender IDs. */
|
|
1410
|
+
senderIds;
|
|
1411
|
+
/** One-time passwords by SMS. */
|
|
1412
|
+
otp;
|
|
1413
|
+
#apiKey;
|
|
1414
|
+
#baseUrl;
|
|
1415
|
+
constructor(options = {}) {
|
|
1416
|
+
const apiKey = (options.apiKey ?? env("LABYRINTHE_API_KEY") ?? "").trim();
|
|
1417
|
+
if (apiKey === "") {
|
|
1418
|
+
throw new InvalidArgumentError(
|
|
1419
|
+
"A Labyrinthe Connect API key is required (lsk_live_\u2026 or lsk_test_\u2026): pass `apiKey` or set LABYRINTHE_API_KEY."
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
if (isBrowser() && options.dangerouslyAllowBrowser !== true) {
|
|
1423
|
+
throw new InvalidArgumentError(
|
|
1424
|
+
"The Labyrinthe Connect client must run server-side: your API key would be exposed in the browser. Call your own backend instead (or set dangerouslyAllowBrowser: true if you really know what you are doing)."
|
|
1425
|
+
);
|
|
1426
|
+
}
|
|
1427
|
+
const timeoutMs = options.timeoutMs ?? 3e4;
|
|
1428
|
+
const maxRetries = options.maxRetries ?? 2;
|
|
1429
|
+
const maxRetryWaitMs = options.maxRetryWaitMs ?? 1e4;
|
|
1430
|
+
const defaultCountryCode = options.defaultCountryCode ?? DEFAULT_COUNTRY_CODE;
|
|
1431
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
1432
|
+
throw new InvalidArgumentError("timeoutMs must be greater than 0.");
|
|
1433
|
+
}
|
|
1434
|
+
if (!Number.isInteger(maxRetries) || maxRetries < 0 || maxRetries > 10) {
|
|
1435
|
+
throw new InvalidArgumentError("maxRetries must be an integer between 0 and 10.");
|
|
1436
|
+
}
|
|
1437
|
+
if (!Number.isFinite(maxRetryWaitMs) || maxRetryWaitMs < 0) {
|
|
1438
|
+
throw new InvalidArgumentError("maxRetryWaitMs cannot be negative.");
|
|
1439
|
+
}
|
|
1440
|
+
assertCountryCode(defaultCountryCode);
|
|
1441
|
+
const defaultSender = options.defaultSender === "" ? void 0 : options.defaultSender;
|
|
1442
|
+
this.#apiKey = apiKey;
|
|
1443
|
+
this.#baseUrl = normalizeBaseUrl(options.baseUrl ?? env("LABYRINTHE_BASE_URL") ?? DEFAULT_BASE_URL);
|
|
1444
|
+
const customFetch = options.fetch;
|
|
1445
|
+
const transport = new Transport({
|
|
1446
|
+
apiKey,
|
|
1447
|
+
baseUrl: this.#baseUrl,
|
|
1448
|
+
timeoutMs,
|
|
1449
|
+
maxRetries,
|
|
1450
|
+
maxRetryWaitMs,
|
|
1451
|
+
// Lu à chaque appel (et lié à globalThis : Workers refuse un fetch détaché).
|
|
1452
|
+
fetch: customFetch ?? ((url, init) => globalThis.fetch(url, init)),
|
|
1453
|
+
logger: options.logger,
|
|
1454
|
+
sleep: options.sleep ?? defaultSleep
|
|
1455
|
+
});
|
|
1456
|
+
this.sms = new Sms(transport, defaultCountryCode, defaultSender);
|
|
1457
|
+
this.balance = new BalanceResource(transport);
|
|
1458
|
+
this.senderIds = new SenderIds(transport);
|
|
1459
|
+
this.otp = new Otp(transport, defaultCountryCode);
|
|
1460
|
+
}
|
|
1461
|
+
/** True with a sandbox key (`lsk_test_…`): nothing is sent, no credit is used. */
|
|
1462
|
+
get isTestMode() {
|
|
1463
|
+
return this.#apiKey.startsWith("lsk_test_");
|
|
1464
|
+
}
|
|
1465
|
+
/** True with a production key (`lsk_live_…`). */
|
|
1466
|
+
get isLiveMode() {
|
|
1467
|
+
return this.#apiKey.startsWith("lsk_live_");
|
|
1468
|
+
}
|
|
1469
|
+
/** The API root used by this client. */
|
|
1470
|
+
get baseUrl() {
|
|
1471
|
+
return this.#baseUrl;
|
|
1472
|
+
}
|
|
1473
|
+
toJSON() {
|
|
1474
|
+
return { apiKey: redactApiKey(this.#apiKey), baseUrl: this.#baseUrl, testMode: this.isTestMode };
|
|
1475
|
+
}
|
|
1476
|
+
[INSPECT2]() {
|
|
1477
|
+
return this.toJSON();
|
|
1478
|
+
}
|
|
1479
|
+
[DENO_INSPECT2]() {
|
|
1480
|
+
return `Labyrinthe ${JSON.stringify(this.toJSON())}`;
|
|
1481
|
+
}
|
|
1482
|
+
};
|
|
1483
|
+
function normalizeBaseUrl(baseUrl) {
|
|
1484
|
+
const trimmed = baseUrl.trim().replace(/\/+$/, "");
|
|
1485
|
+
let url;
|
|
1486
|
+
try {
|
|
1487
|
+
url = new URL(trimmed);
|
|
1488
|
+
} catch {
|
|
1489
|
+
url = new URL("invalid:");
|
|
1490
|
+
}
|
|
1491
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
1492
|
+
throw new InvalidArgumentError(
|
|
1493
|
+
"baseUrl must be an absolute http(s) URL, e.g. https://api.labyrinthe-rdc.com/api/public/v1."
|
|
1494
|
+
);
|
|
1495
|
+
}
|
|
1496
|
+
return url.pathname === "/" || url.pathname === "" ? trimmed + API_PATH : trimmed;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// src/webhooks.ts
|
|
1500
|
+
var SIGNATURE_HEADER = "X-Signature";
|
|
1501
|
+
var DEFAULT_TOLERANCE_SECONDS = 300;
|
|
1502
|
+
var PREFIX = "sha256=";
|
|
1503
|
+
var encoder = new TextEncoder();
|
|
1504
|
+
var reader = new Reader("Invalid webhook payload");
|
|
1505
|
+
var Webhooks = Object.freeze({
|
|
1506
|
+
SIGNATURE_HEADER,
|
|
1507
|
+
DEFAULT_TOLERANCE_SECONDS,
|
|
1508
|
+
/**
|
|
1509
|
+
* Verifies a delivery and returns the typed event.
|
|
1510
|
+
*
|
|
1511
|
+
* @throws SignatureVerificationError wrong/missing signature or timestamp out of tolerance: answer 400
|
|
1512
|
+
* @throws InvalidWebhookPayloadError signed body that is not a valid envelope: answer 400
|
|
1513
|
+
*/
|
|
1514
|
+
async constructEvent(rawBody, signatureHeader, secret, options = {}) {
|
|
1515
|
+
const bytes = toBytes(rawBody);
|
|
1516
|
+
if (!await Webhooks.verifySignature(bytes, signatureHeader, secret)) {
|
|
1517
|
+
throw new SignatureVerificationError("Invalid webhook signature: the body was not signed with this secret.");
|
|
1518
|
+
}
|
|
1519
|
+
let envelope;
|
|
1520
|
+
try {
|
|
1521
|
+
envelope = JSON.parse(new TextDecoder().decode(bytes));
|
|
1522
|
+
} catch {
|
|
1523
|
+
throw new InvalidWebhookPayloadError("The webhook body is not valid JSON.");
|
|
1524
|
+
}
|
|
1525
|
+
if (!isRecord(envelope)) {
|
|
1526
|
+
throw new InvalidWebhookPayloadError("The webhook body is not a JSON object.");
|
|
1527
|
+
}
|
|
1528
|
+
const tolerance = options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
|
|
1529
|
+
if (!Number.isFinite(tolerance) || tolerance < 0) {
|
|
1530
|
+
throw new InvalidArgumentError("toleranceSeconds cannot be negative.");
|
|
1531
|
+
}
|
|
1532
|
+
try {
|
|
1533
|
+
const timestamp = reader.int(envelope, "timestamp");
|
|
1534
|
+
const nowMs = options.now === void 0 ? Date.now() : new Date(options.now).getTime();
|
|
1535
|
+
if (tolerance > 0 && Math.abs(nowMs / 1e3 - timestamp) > tolerance) {
|
|
1536
|
+
throw new SignatureVerificationError(
|
|
1537
|
+
`Webhook timestamp is outside the ${String(tolerance)} s tolerance (possible replay).`
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
return buildEvent(envelope, timestamp);
|
|
1541
|
+
} catch (error) {
|
|
1542
|
+
if (error instanceof UnexpectedResponseError) {
|
|
1543
|
+
throw new InvalidWebhookPayloadError(error.message);
|
|
1544
|
+
}
|
|
1545
|
+
throw error;
|
|
1546
|
+
}
|
|
1547
|
+
},
|
|
1548
|
+
/** Constant-time check of the X-Signature header against the raw body. */
|
|
1549
|
+
async verifySignature(rawBody, signatureHeader, secret) {
|
|
1550
|
+
const key = await importKey(secret);
|
|
1551
|
+
const signature = (signatureHeader ?? "").trim().toLowerCase();
|
|
1552
|
+
if (!signature.startsWith(PREFIX)) {
|
|
1553
|
+
return false;
|
|
1554
|
+
}
|
|
1555
|
+
const hex = signature.slice(PREFIX.length);
|
|
1556
|
+
if (!/^[0-9a-f]{64}$/.test(hex)) {
|
|
1557
|
+
return false;
|
|
1558
|
+
}
|
|
1559
|
+
const expected = new Uint8Array(32);
|
|
1560
|
+
for (let i = 0; i < 32; i++) {
|
|
1561
|
+
expected[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
1562
|
+
}
|
|
1563
|
+
const crypto = await getCrypto();
|
|
1564
|
+
return crypto.subtle.verify("HMAC", key, expected, toBytes(rawBody));
|
|
1565
|
+
},
|
|
1566
|
+
/** The X-Signature header value for a body: "sha256=<hex>". Useful to test your webhook endpoint. */
|
|
1567
|
+
async sign(rawBody, secret) {
|
|
1568
|
+
const key = await importKey(secret);
|
|
1569
|
+
const crypto = await getCrypto();
|
|
1570
|
+
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, toBytes(rawBody)));
|
|
1571
|
+
return PREFIX + Array.from(mac, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
1572
|
+
}
|
|
1573
|
+
});
|
|
1574
|
+
async function importKey(secret) {
|
|
1575
|
+
if (typeof secret !== "string" || secret === "") {
|
|
1576
|
+
throw new InvalidArgumentError("The webhook secret cannot be empty.");
|
|
1577
|
+
}
|
|
1578
|
+
const crypto = await getCrypto();
|
|
1579
|
+
return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
1580
|
+
"sign",
|
|
1581
|
+
"verify"
|
|
1582
|
+
]);
|
|
1583
|
+
}
|
|
1584
|
+
var OTP_STATUSES = ["verified", "failed", "expired"];
|
|
1585
|
+
function toOtpWebhookData(data) {
|
|
1586
|
+
const status = reader.string(data, "status");
|
|
1587
|
+
const reason = reader.nullableString(data, "reason");
|
|
1588
|
+
return {
|
|
1589
|
+
otpId: reader.string(data, "otp_id"),
|
|
1590
|
+
phone: reader.string(data, "phone"),
|
|
1591
|
+
status: OTP_STATUSES.includes(status) ? status : "unknown",
|
|
1592
|
+
reason: reason === null ? null : reason === "max_attempts_reached" || reason === "expired" ? reason : "unknown",
|
|
1593
|
+
verifiedAt: reader.nullableDate(data, "verified_at")
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
function toBytes(rawBody) {
|
|
1597
|
+
if (typeof rawBody === "string") {
|
|
1598
|
+
return encoder.encode(rawBody);
|
|
1599
|
+
}
|
|
1600
|
+
if (rawBody instanceof ArrayBuffer) {
|
|
1601
|
+
return new Uint8Array(rawBody);
|
|
1602
|
+
}
|
|
1603
|
+
if (ArrayBuffer.isView(rawBody)) {
|
|
1604
|
+
return new Uint8Array(rawBody);
|
|
1605
|
+
}
|
|
1606
|
+
throw new InvalidArgumentError(
|
|
1607
|
+
"The webhook body must be the RAW request body (string, Uint8Array/Buffer or ArrayBuffer), not a parsed object."
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
function buildEvent(envelope, timestamp) {
|
|
1611
|
+
const id = reader.string(envelope, "id");
|
|
1612
|
+
const event = reader.string(envelope, "event");
|
|
1613
|
+
const data = reader.optionalObject(envelope, "data");
|
|
1614
|
+
const base = { id, type: event, timestamp: new Date(timestamp * 1e3), raw: envelope };
|
|
1615
|
+
switch (event) {
|
|
1616
|
+
case "message.status_changed":
|
|
1617
|
+
return {
|
|
1618
|
+
...base,
|
|
1619
|
+
event,
|
|
1620
|
+
data: {
|
|
1621
|
+
messageId: reader.string(data, "message_id"),
|
|
1622
|
+
recipient: reader.string(data, "recipient"),
|
|
1623
|
+
status: messageStatus(reader.string(data, "status")),
|
|
1624
|
+
providerMessageId: reader.nullableString(data, "provider_message_id"),
|
|
1625
|
+
campaignId: reader.nullableString(data, "campaign_id"),
|
|
1626
|
+
// Spec 1.2.0 : absents d'un backend plus ancien → null.
|
|
1627
|
+
reference: reader.nullableString(data, "reference"),
|
|
1628
|
+
errorCode: reader.nullableString(data, "error_code"),
|
|
1629
|
+
errorMessage: reader.nullableString(data, "error_message")
|
|
1630
|
+
}
|
|
1631
|
+
};
|
|
1632
|
+
case "campaign.completed": {
|
|
1633
|
+
const counts = reader.optionalObject(data, "messages_by_status");
|
|
1634
|
+
const messagesByStatus = {};
|
|
1635
|
+
for (const status of Object.keys(counts)) {
|
|
1636
|
+
messagesByStatus[status] = reader.int(counts, status);
|
|
1637
|
+
}
|
|
1638
|
+
return {
|
|
1639
|
+
...base,
|
|
1640
|
+
event,
|
|
1641
|
+
data: {
|
|
1642
|
+
campaignId: reader.string(data, "campaign_id"),
|
|
1643
|
+
name: reader.nullableString(data, "name") ?? "",
|
|
1644
|
+
recipientsCount: reader.nullableInt(data, "recipients_count") ?? 0,
|
|
1645
|
+
messagesByStatus,
|
|
1646
|
+
creditsCharged: reader.nullableInt(data, "credits_charged") ?? 0,
|
|
1647
|
+
completedAt: reader.nullableDate(data, "completed_at")
|
|
1648
|
+
}
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
case "balance.low":
|
|
1652
|
+
return {
|
|
1653
|
+
...base,
|
|
1654
|
+
event,
|
|
1655
|
+
data: { balance: reader.int(data, "balance"), threshold: reader.int(data, "threshold") }
|
|
1656
|
+
};
|
|
1657
|
+
case "otp.verified":
|
|
1658
|
+
case "otp.failed":
|
|
1659
|
+
return { ...base, event, data: toOtpWebhookData(data) };
|
|
1660
|
+
case "test.ping":
|
|
1661
|
+
return { ...base, event, data };
|
|
1662
|
+
default:
|
|
1663
|
+
return { ...base, event: "unknown", data };
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
export { AccountSuspendedError, ApiError, AuthenticationError, BalanceResource, ConnectionError, DEFAULT_BASE_URL, DEFAULT_COUNTRY_CODE, DEFAULT_TOLERANCE_SECONDS, Encoding, FeatureDisabledError, IdempotencyKeyReusedError, IdempotencyKeyUnresolvedError, IdempotencyRequestInProgressError, InsufficientCreditsError, InvalidArgumentError, InvalidPhoneError, InvalidWebhookPayloadError, Labyrinthe, LabyrintheError, MessageStatus, NotFoundError, Otp, OtpCodeType, OtpFailureReason, OtpRateLimitScope, OtpRateLimitedError, OtpResendTooSoonError, OtpSendResult, OtpVerificationStatus, Page, PagePromise, PermissionError, PhoneNumber, RateLimitError, RecipientNotSendableError, RejectionReason, RequestAbortedError, SIGNATURE_HEADER, SenderIdNotApprovedError, SenderIdStatus, SenderIds, ServiceUnavailableError, SignatureVerificationError, Sms, TimeoutError, UnexpectedResponseError, VERSION, ValidationError, WebhookEventType, Webhooks, isFailedStatus, isFinalStatus, isPendingStatus };
|