@textbee/sdk 0.1.0 → 0.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/README.md CHANGED
@@ -28,7 +28,7 @@ import { Textbee } from '@textbee/sdk'
28
28
  const textbee = new Textbee({ apiKey: process.env.TEXTBEE_API_KEY })
29
29
 
30
30
  await textbee.sendSms({
31
- recipients: ['+251912345678'],
31
+ recipients: ['+12025550123'],
32
32
  message: 'Hello from textbee!',
33
33
  })
34
34
  ```
@@ -45,7 +45,7 @@ await textbee.sendSms({
45
45
 
46
46
  ```js
47
47
  await textbee.sendSms({
48
- recipients: ['+251912345678'],
48
+ recipients: ['+12025550123'],
49
49
  message: 'Your appointment is tomorrow at 9am',
50
50
  deviceId: '65f0000000000000000000aa',
51
51
  simSubscriptionId: 2,
@@ -127,6 +127,65 @@ app.post('/webhooks/textbee', express.raw({ type: 'application/json' }), async (
127
127
  })
128
128
  ```
129
129
 
130
+ ## SMS utilities
131
+
132
+ Pure helpers for working with SMS text and phone numbers. No API key, no network calls, and they are useful with any SMS provider, not just textbee. Import only what you need and the rest is tree-shaken away.
133
+
134
+ ### Segments and encoding
135
+
136
+ Carriers bill per segment, not per message. A message stays in the 7-bit GSM alphabet at 160 characters per segment, but a single character outside that alphabet, one emoji or one curly quote, switches the whole message to UCS-2 and drops the limit to 70.
137
+
138
+ ```js
139
+ import { countSmsSegments, getSmsEncoding, findNonGsm7Characters } from '@textbee/sdk'
140
+
141
+ countSmsSegments('Your code is 123456')
142
+ // { encoding: 'gsm-7', length: 19, segments: 1, remainingInSegment: 141 }
143
+
144
+ countSmsSegments('Your code is 123456 🎉')
145
+ // { encoding: 'ucs-2', length: 22, segments: 1, remainingInSegment: 48 }
146
+
147
+ getSmsEncoding('plain ascii') // 'gsm-7'
148
+ findNonGsm7Characters('Hi 🎉') // ['🎉']
149
+ ```
150
+
151
+ Longer messages are split, and concatenation headers shrink each segment to 153 characters (GSM-7) or 67 (UCS-2). `remainingInSegment` counts single-unit characters, so a two-unit character such as an emoji or `€` may not fit even when it reads as 1.
152
+
153
+ ### Keeping messages in GSM-7
154
+
155
+ Text pasted from a word processor or a CMS is full of curly quotes, ellipses, and non-breaking spaces. `sanitizeForGsm7` swaps them for plain equivalents so a message does not silently cost three times as much.
156
+
157
+ ```js
158
+ import { sanitizeForGsm7, countSmsSegments } from '@textbee/sdk'
159
+
160
+ const pasted = '“Your order shipped…”'
161
+ countSmsSegments(pasted).encoding // 'ucs-2'
162
+
163
+ const clean = sanitizeForGsm7(pasted) // '"Your order shipped..."'
164
+ countSmsSegments(clean).encoding // 'gsm-7'
165
+
166
+ // Optionally strip accents that GSM-7 does not carry. Letters it does carry,
167
+ // like é, ü, and ñ, are always left alone.
168
+ sanitizeForGsm7('naïve', { transliterateAccents: true }) // 'naive'
169
+ ```
170
+
171
+ It is best effort: characters with no safe equivalent pass through untouched. Check the result with `getSmsEncoding` and see what is left with `findNonGsm7Characters`.
172
+
173
+ ### Phone number helpers
174
+
175
+ ```js
176
+ import { isValidE164, normalizePhoneNumber } from '@textbee/sdk'
177
+
178
+ isValidE164('+12025550123') // true
179
+ isValidE164('202-555-0123') // false
180
+
181
+ normalizePhoneNumber('+1 (202) 555-0123') // '+12025550123'
182
+ normalizePhoneNumber('0012025550123') // '+12025550123'
183
+ normalizePhoneNumber('(202) 555-0123', { defaultCountryCode: '1' }) // '+12025550123'
184
+ normalizePhoneNumber('not a number') // null
185
+ ```
186
+
187
+ These are format-only helpers, not [libphonenumber](https://github.com/google/libphonenumber). They know nothing about country dialing plans, so a well-formed but unassigned number still passes. Input that cannot be normalized returns `null`; an unusable `defaultCountryCode` throws a `TypeError`.
188
+
130
189
  ## Errors
131
190
 
132
191
  Any non-2xx response throws a `TextbeeError` carrying the status and the parsed body. Network failures reject with the underlying `fetch` error instead.
@@ -135,7 +194,7 @@ Any non-2xx response throws a `TextbeeError` carrying the status and the parsed
135
194
  import { TextbeeError } from '@textbee/sdk'
136
195
 
137
196
  try {
138
- await textbee.sendSms({ recipients: ['+251912345678'], message: 'hi' })
197
+ await textbee.sendSms({ recipients: ['+12025550123'], message: 'hi' })
139
198
  } catch (error) {
140
199
  if (error instanceof TextbeeError) {
141
200
  console.error(error.status, error.message)
package/dist/index.cjs CHANGED
@@ -22,6 +22,12 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  Textbee: () => Textbee,
24
24
  TextbeeError: () => TextbeeError,
25
+ countSmsSegments: () => countSmsSegments,
26
+ findNonGsm7Characters: () => findNonGsm7Characters,
27
+ getSmsEncoding: () => getSmsEncoding,
28
+ isValidE164: () => isValidE164,
29
+ normalizePhoneNumber: () => normalizePhoneNumber,
30
+ sanitizeForGsm7: () => sanitizeForGsm7,
25
31
  verifyWebhookSignature: () => verifyWebhookSignature
26
32
  });
27
33
  module.exports = __toCommonJS(index_exports);
@@ -42,7 +48,7 @@ var TextbeeError = class extends Error {
42
48
 
43
49
  // src/client.ts
44
50
  var DEFAULT_BASE_URL = "https://api.textbee.dev/api/v1";
45
- var CLIENT_ID = `textbee-js/${"0.1.0"}`;
51
+ var CLIENT_ID = `textbee-js/${"0.2.0"}`;
46
52
  var Textbee = class {
47
53
  #apiKey;
48
54
  #baseUrl;
@@ -211,6 +217,226 @@ function errorMessage(payload, status) {
211
217
  return `HTTP ${status}`;
212
218
  }
213
219
 
220
+ // src/sms-utils.ts
221
+ var GSM7_BASE = `@\xA3$\xA5\xE8\xE9\xF9\xEC\xF2\xC7
222
+ \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`;
223
+ var GSM7_EXTENSION = "\f^{}\\[~]|\u20AC";
224
+ var GSM7_BASE_SET = new Set(GSM7_BASE);
225
+ var GSM7_EXTENSION_SET = new Set(GSM7_EXTENSION);
226
+ var GSM7_SINGLE_SEGMENT_LIMIT = 160;
227
+ var GSM7_CONCATENATED_SEGMENT_LIMIT = 153;
228
+ var UCS2_SINGLE_SEGMENT_LIMIT = 70;
229
+ var UCS2_CONCATENATED_SEGMENT_LIMIT = 67;
230
+ function gsm7Cost(char) {
231
+ if (GSM7_BASE_SET.has(char)) {
232
+ return 1;
233
+ }
234
+ if (GSM7_EXTENSION_SET.has(char)) {
235
+ return 2;
236
+ }
237
+ return void 0;
238
+ }
239
+ function isGsm7Char(char) {
240
+ return gsm7Cost(char) !== void 0;
241
+ }
242
+ function getSmsEncoding(message) {
243
+ for (const char of message) {
244
+ if (!isGsm7Char(char)) {
245
+ return "ucs-2";
246
+ }
247
+ }
248
+ return "gsm-7";
249
+ }
250
+ function countSmsSegments(message) {
251
+ const encoding = getSmsEncoding(message);
252
+ const costs = characterCosts(message, encoding);
253
+ const length = costs.reduce((total, cost) => total + cost, 0);
254
+ if (length === 0) {
255
+ return {
256
+ encoding,
257
+ length: 0,
258
+ segments: 0,
259
+ remainingInSegment: encoding === "gsm-7" ? GSM7_SINGLE_SEGMENT_LIMIT : UCS2_SINGLE_SEGMENT_LIMIT
260
+ };
261
+ }
262
+ const singleLimit = encoding === "gsm-7" ? GSM7_SINGLE_SEGMENT_LIMIT : UCS2_SINGLE_SEGMENT_LIMIT;
263
+ if (length <= singleLimit) {
264
+ return {
265
+ encoding,
266
+ length,
267
+ segments: 1,
268
+ remainingInSegment: singleLimit - length
269
+ };
270
+ }
271
+ const concatenatedLimit = encoding === "gsm-7" ? GSM7_CONCATENATED_SEGMENT_LIMIT : UCS2_CONCATENATED_SEGMENT_LIMIT;
272
+ let segments = 1;
273
+ let usedInSegment = 0;
274
+ for (const cost of costs) {
275
+ if (usedInSegment + cost > concatenatedLimit) {
276
+ segments += 1;
277
+ usedInSegment = cost;
278
+ } else {
279
+ usedInSegment += cost;
280
+ }
281
+ }
282
+ return {
283
+ encoding,
284
+ length,
285
+ segments,
286
+ remainingInSegment: concatenatedLimit - usedInSegment
287
+ };
288
+ }
289
+ function findNonGsm7Characters(message) {
290
+ const found = /* @__PURE__ */ new Set();
291
+ for (const char of message) {
292
+ if (!isGsm7Char(char)) {
293
+ found.add(char);
294
+ }
295
+ }
296
+ return Array.from(found);
297
+ }
298
+ function characterCosts(message, encoding) {
299
+ const costs = [];
300
+ for (const char of message) {
301
+ costs.push(encoding === "gsm-7" ? gsm7Cost(char) ?? 1 : char.length);
302
+ }
303
+ return costs;
304
+ }
305
+ var GSM7_REPLACEMENTS = {
306
+ // curly single quotes, prime, acute accent, backtick
307
+ "\u2018": "'",
308
+ "\u2019": "'",
309
+ "\u201A": "'",
310
+ "\u201B": "'",
311
+ "\u2032": "'",
312
+ "\xB4": "'",
313
+ "`": "'",
314
+ // curly double quotes, double prime, guillemets
315
+ "\u201C": '"',
316
+ "\u201D": '"',
317
+ "\u201E": '"',
318
+ "\u201F": '"',
319
+ "\u2033": '"',
320
+ "\xAB": '"',
321
+ "\xBB": '"',
322
+ // ellipsis
323
+ "\u2026": "...",
324
+ // hyphen variants, en dash, em dash, horizontal bar, minus sign
325
+ "\u2010": "-",
326
+ "\u2011": "-",
327
+ "\u2012": "-",
328
+ "\u2013": "-",
329
+ "\u2014": "-",
330
+ "\u2015": "-",
331
+ "\u2212": "-",
332
+ // non-breaking and typographic spaces
333
+ "\xA0": " ",
334
+ "\u2000": " ",
335
+ "\u2001": " ",
336
+ "\u2002": " ",
337
+ "\u2003": " ",
338
+ "\u2004": " ",
339
+ "\u2005": " ",
340
+ "\u2006": " ",
341
+ "\u2007": " ",
342
+ "\u2008": " ",
343
+ "\u2009": " ",
344
+ "\u200A": " ",
345
+ "\u202F": " ",
346
+ "\u205F": " ",
347
+ "\u3000": " ",
348
+ // invisible characters worth dropping outright
349
+ "\u200B": "",
350
+ "\uFEFF": "",
351
+ "\xAD": "",
352
+ // fraction slash
353
+ "\u2044": "/"
354
+ };
355
+ var TRANSLITERATION_EXTRAS = {
356
+ "\u0152": "OE",
357
+ "\u0153": "oe",
358
+ "\u0141": "L",
359
+ "\u0142": "l",
360
+ "\u0110": "D",
361
+ "\u0111": "d",
362
+ "\u0131": "i"
363
+ };
364
+ var COMBINING_MARKS = /[\u0300-\u036F]/g;
365
+ function sanitizeForGsm7(message, options = {}) {
366
+ const { transliterateAccents = false } = options;
367
+ let result = "";
368
+ for (const char of message) {
369
+ const replacement = GSM7_REPLACEMENTS[char];
370
+ if (replacement !== void 0) {
371
+ result += replacement;
372
+ continue;
373
+ }
374
+ if (isGsm7Char(char)) {
375
+ result += char;
376
+ continue;
377
+ }
378
+ result += transliterateAccents ? transliterate(char) : char;
379
+ }
380
+ return result;
381
+ }
382
+ function transliterate(char) {
383
+ const extra = TRANSLITERATION_EXTRAS[char];
384
+ if (extra !== void 0) {
385
+ return extra;
386
+ }
387
+ const stripped = char.normalize("NFD").replace(COMBINING_MARKS, "");
388
+ if (stripped.length === 1 && /[A-Za-z]/.test(stripped)) {
389
+ return stripped;
390
+ }
391
+ return char;
392
+ }
393
+ var E164_PATTERN = /^\+[1-9]\d{1,14}$/;
394
+ var FORMATTING_CHARACTERS = /[\s.\-()]/g;
395
+ var DIGITS_ONLY = /^\d+$/;
396
+ var COUNTRY_CODE_PATTERN = /^[1-9]\d{0,2}$/;
397
+ function isValidE164(input) {
398
+ return E164_PATTERN.test(input);
399
+ }
400
+ function normalizePhoneNumber(input, options = {}) {
401
+ const trimmed = input.trim();
402
+ if (!trimmed) {
403
+ return null;
404
+ }
405
+ const hasPlus = trimmed.startsWith("+");
406
+ const withoutPlus = hasPlus ? trimmed.slice(1) : trimmed;
407
+ const digits = withoutPlus.replace(FORMATTING_CHARACTERS, "");
408
+ if (!DIGITS_ONLY.test(digits)) {
409
+ return null;
410
+ }
411
+ if (hasPlus) {
412
+ return asE164OrNull(`+${digits}`);
413
+ }
414
+ if (digits.startsWith("00")) {
415
+ return asE164OrNull(`+${digits.slice(2)}`);
416
+ }
417
+ const countryCode = normalizeCountryCode(options.defaultCountryCode);
418
+ if (countryCode === void 0) {
419
+ return null;
420
+ }
421
+ const national = digits.startsWith("0") ? digits.slice(1) : digits;
422
+ return asE164OrNull(`+${countryCode}${national}`);
423
+ }
424
+ function asE164OrNull(candidate) {
425
+ return isValidE164(candidate) ? candidate : null;
426
+ }
427
+ function normalizeCountryCode(value) {
428
+ if (value === void 0) {
429
+ return void 0;
430
+ }
431
+ const code = value.startsWith("+") ? value.slice(1) : value;
432
+ if (!COUNTRY_CODE_PATTERN.test(code)) {
433
+ throw new TypeError(
434
+ `defaultCountryCode must be 1 to 3 digits starting with 1-9, got ${JSON.stringify(value)}`
435
+ );
436
+ }
437
+ return code;
438
+ }
439
+
214
440
  // src/webhooks.ts
215
441
  async function verifyWebhookSignature(options) {
216
442
  const { payload, signature, signingSecret } = options;
@@ -247,5 +473,11 @@ function constantTimeEqual(a, b) {
247
473
  0 && (module.exports = {
248
474
  Textbee,
249
475
  TextbeeError,
476
+ countSmsSegments,
477
+ findNonGsm7Characters,
478
+ getSmsEncoding,
479
+ isValidE164,
480
+ normalizePhoneNumber,
481
+ sanitizeForGsm7,
250
482
  verifyWebhookSignature
251
483
  });
package/dist/index.d.cts CHANGED
@@ -234,6 +234,97 @@ declare class TextbeeError extends Error {
234
234
  constructor(status: number, message: string, body?: unknown);
235
235
  }
236
236
 
237
+ /**
238
+ * Pure SMS helpers. No API key, no network, no dependencies. They work with
239
+ * any SMS provider, not just textbee.
240
+ */
241
+ /** How the message body will be encoded on the wire. */
242
+ type SmsEncoding = 'gsm-7' | 'ucs-2';
243
+ interface SmsSegmentInfo {
244
+ /** `gsm-7` when every character fits the GSM 03.38 alphabet, else `ucs-2`. */
245
+ encoding: SmsEncoding;
246
+ /**
247
+ * Encoded length: septets for gsm-7 (extension characters cost 2), UTF-16
248
+ * code units for ucs-2 (emoji and other astral characters cost 2).
249
+ */
250
+ length: number;
251
+ /** Number of SMS segments the message occupies. 0 for an empty string. */
252
+ segments: number;
253
+ /**
254
+ * How many more single-unit characters fit before another segment is needed.
255
+ * A two-unit character (extension character or emoji) may not fit even when
256
+ * this is 1.
257
+ */
258
+ remainingInSegment: number;
259
+ }
260
+ /**
261
+ * Detect the encoding a message requires: `gsm-7` when every character is in
262
+ * the GSM 03.38 alphabet or its extension table, `ucs-2` otherwise. An empty
263
+ * string reports `gsm-7`.
264
+ */
265
+ declare function getSmsEncoding(message: string): SmsEncoding;
266
+ /**
267
+ * Compute encoding, encoded length, segment count, and remaining room the way
268
+ * a standards-following handset splits a message.
269
+ *
270
+ * Single segment limits are 160 septets (gsm-7) and 70 code units (ucs-2).
271
+ * Concatenated messages carry a header, shrinking each segment to 153 and 67.
272
+ * A two-unit character never straddles a boundary: it moves whole to the next
273
+ * segment and wastes one unit.
274
+ */
275
+ declare function countSmsSegments(message: string): SmsSegmentInfo;
276
+ /**
277
+ * The unique characters forcing a message into ucs-2, in first appearance
278
+ * order. Empty when the message is already gsm-7 safe. Pair it with
279
+ * {@link sanitizeForGsm7} to see what is left after sanitizing.
280
+ */
281
+ declare function findNonGsm7Characters(message: string): string[];
282
+ interface SanitizeForGsm7Options {
283
+ /**
284
+ * Strip accents from Latin letters that GSM-7 does not carry, so `ï` becomes
285
+ * `i`. Letters GSM-7 already has (é, ü, ñ, à and friends) are never touched.
286
+ * Defaults to false.
287
+ */
288
+ transliterateAccents?: boolean;
289
+ }
290
+ /**
291
+ * Replace common Unicode lookalikes (curly quotes, ellipsis, exotic spaces and
292
+ * dashes) with GSM-7 equivalents, so pasted text does not silently fall into
293
+ * ucs-2 and more than double its segment count.
294
+ *
295
+ * Best effort by design: characters with no safe replacement are left alone.
296
+ * Check the result with {@link getSmsEncoding} and inspect leftovers with
297
+ * {@link findNonGsm7Characters}.
298
+ */
299
+ declare function sanitizeForGsm7(message: string, options?: SanitizeForGsm7Options): string;
300
+ /**
301
+ * Check that a string is strictly E.164: a plus sign, a leading digit 1-9, then
302
+ * 1 to 14 more digits, nothing else.
303
+ *
304
+ * This is a format check only. It does not know country dialing plans, so a
305
+ * well-formed but unassigned number still passes.
306
+ */
307
+ declare function isValidE164(input: string): boolean;
308
+ interface NormalizePhoneNumberOptions {
309
+ /**
310
+ * Country calling code applied to national-format input, with or without a
311
+ * leading plus: `1` and `+1` both work. With it, `(202) 555-0123` becomes
312
+ * `+12025550123` and one leading trunk zero is dropped. Without it,
313
+ * national-format input returns null.
314
+ */
315
+ defaultCountryCode?: string;
316
+ }
317
+ /**
318
+ * Normalize free-form phone input to E.164, or return null when it cannot be
319
+ * done safely. Strips spaces, dots, hyphens and parentheses, converts a leading
320
+ * `00` to a plus, and optionally applies a default country code.
321
+ *
322
+ * This is a formatter, not libphonenumber: it never validates a number against
323
+ * a country dialing plan. Bad input returns null, a bad
324
+ * {@link NormalizePhoneNumberOptions.defaultCountryCode} throws.
325
+ */
326
+ declare function normalizePhoneNumber(input: string, options?: NormalizePhoneNumberOptions): string | null;
327
+
237
328
  interface VerifyWebhookSignatureOptions {
238
329
  /**
239
330
  * The webhook request body. Prefer the raw body string.
@@ -256,4 +347,4 @@ interface VerifyWebhookSignatureOptions {
256
347
  */
257
348
  declare function verifyWebhookSignature(options: VerifyWebhookSignatureOptions): Promise<boolean>;
258
349
 
259
- export { type Device, type IterateMessagesOptions, type ListMessagesOptions, type Message, type MessageDevice, type MessageList, type MessageListMeta, type SendSmsImmediateResponse, type SendSmsQueuedResponse, type SendSmsRequest, type SendSmsResponse, type SmsBatch, type SmsBatchResult, Textbee, TextbeeError, type TextbeeOptions, type VerifyWebhookSignatureOptions, type WebhookEvent, verifyWebhookSignature };
350
+ export { type Device, type IterateMessagesOptions, type ListMessagesOptions, type Message, type MessageDevice, type MessageList, type MessageListMeta, type NormalizePhoneNumberOptions, type SanitizeForGsm7Options, type SendSmsImmediateResponse, type SendSmsQueuedResponse, type SendSmsRequest, type SendSmsResponse, type SmsBatch, type SmsBatchResult, type SmsEncoding, type SmsSegmentInfo, Textbee, TextbeeError, type TextbeeOptions, type VerifyWebhookSignatureOptions, type WebhookEvent, countSmsSegments, findNonGsm7Characters, getSmsEncoding, isValidE164, normalizePhoneNumber, sanitizeForGsm7, verifyWebhookSignature };
package/dist/index.d.ts CHANGED
@@ -234,6 +234,97 @@ declare class TextbeeError extends Error {
234
234
  constructor(status: number, message: string, body?: unknown);
235
235
  }
236
236
 
237
+ /**
238
+ * Pure SMS helpers. No API key, no network, no dependencies. They work with
239
+ * any SMS provider, not just textbee.
240
+ */
241
+ /** How the message body will be encoded on the wire. */
242
+ type SmsEncoding = 'gsm-7' | 'ucs-2';
243
+ interface SmsSegmentInfo {
244
+ /** `gsm-7` when every character fits the GSM 03.38 alphabet, else `ucs-2`. */
245
+ encoding: SmsEncoding;
246
+ /**
247
+ * Encoded length: septets for gsm-7 (extension characters cost 2), UTF-16
248
+ * code units for ucs-2 (emoji and other astral characters cost 2).
249
+ */
250
+ length: number;
251
+ /** Number of SMS segments the message occupies. 0 for an empty string. */
252
+ segments: number;
253
+ /**
254
+ * How many more single-unit characters fit before another segment is needed.
255
+ * A two-unit character (extension character or emoji) may not fit even when
256
+ * this is 1.
257
+ */
258
+ remainingInSegment: number;
259
+ }
260
+ /**
261
+ * Detect the encoding a message requires: `gsm-7` when every character is in
262
+ * the GSM 03.38 alphabet or its extension table, `ucs-2` otherwise. An empty
263
+ * string reports `gsm-7`.
264
+ */
265
+ declare function getSmsEncoding(message: string): SmsEncoding;
266
+ /**
267
+ * Compute encoding, encoded length, segment count, and remaining room the way
268
+ * a standards-following handset splits a message.
269
+ *
270
+ * Single segment limits are 160 septets (gsm-7) and 70 code units (ucs-2).
271
+ * Concatenated messages carry a header, shrinking each segment to 153 and 67.
272
+ * A two-unit character never straddles a boundary: it moves whole to the next
273
+ * segment and wastes one unit.
274
+ */
275
+ declare function countSmsSegments(message: string): SmsSegmentInfo;
276
+ /**
277
+ * The unique characters forcing a message into ucs-2, in first appearance
278
+ * order. Empty when the message is already gsm-7 safe. Pair it with
279
+ * {@link sanitizeForGsm7} to see what is left after sanitizing.
280
+ */
281
+ declare function findNonGsm7Characters(message: string): string[];
282
+ interface SanitizeForGsm7Options {
283
+ /**
284
+ * Strip accents from Latin letters that GSM-7 does not carry, so `ï` becomes
285
+ * `i`. Letters GSM-7 already has (é, ü, ñ, à and friends) are never touched.
286
+ * Defaults to false.
287
+ */
288
+ transliterateAccents?: boolean;
289
+ }
290
+ /**
291
+ * Replace common Unicode lookalikes (curly quotes, ellipsis, exotic spaces and
292
+ * dashes) with GSM-7 equivalents, so pasted text does not silently fall into
293
+ * ucs-2 and more than double its segment count.
294
+ *
295
+ * Best effort by design: characters with no safe replacement are left alone.
296
+ * Check the result with {@link getSmsEncoding} and inspect leftovers with
297
+ * {@link findNonGsm7Characters}.
298
+ */
299
+ declare function sanitizeForGsm7(message: string, options?: SanitizeForGsm7Options): string;
300
+ /**
301
+ * Check that a string is strictly E.164: a plus sign, a leading digit 1-9, then
302
+ * 1 to 14 more digits, nothing else.
303
+ *
304
+ * This is a format check only. It does not know country dialing plans, so a
305
+ * well-formed but unassigned number still passes.
306
+ */
307
+ declare function isValidE164(input: string): boolean;
308
+ interface NormalizePhoneNumberOptions {
309
+ /**
310
+ * Country calling code applied to national-format input, with or without a
311
+ * leading plus: `1` and `+1` both work. With it, `(202) 555-0123` becomes
312
+ * `+12025550123` and one leading trunk zero is dropped. Without it,
313
+ * national-format input returns null.
314
+ */
315
+ defaultCountryCode?: string;
316
+ }
317
+ /**
318
+ * Normalize free-form phone input to E.164, or return null when it cannot be
319
+ * done safely. Strips spaces, dots, hyphens and parentheses, converts a leading
320
+ * `00` to a plus, and optionally applies a default country code.
321
+ *
322
+ * This is a formatter, not libphonenumber: it never validates a number against
323
+ * a country dialing plan. Bad input returns null, a bad
324
+ * {@link NormalizePhoneNumberOptions.defaultCountryCode} throws.
325
+ */
326
+ declare function normalizePhoneNumber(input: string, options?: NormalizePhoneNumberOptions): string | null;
327
+
237
328
  interface VerifyWebhookSignatureOptions {
238
329
  /**
239
330
  * The webhook request body. Prefer the raw body string.
@@ -256,4 +347,4 @@ interface VerifyWebhookSignatureOptions {
256
347
  */
257
348
  declare function verifyWebhookSignature(options: VerifyWebhookSignatureOptions): Promise<boolean>;
258
349
 
259
- export { type Device, type IterateMessagesOptions, type ListMessagesOptions, type Message, type MessageDevice, type MessageList, type MessageListMeta, type SendSmsImmediateResponse, type SendSmsQueuedResponse, type SendSmsRequest, type SendSmsResponse, type SmsBatch, type SmsBatchResult, Textbee, TextbeeError, type TextbeeOptions, type VerifyWebhookSignatureOptions, type WebhookEvent, verifyWebhookSignature };
350
+ export { type Device, type IterateMessagesOptions, type ListMessagesOptions, type Message, type MessageDevice, type MessageList, type MessageListMeta, type NormalizePhoneNumberOptions, type SanitizeForGsm7Options, type SendSmsImmediateResponse, type SendSmsQueuedResponse, type SendSmsRequest, type SendSmsResponse, type SmsBatch, type SmsBatchResult, type SmsEncoding, type SmsSegmentInfo, Textbee, TextbeeError, type TextbeeOptions, type VerifyWebhookSignatureOptions, type WebhookEvent, countSmsSegments, findNonGsm7Characters, getSmsEncoding, isValidE164, normalizePhoneNumber, sanitizeForGsm7, verifyWebhookSignature };
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.2.0"}`;
18
18
  var Textbee = class {
19
19
  #apiKey;
20
20
  #baseUrl;
@@ -183,6 +183,226 @@ function errorMessage(payload, status) {
183
183
  return `HTTP ${status}`;
184
184
  }
185
185
 
186
+ // src/sms-utils.ts
187
+ var GSM7_BASE = `@\xA3$\xA5\xE8\xE9\xF9\xEC\xF2\xC7
188
+ \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`;
189
+ var GSM7_EXTENSION = "\f^{}\\[~]|\u20AC";
190
+ var GSM7_BASE_SET = new Set(GSM7_BASE);
191
+ var GSM7_EXTENSION_SET = new Set(GSM7_EXTENSION);
192
+ var GSM7_SINGLE_SEGMENT_LIMIT = 160;
193
+ var GSM7_CONCATENATED_SEGMENT_LIMIT = 153;
194
+ var UCS2_SINGLE_SEGMENT_LIMIT = 70;
195
+ var UCS2_CONCATENATED_SEGMENT_LIMIT = 67;
196
+ function gsm7Cost(char) {
197
+ if (GSM7_BASE_SET.has(char)) {
198
+ return 1;
199
+ }
200
+ if (GSM7_EXTENSION_SET.has(char)) {
201
+ return 2;
202
+ }
203
+ return void 0;
204
+ }
205
+ function isGsm7Char(char) {
206
+ return gsm7Cost(char) !== void 0;
207
+ }
208
+ function getSmsEncoding(message) {
209
+ for (const char of message) {
210
+ if (!isGsm7Char(char)) {
211
+ return "ucs-2";
212
+ }
213
+ }
214
+ return "gsm-7";
215
+ }
216
+ function countSmsSegments(message) {
217
+ const encoding = getSmsEncoding(message);
218
+ const costs = characterCosts(message, encoding);
219
+ const length = costs.reduce((total, cost) => total + cost, 0);
220
+ if (length === 0) {
221
+ return {
222
+ encoding,
223
+ length: 0,
224
+ segments: 0,
225
+ remainingInSegment: encoding === "gsm-7" ? GSM7_SINGLE_SEGMENT_LIMIT : UCS2_SINGLE_SEGMENT_LIMIT
226
+ };
227
+ }
228
+ const singleLimit = encoding === "gsm-7" ? GSM7_SINGLE_SEGMENT_LIMIT : UCS2_SINGLE_SEGMENT_LIMIT;
229
+ if (length <= singleLimit) {
230
+ return {
231
+ encoding,
232
+ length,
233
+ segments: 1,
234
+ remainingInSegment: singleLimit - length
235
+ };
236
+ }
237
+ const concatenatedLimit = encoding === "gsm-7" ? GSM7_CONCATENATED_SEGMENT_LIMIT : UCS2_CONCATENATED_SEGMENT_LIMIT;
238
+ let segments = 1;
239
+ let usedInSegment = 0;
240
+ for (const cost of costs) {
241
+ if (usedInSegment + cost > concatenatedLimit) {
242
+ segments += 1;
243
+ usedInSegment = cost;
244
+ } else {
245
+ usedInSegment += cost;
246
+ }
247
+ }
248
+ return {
249
+ encoding,
250
+ length,
251
+ segments,
252
+ remainingInSegment: concatenatedLimit - usedInSegment
253
+ };
254
+ }
255
+ function findNonGsm7Characters(message) {
256
+ const found = /* @__PURE__ */ new Set();
257
+ for (const char of message) {
258
+ if (!isGsm7Char(char)) {
259
+ found.add(char);
260
+ }
261
+ }
262
+ return Array.from(found);
263
+ }
264
+ function characterCosts(message, encoding) {
265
+ const costs = [];
266
+ for (const char of message) {
267
+ costs.push(encoding === "gsm-7" ? gsm7Cost(char) ?? 1 : char.length);
268
+ }
269
+ return costs;
270
+ }
271
+ var GSM7_REPLACEMENTS = {
272
+ // curly single quotes, prime, acute accent, backtick
273
+ "\u2018": "'",
274
+ "\u2019": "'",
275
+ "\u201A": "'",
276
+ "\u201B": "'",
277
+ "\u2032": "'",
278
+ "\xB4": "'",
279
+ "`": "'",
280
+ // curly double quotes, double prime, guillemets
281
+ "\u201C": '"',
282
+ "\u201D": '"',
283
+ "\u201E": '"',
284
+ "\u201F": '"',
285
+ "\u2033": '"',
286
+ "\xAB": '"',
287
+ "\xBB": '"',
288
+ // ellipsis
289
+ "\u2026": "...",
290
+ // hyphen variants, en dash, em dash, horizontal bar, minus sign
291
+ "\u2010": "-",
292
+ "\u2011": "-",
293
+ "\u2012": "-",
294
+ "\u2013": "-",
295
+ "\u2014": "-",
296
+ "\u2015": "-",
297
+ "\u2212": "-",
298
+ // non-breaking and typographic spaces
299
+ "\xA0": " ",
300
+ "\u2000": " ",
301
+ "\u2001": " ",
302
+ "\u2002": " ",
303
+ "\u2003": " ",
304
+ "\u2004": " ",
305
+ "\u2005": " ",
306
+ "\u2006": " ",
307
+ "\u2007": " ",
308
+ "\u2008": " ",
309
+ "\u2009": " ",
310
+ "\u200A": " ",
311
+ "\u202F": " ",
312
+ "\u205F": " ",
313
+ "\u3000": " ",
314
+ // invisible characters worth dropping outright
315
+ "\u200B": "",
316
+ "\uFEFF": "",
317
+ "\xAD": "",
318
+ // fraction slash
319
+ "\u2044": "/"
320
+ };
321
+ var TRANSLITERATION_EXTRAS = {
322
+ "\u0152": "OE",
323
+ "\u0153": "oe",
324
+ "\u0141": "L",
325
+ "\u0142": "l",
326
+ "\u0110": "D",
327
+ "\u0111": "d",
328
+ "\u0131": "i"
329
+ };
330
+ var COMBINING_MARKS = /[\u0300-\u036F]/g;
331
+ function sanitizeForGsm7(message, options = {}) {
332
+ const { transliterateAccents = false } = options;
333
+ let result = "";
334
+ for (const char of message) {
335
+ const replacement = GSM7_REPLACEMENTS[char];
336
+ if (replacement !== void 0) {
337
+ result += replacement;
338
+ continue;
339
+ }
340
+ if (isGsm7Char(char)) {
341
+ result += char;
342
+ continue;
343
+ }
344
+ result += transliterateAccents ? transliterate(char) : char;
345
+ }
346
+ return result;
347
+ }
348
+ function transliterate(char) {
349
+ const extra = TRANSLITERATION_EXTRAS[char];
350
+ if (extra !== void 0) {
351
+ return extra;
352
+ }
353
+ const stripped = char.normalize("NFD").replace(COMBINING_MARKS, "");
354
+ if (stripped.length === 1 && /[A-Za-z]/.test(stripped)) {
355
+ return stripped;
356
+ }
357
+ return char;
358
+ }
359
+ var E164_PATTERN = /^\+[1-9]\d{1,14}$/;
360
+ var FORMATTING_CHARACTERS = /[\s.\-()]/g;
361
+ var DIGITS_ONLY = /^\d+$/;
362
+ var COUNTRY_CODE_PATTERN = /^[1-9]\d{0,2}$/;
363
+ function isValidE164(input) {
364
+ return E164_PATTERN.test(input);
365
+ }
366
+ function normalizePhoneNumber(input, options = {}) {
367
+ const trimmed = input.trim();
368
+ if (!trimmed) {
369
+ return null;
370
+ }
371
+ const hasPlus = trimmed.startsWith("+");
372
+ const withoutPlus = hasPlus ? trimmed.slice(1) : trimmed;
373
+ const digits = withoutPlus.replace(FORMATTING_CHARACTERS, "");
374
+ if (!DIGITS_ONLY.test(digits)) {
375
+ return null;
376
+ }
377
+ if (hasPlus) {
378
+ return asE164OrNull(`+${digits}`);
379
+ }
380
+ if (digits.startsWith("00")) {
381
+ return asE164OrNull(`+${digits.slice(2)}`);
382
+ }
383
+ const countryCode = normalizeCountryCode(options.defaultCountryCode);
384
+ if (countryCode === void 0) {
385
+ return null;
386
+ }
387
+ const national = digits.startsWith("0") ? digits.slice(1) : digits;
388
+ return asE164OrNull(`+${countryCode}${national}`);
389
+ }
390
+ function asE164OrNull(candidate) {
391
+ return isValidE164(candidate) ? candidate : null;
392
+ }
393
+ function normalizeCountryCode(value) {
394
+ if (value === void 0) {
395
+ return void 0;
396
+ }
397
+ const code = value.startsWith("+") ? value.slice(1) : value;
398
+ if (!COUNTRY_CODE_PATTERN.test(code)) {
399
+ throw new TypeError(
400
+ `defaultCountryCode must be 1 to 3 digits starting with 1-9, got ${JSON.stringify(value)}`
401
+ );
402
+ }
403
+ return code;
404
+ }
405
+
186
406
  // src/webhooks.ts
187
407
  async function verifyWebhookSignature(options) {
188
408
  const { payload, signature, signingSecret } = options;
@@ -218,5 +438,11 @@ function constantTimeEqual(a, b) {
218
438
  export {
219
439
  Textbee,
220
440
  TextbeeError,
441
+ countSmsSegments,
442
+ findNonGsm7Characters,
443
+ getSmsEncoding,
444
+ isValidE164,
445
+ normalizePhoneNumber,
446
+ sanitizeForGsm7,
221
447
  verifyWebhookSignature
222
448
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@textbee/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",