@textbee/sdk 0.1.0 → 0.3.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/dist/index.js CHANGED
@@ -14,7 +14,7 @@ var TextbeeError = class extends Error {
14
14
 
15
15
  // src/client.ts
16
16
  var DEFAULT_BASE_URL = "https://api.textbee.dev/api/v1";
17
- var CLIENT_ID = `textbee-js/${"0.1.0"}`;
17
+ var CLIENT_ID = `textbee-js/${"0.3.0"}`;
18
18
  var Textbee = class {
19
19
  #apiKey;
20
20
  #baseUrl;
@@ -23,7 +23,7 @@ var Textbee = class {
23
23
  throw new TypeError("Textbee requires an apiKey");
24
24
  }
25
25
  this.#apiKey = options.apiKey;
26
- this.#baseUrl = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
26
+ this.#baseUrl = normalizeBaseUrl(options.baseUrl);
27
27
  }
28
28
  /** Send an SMS to one or more recipients. */
29
29
  async sendSms(request) {
@@ -94,9 +94,10 @@ var Textbee = class {
94
94
  }
95
95
  /**
96
96
  * Iterate every message matching the filters, following the pagination
97
- * cursor until the end. Use `order: 'asc'` with a `from` bound to drain
98
- * forward when polling; keep the last message's `createdAt` (or track
99
- * `meta.nextCursor` via `getMessages`) to resume the next poll.
97
+ * cursor until the end. `limit` is the page size, not a cap on the total.
98
+ * Use `order: 'asc'` with a `from` bound to drain forward when polling.
99
+ * `from` is inclusive, so a poll that resumes from the last `createdAt` sees
100
+ * that message again; skip ids you have already handled.
100
101
  *
101
102
  * ```ts
102
103
  * for await (const message of textbee.iterateMessages({ direction: 'received' })) {
@@ -120,7 +121,11 @@ var Textbee = class {
120
121
  );
121
122
  return payload.data;
122
123
  }
123
- /** Fetch a batch and every message in it. Pair this with `smsBatchId` from a send. */
124
+ /**
125
+ * Fetch a batch and every message in it, by the `smsBatchId` a send returns.
126
+ * Needs the id of the device that sent the batch. When textbee picked the
127
+ * device, use `getMessages({ smsBatchId })` instead.
128
+ */
124
129
  async getSmsBatch(deviceId, smsBatchId) {
125
130
  const payload = await this.#request(
126
131
  "GET",
@@ -156,6 +161,44 @@ var Textbee = class {
156
161
  return payload;
157
162
  }
158
163
  };
164
+ function normalizeBaseUrl(raw) {
165
+ const value = raw?.trim();
166
+ if (!value) {
167
+ return DEFAULT_BASE_URL;
168
+ }
169
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `https://${value}`;
170
+ let url;
171
+ try {
172
+ url = new URL(withScheme);
173
+ } catch {
174
+ throw new TypeError(
175
+ "baseUrl is not a valid URL. Use your textbee api, for example https://sms.example.com. The /api/v1 suffix is added for you."
176
+ );
177
+ }
178
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
179
+ throw new TypeError(`baseUrl must be http or https, got "${url.protocol}"`);
180
+ }
181
+ if (url.username || url.password) {
182
+ throw new TypeError("baseUrl must not contain a username or password");
183
+ }
184
+ const segments = url.pathname.split("/").filter(Boolean);
185
+ let cut = -1;
186
+ for (let i = segments.length - 2; i >= 0; i--) {
187
+ if (segments[i].toLowerCase() === "api" && segments[i + 1].toLowerCase() === "v1") {
188
+ cut = i + 2;
189
+ break;
190
+ }
191
+ }
192
+ let path;
193
+ if (segments.length === 0) {
194
+ path = ["api", "v1"];
195
+ } else if (cut === -1) {
196
+ path = segments;
197
+ } else {
198
+ path = [...segments.slice(0, cut - 2), "api", "v1"];
199
+ }
200
+ return [url.origin, ...path].join("/");
201
+ }
159
202
  function toIsoString(value) {
160
203
  return value instanceof Date ? value.toISOString() : value;
161
204
  }
@@ -173,16 +216,240 @@ async function readBody(response) {
173
216
  function errorMessage(payload, status) {
174
217
  if (payload && typeof payload === "object") {
175
218
  const record = payload;
176
- if (typeof record.error === "string" && record.error) {
177
- return record.error;
178
- }
179
- if (typeof record.message === "string" && record.message) {
180
- return record.message;
219
+ const keys = "statusCode" in record ? ["message", "error"] : ["error", "message"];
220
+ for (const key of keys) {
221
+ const value = record[key];
222
+ if (typeof value === "string" && value) {
223
+ return value;
224
+ }
225
+ if (Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === "string")) {
226
+ return value.join("; ");
227
+ }
181
228
  }
182
229
  }
183
230
  return `HTTP ${status}`;
184
231
  }
185
232
 
233
+ // src/sms-utils.ts
234
+ var GSM7_BASE = `@\xA3$\xA5\xE8\xE9\xF9\xEC\xF2\xC7
235
+ \xD8\xF8\r\xC5\xE5\u0394_\u03A6\u0393\u039B\u03A9\u03A0\u03A8\u03A3\u0398\u039E\xC6\xE6\xDF\xC9 !"#\xA4%&'()*+,-./0123456789:;<=>?\xA1ABCDEFGHIJKLMNOPQRSTUVWXYZ\xC4\xD6\xD1\xDC\xA7\xBFabcdefghijklmnopqrstuvwxyz\xE4\xF6\xF1\xFC\xE0`;
236
+ var GSM7_EXTENSION = "\f^{}\\[~]|\u20AC";
237
+ var GSM7_BASE_SET = new Set(GSM7_BASE);
238
+ var GSM7_EXTENSION_SET = new Set(GSM7_EXTENSION);
239
+ var GSM7_SINGLE_SEGMENT_LIMIT = 160;
240
+ var GSM7_CONCATENATED_SEGMENT_LIMIT = 153;
241
+ var UCS2_SINGLE_SEGMENT_LIMIT = 70;
242
+ var UCS2_CONCATENATED_SEGMENT_LIMIT = 67;
243
+ function gsm7Cost(char) {
244
+ if (GSM7_BASE_SET.has(char)) {
245
+ return 1;
246
+ }
247
+ if (GSM7_EXTENSION_SET.has(char)) {
248
+ return 2;
249
+ }
250
+ return void 0;
251
+ }
252
+ function isGsm7Char(char) {
253
+ return gsm7Cost(char) !== void 0;
254
+ }
255
+ function getSmsEncoding(message) {
256
+ for (const char of message) {
257
+ if (!isGsm7Char(char)) {
258
+ return "ucs-2";
259
+ }
260
+ }
261
+ return "gsm-7";
262
+ }
263
+ function countSmsSegments(message) {
264
+ const encoding = getSmsEncoding(message);
265
+ const costs = characterCosts(message, encoding);
266
+ const length = costs.reduce((total, cost) => total + cost, 0);
267
+ if (length === 0) {
268
+ return {
269
+ encoding,
270
+ length: 0,
271
+ segments: 0,
272
+ remainingInSegment: encoding === "gsm-7" ? GSM7_SINGLE_SEGMENT_LIMIT : UCS2_SINGLE_SEGMENT_LIMIT
273
+ };
274
+ }
275
+ const singleLimit = encoding === "gsm-7" ? GSM7_SINGLE_SEGMENT_LIMIT : UCS2_SINGLE_SEGMENT_LIMIT;
276
+ if (length <= singleLimit) {
277
+ return {
278
+ encoding,
279
+ length,
280
+ segments: 1,
281
+ remainingInSegment: singleLimit - length
282
+ };
283
+ }
284
+ const concatenatedLimit = encoding === "gsm-7" ? GSM7_CONCATENATED_SEGMENT_LIMIT : UCS2_CONCATENATED_SEGMENT_LIMIT;
285
+ let segments = 1;
286
+ let usedInSegment = 0;
287
+ for (const cost of costs) {
288
+ if (usedInSegment + cost > concatenatedLimit) {
289
+ segments += 1;
290
+ usedInSegment = cost;
291
+ } else {
292
+ usedInSegment += cost;
293
+ }
294
+ }
295
+ return {
296
+ encoding,
297
+ length,
298
+ segments,
299
+ remainingInSegment: concatenatedLimit - usedInSegment
300
+ };
301
+ }
302
+ function findNonGsm7Characters(message) {
303
+ const found = /* @__PURE__ */ new Set();
304
+ for (const char of message) {
305
+ if (!isGsm7Char(char)) {
306
+ found.add(char);
307
+ }
308
+ }
309
+ return Array.from(found);
310
+ }
311
+ function characterCosts(message, encoding) {
312
+ const costs = [];
313
+ for (const char of message) {
314
+ costs.push(encoding === "gsm-7" ? gsm7Cost(char) ?? 1 : char.length);
315
+ }
316
+ return costs;
317
+ }
318
+ var GSM7_REPLACEMENTS = {
319
+ // curly single quotes, prime, acute accent, backtick
320
+ "\u2018": "'",
321
+ "\u2019": "'",
322
+ "\u201A": "'",
323
+ "\u201B": "'",
324
+ "\u2032": "'",
325
+ "\xB4": "'",
326
+ "`": "'",
327
+ // curly double quotes, double prime, guillemets
328
+ "\u201C": '"',
329
+ "\u201D": '"',
330
+ "\u201E": '"',
331
+ "\u201F": '"',
332
+ "\u2033": '"',
333
+ "\xAB": '"',
334
+ "\xBB": '"',
335
+ // ellipsis
336
+ "\u2026": "...",
337
+ // hyphen variants, en dash, em dash, horizontal bar, minus sign
338
+ "\u2010": "-",
339
+ "\u2011": "-",
340
+ "\u2012": "-",
341
+ "\u2013": "-",
342
+ "\u2014": "-",
343
+ "\u2015": "-",
344
+ "\u2212": "-",
345
+ // non-breaking and typographic spaces
346
+ "\xA0": " ",
347
+ "\u2000": " ",
348
+ "\u2001": " ",
349
+ "\u2002": " ",
350
+ "\u2003": " ",
351
+ "\u2004": " ",
352
+ "\u2005": " ",
353
+ "\u2006": " ",
354
+ "\u2007": " ",
355
+ "\u2008": " ",
356
+ "\u2009": " ",
357
+ "\u200A": " ",
358
+ "\u202F": " ",
359
+ "\u205F": " ",
360
+ "\u3000": " ",
361
+ // invisible characters worth dropping outright
362
+ "\u200B": "",
363
+ "\uFEFF": "",
364
+ "\xAD": "",
365
+ // fraction slash
366
+ "\u2044": "/"
367
+ };
368
+ var TRANSLITERATION_EXTRAS = {
369
+ "\u0152": "OE",
370
+ "\u0153": "oe",
371
+ "\u0141": "L",
372
+ "\u0142": "l",
373
+ "\u0110": "D",
374
+ "\u0111": "d",
375
+ "\u0131": "i"
376
+ };
377
+ var COMBINING_MARKS = /[\u0300-\u036F]/g;
378
+ function sanitizeForGsm7(message, options = {}) {
379
+ const { transliterateAccents = false } = options;
380
+ let result = "";
381
+ for (const char of message) {
382
+ const replacement = GSM7_REPLACEMENTS[char];
383
+ if (replacement !== void 0) {
384
+ result += replacement;
385
+ continue;
386
+ }
387
+ if (isGsm7Char(char)) {
388
+ result += char;
389
+ continue;
390
+ }
391
+ result += transliterateAccents ? transliterate(char) : char;
392
+ }
393
+ return result;
394
+ }
395
+ function transliterate(char) {
396
+ const extra = TRANSLITERATION_EXTRAS[char];
397
+ if (extra !== void 0) {
398
+ return extra;
399
+ }
400
+ const stripped = char.normalize("NFD").replace(COMBINING_MARKS, "");
401
+ if (stripped.length === 1 && /[A-Za-z]/.test(stripped)) {
402
+ return stripped;
403
+ }
404
+ return char;
405
+ }
406
+ var E164_PATTERN = /^\+[1-9]\d{1,14}$/;
407
+ var FORMATTING_CHARACTERS = /[\s.\-()]/g;
408
+ var DIGITS_ONLY = /^\d+$/;
409
+ var COUNTRY_CODE_PATTERN = /^[1-9]\d{0,2}$/;
410
+ function isValidE164(input) {
411
+ return E164_PATTERN.test(input);
412
+ }
413
+ function normalizePhoneNumber(input, options = {}) {
414
+ const trimmed = input.trim();
415
+ if (!trimmed) {
416
+ return null;
417
+ }
418
+ const hasPlus = trimmed.startsWith("+");
419
+ const withoutPlus = hasPlus ? trimmed.slice(1) : trimmed;
420
+ const digits = withoutPlus.replace(FORMATTING_CHARACTERS, "");
421
+ if (!DIGITS_ONLY.test(digits)) {
422
+ return null;
423
+ }
424
+ if (hasPlus) {
425
+ return asE164OrNull(`+${digits}`);
426
+ }
427
+ if (digits.startsWith("00")) {
428
+ return asE164OrNull(`+${digits.slice(2)}`);
429
+ }
430
+ const countryCode = normalizeCountryCode(options.defaultCountryCode);
431
+ if (countryCode === void 0) {
432
+ return null;
433
+ }
434
+ const national = digits.startsWith("0") ? digits.slice(1) : digits;
435
+ return asE164OrNull(`+${countryCode}${national}`);
436
+ }
437
+ function asE164OrNull(candidate) {
438
+ return isValidE164(candidate) ? candidate : null;
439
+ }
440
+ function normalizeCountryCode(value) {
441
+ if (value === void 0) {
442
+ return void 0;
443
+ }
444
+ const code = value.startsWith("+") ? value.slice(1) : value;
445
+ if (!COUNTRY_CODE_PATTERN.test(code)) {
446
+ throw new TypeError(
447
+ `defaultCountryCode must be 1 to 3 digits starting with 1-9, got ${JSON.stringify(value)}`
448
+ );
449
+ }
450
+ return code;
451
+ }
452
+
186
453
  // src/webhooks.ts
187
454
  async function verifyWebhookSignature(options) {
188
455
  const { payload, signature, signingSecret } = options;
@@ -218,5 +485,11 @@ function constantTimeEqual(a, b) {
218
485
  export {
219
486
  Textbee,
220
487
  TextbeeError,
488
+ countSmsSegments,
489
+ findNonGsm7Characters,
490
+ getSmsEncoding,
491
+ isValidE164,
492
+ normalizePhoneNumber,
493
+ sanitizeForGsm7,
221
494
  verifyWebhookSignature
222
495
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@textbee/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Official JavaScript SDK for textbee.dev, the open source SMS gateway",
5
5
  "license": "MIT",
6
6
  "packageManager": "pnpm@9.14.2",
@@ -44,7 +44,10 @@
44
44
  "sms",
45
45
  "sms-gateway",
46
46
  "textbee",
47
- "android"
47
+ "android",
48
+ "sms-segments",
49
+ "gsm-7",
50
+ "e164"
48
51
  ],
49
52
  "scripts": {
50
53
  "build": "tsup",