@textbee/sdk 0.0.3 → 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,
@@ -71,17 +71,33 @@ await textbee.setDefaultDevice(deviceId)
71
71
 
72
72
  ## Messages and delivery status
73
73
 
74
+ History is account-level: one call covers every device, and `deviceIds` narrows it.
75
+
74
76
  ```js
75
- // Paginated history, filterable and searchable
76
- const { data, meta } = await textbee.getMessages(deviceId, {
77
- type: 'received', // filter is lowercase: 'all' | 'sent' | 'received'
77
+ // Paginated history across the whole account, filterable and searchable
78
+ const { data, meta } = await textbee.getMessages({
79
+ direction: 'received', // 'all' | 'sent' | 'received'
80
+ deviceIds: [deviceId], // omit for every device
81
+ status: 'delivered', // delivery state; direction=sent + status=failed lists failed sends
82
+ search: 'invoice',
83
+ from: '2026-08-01', // dates are UTC; datetimes need an explicit timezone
84
+ to: '2026-09-01T00:00:00Z', // exclusive, so windows never double-count
78
85
  page: 1,
79
86
  limit: 50,
80
- search: 'invoice',
81
87
  })
82
88
 
83
- // Each message reports its direction uppercase, so compare accordingly
84
- data.filter((m) => m.type === 'RECEIVED')
89
+ // direction on each message is lowercase and feeds straight back into filters
90
+ data.filter((m) => m.direction === 'received')
91
+
92
+ // Which recipients of a bulk send failed: filter by the batch a send returned
93
+ const { smsBatchId } = await textbee.sendSms({ recipients, message })
94
+ const failed = await textbee.getMessages({ smsBatchId, status: 'failed' })
95
+
96
+ // Drain everything matching a filter: iterateMessages follows the
97
+ // pagination cursor for you until there is nothing left
98
+ for await (const message of textbee.iterateMessages({ direction: 'received', order: 'asc' })) {
99
+ console.log(message.sender, message.message)
100
+ }
85
101
 
86
102
  // A single message and its current status
87
103
  const sms = await textbee.getSms(deviceId, smsId)
@@ -111,6 +127,65 @@ app.post('/webhooks/textbee', express.raw({ type: 'application/json' }), async (
111
127
  })
112
128
  ```
113
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
+
114
189
  ## Errors
115
190
 
116
191
  Any non-2xx response throws a `TextbeeError` carrying the status and the parsed body. Network failures reject with the underlying `fetch` error instead.
@@ -119,7 +194,7 @@ Any non-2xx response throws a `TextbeeError` carrying the status and the parsed
119
194
  import { TextbeeError } from '@textbee/sdk'
120
195
 
121
196
  try {
122
- await textbee.sendSms({ recipients: ['+251912345678'], message: 'hi' })
197
+ await textbee.sendSms({ recipients: ['+12025550123'], message: 'hi' })
123
198
  } catch (error) {
124
199
  if (error instanceof TextbeeError) {
125
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.0.3"}`;
51
+ var CLIENT_ID = `textbee-js/${"0.2.0"}`;
46
52
  var Textbee = class {
47
53
  #apiKey;
48
54
  #baseUrl;
@@ -99,20 +105,46 @@ var Textbee = class {
99
105
  );
100
106
  return payload.data;
101
107
  }
102
- /** Page through a device's sent and received messages. */
103
- async getMessages(deviceId, options = {}) {
104
- return await this.#request(
105
- "GET",
106
- `/gateway/devices/${encodeURIComponent(deviceId)}/messages`,
107
- {
108
- query: {
109
- type: options.type,
110
- page: options.page,
111
- limit: options.limit,
112
- search: options.search
113
- }
108
+ /**
109
+ * Page through the account's sent and received messages, filtered by
110
+ * device, batch, direction, status, text, and time range.
111
+ */
112
+ async getMessages(options = {}) {
113
+ return await this.#request("GET", "/gateway/messages", {
114
+ query: {
115
+ deviceIds: options.deviceIds?.length ? options.deviceIds.join(",") : void 0,
116
+ smsBatchId: options.smsBatchId,
117
+ direction: options.direction,
118
+ status: options.status,
119
+ search: options.search,
120
+ from: toIsoString(options.from),
121
+ to: toIsoString(options.to),
122
+ order: options.order,
123
+ page: options.page,
124
+ limit: options.limit,
125
+ cursor: options.cursor
114
126
  }
115
- );
127
+ });
128
+ }
129
+ /**
130
+ * Iterate every message matching the filters, following the pagination
131
+ * cursor until the end. Use `order: 'asc'` with a `from` bound to drain
132
+ * forward when polling; keep the last message's `createdAt` (or track
133
+ * `meta.nextCursor` via `getMessages`) to resume the next poll.
134
+ *
135
+ * ```ts
136
+ * for await (const message of textbee.iterateMessages({ direction: 'received' })) {
137
+ * handle(message)
138
+ * }
139
+ * ```
140
+ */
141
+ async *iterateMessages(options = {}) {
142
+ let page = await this.getMessages(options);
143
+ yield* page.data;
144
+ while (page.meta.nextCursor) {
145
+ page = await this.getMessages({ ...options, cursor: page.meta.nextCursor });
146
+ yield* page.data;
147
+ }
116
148
  }
117
149
  /** Fetch a single message, including its delivery status. */
118
150
  async getSms(deviceId, smsId) {
@@ -158,6 +190,9 @@ var Textbee = class {
158
190
  return payload;
159
191
  }
160
192
  };
193
+ function toIsoString(value) {
194
+ return value instanceof Date ? value.toISOString() : value;
195
+ }
161
196
  async function readBody(response) {
162
197
  const text = await response.text();
163
198
  if (!text) {
@@ -182,6 +217,226 @@ function errorMessage(payload, status) {
182
217
  return `HTTP ${status}`;
183
218
  }
184
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
+
185
440
  // src/webhooks.ts
186
441
  async function verifyWebhookSignature(options) {
187
442
  const { payload, signature, signingSecret } = options;
@@ -218,5 +473,11 @@ function constantTimeEqual(a, b) {
218
473
  0 && (module.exports = {
219
474
  Textbee,
220
475
  TextbeeError,
476
+ countSmsSegments,
477
+ findNonGsm7Characters,
478
+ getSmsEncoding,
479
+ isValidE164,
480
+ normalizePhoneNumber,
481
+ sanitizeForGsm7,
221
482
  verifyWebhookSignature
222
483
  });
package/dist/index.d.cts CHANGED
@@ -54,16 +54,42 @@ interface SendSmsImmediateResponse {
54
54
  * with `'smsBatchId' in result` before reading batch fields.
55
55
  */
56
56
  type SendSmsResponse = SendSmsQueuedResponse | SendSmsImmediateResponse;
57
- interface GetMessagesOptions {
57
+ interface ListMessagesOptions {
58
+ /** Only messages from these devices. Omit for every device on the account. */
59
+ deviceIds?: string[];
60
+ /**
61
+ * Only messages from this batch, using the `smsBatchId` a send returns.
62
+ * Combine with `status: 'failed'` to list the recipients that failed.
63
+ */
64
+ smsBatchId?: string;
58
65
  /** Filter by direction. Defaults to `all`. */
59
- type?: 'all' | 'sent' | 'received';
60
- /** 1-based page number. Defaults to 1. */
66
+ direction?: 'all' | 'sent' | 'received';
67
+ /** Filter by delivery state, e.g. `failed` to list sends that failed. */
68
+ status?: 'pending' | 'dispatched' | 'sent' | 'delivered' | 'failed' | 'unknown' | 'received';
69
+ /** Free text match across the message body, recipient, and sender. */
70
+ search?: string;
71
+ /**
72
+ * Inclusive lower bound on when the platform stored the message. A string
73
+ * must carry an explicit timezone (`2026-08-01T00:00:00Z`); a bare date like
74
+ * `2026-08-01` is read as UTC midnight.
75
+ */
76
+ from?: string | Date;
77
+ /** Exclusive upper bound, same formats as `from`. */
78
+ to?: string | Date;
79
+ /** `desc` (default) for newest first, `asc` to walk forward when polling. */
80
+ order?: 'desc' | 'asc';
81
+ /** 1-based page number. Defaults to 1. Mutually exclusive with `cursor`. */
61
82
  page?: number;
62
83
  /** Items per page. Defaults to 50, capped at 100 by the API. */
63
84
  limit?: number;
64
- /** Free text match across the message body, recipient, and sender. */
65
- search?: string;
85
+ /**
86
+ * Opaque position from a previous response's `meta.nextCursor`. When set,
87
+ * `meta` switches to cursor mode and omits the total count.
88
+ */
89
+ cursor?: string;
66
90
  }
91
+ /** Everything `iterateMessages` accepts: a filter set, minus the paging knobs it drives itself. */
92
+ type IterateMessagesOptions = Omit<ListMessagesOptions, 'page' | 'cursor'>;
67
93
  interface Device {
68
94
  _id: string;
69
95
  name?: string;
@@ -82,14 +108,29 @@ interface Device {
82
108
  createdAt?: string;
83
109
  updatedAt?: string;
84
110
  }
111
+ interface MessageDevice {
112
+ _id: string;
113
+ brand?: string;
114
+ model?: string;
115
+ buildId?: string;
116
+ enabled?: boolean;
117
+ }
85
118
  interface Message {
86
119
  _id: string;
87
120
  message: string;
88
121
  /**
89
- * Direction, uppercase. Note the asymmetry with the `type` filter accepted by
90
- * getMessages, which is lowercase.
122
+ * Direction, uppercase.
123
+ *
124
+ * @deprecated Read `direction` instead: it is lowercase and matches the
125
+ * `direction` filter, so a response value feeds straight back into a query.
91
126
  */
92
127
  type: 'SENT' | 'RECEIVED';
128
+ /** Direction, lowercase. Matches the `direction` filter values. */
129
+ direction?: 'sent' | 'received';
130
+ /** Message channel. Currently always `sms`; absent means `sms`. */
131
+ channel?: string;
132
+ /** The sending or receiving device, populated by the history endpoints. */
133
+ device?: MessageDevice;
93
134
  /** Lowercase, and absent on messages stored before status tracking. */
94
135
  status?: 'pending' | 'dispatched' | 'sent' | 'delivered' | 'failed' | 'unknown' | 'received';
95
136
  sender?: string;
@@ -120,15 +161,23 @@ interface SmsBatch {
120
161
  createdAt?: string;
121
162
  updatedAt?: string;
122
163
  }
123
- interface PaginationMeta {
124
- page: number;
164
+ /**
165
+ * Meta for the account-level message list. Page mode carries the counters and
166
+ * the cursor fields; cursor mode carries `limit`, `nextCursor`, and `hasMore`
167
+ * only, skipping the expensive total count.
168
+ */
169
+ interface MessageListMeta {
125
170
  limit: number;
126
- total: number;
127
- totalPages: number;
171
+ page?: number;
172
+ total?: number;
173
+ totalPages?: number;
174
+ /** Opaque position after the last message. Null on the final page. */
175
+ nextCursor?: string | null;
176
+ hasMore?: boolean;
128
177
  }
129
- interface MessagesPage {
178
+ interface MessageList {
130
179
  data: Message[];
131
- meta: PaginationMeta;
180
+ meta: MessageListMeta;
132
181
  }
133
182
  interface SmsBatchResult {
134
183
  batch: SmsBatch;
@@ -148,8 +197,24 @@ declare class Textbee {
148
197
  getDevice(deviceId: string): Promise<Device>;
149
198
  /** Make a device the default sender for requests that omit `deviceId`. */
150
199
  setDefaultDevice(deviceId: string): Promise<Device>;
151
- /** Page through a device's sent and received messages. */
152
- getMessages(deviceId: string, options?: GetMessagesOptions): Promise<MessagesPage>;
200
+ /**
201
+ * Page through the account's sent and received messages, filtered by
202
+ * device, batch, direction, status, text, and time range.
203
+ */
204
+ getMessages(options?: ListMessagesOptions): Promise<MessageList>;
205
+ /**
206
+ * Iterate every message matching the filters, following the pagination
207
+ * cursor until the end. Use `order: 'asc'` with a `from` bound to drain
208
+ * forward when polling; keep the last message's `createdAt` (or track
209
+ * `meta.nextCursor` via `getMessages`) to resume the next poll.
210
+ *
211
+ * ```ts
212
+ * for await (const message of textbee.iterateMessages({ direction: 'received' })) {
213
+ * handle(message)
214
+ * }
215
+ * ```
216
+ */
217
+ iterateMessages(options?: IterateMessagesOptions): AsyncGenerator<Message, void, undefined>;
153
218
  /** Fetch a single message, including its delivery status. */
154
219
  getSms(deviceId: string, smsId: string): Promise<Message>;
155
220
  /** Fetch a batch and every message in it. Pair this with `smsBatchId` from a send. */
@@ -169,6 +234,97 @@ declare class TextbeeError extends Error {
169
234
  constructor(status: number, message: string, body?: unknown);
170
235
  }
171
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
+
172
328
  interface VerifyWebhookSignatureOptions {
173
329
  /**
174
330
  * The webhook request body. Prefer the raw body string.
@@ -191,4 +347,4 @@ interface VerifyWebhookSignatureOptions {
191
347
  */
192
348
  declare function verifyWebhookSignature(options: VerifyWebhookSignatureOptions): Promise<boolean>;
193
349
 
194
- export { type Device, type GetMessagesOptions, type Message, type MessagesPage, type PaginationMeta, 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
@@ -54,16 +54,42 @@ interface SendSmsImmediateResponse {
54
54
  * with `'smsBatchId' in result` before reading batch fields.
55
55
  */
56
56
  type SendSmsResponse = SendSmsQueuedResponse | SendSmsImmediateResponse;
57
- interface GetMessagesOptions {
57
+ interface ListMessagesOptions {
58
+ /** Only messages from these devices. Omit for every device on the account. */
59
+ deviceIds?: string[];
60
+ /**
61
+ * Only messages from this batch, using the `smsBatchId` a send returns.
62
+ * Combine with `status: 'failed'` to list the recipients that failed.
63
+ */
64
+ smsBatchId?: string;
58
65
  /** Filter by direction. Defaults to `all`. */
59
- type?: 'all' | 'sent' | 'received';
60
- /** 1-based page number. Defaults to 1. */
66
+ direction?: 'all' | 'sent' | 'received';
67
+ /** Filter by delivery state, e.g. `failed` to list sends that failed. */
68
+ status?: 'pending' | 'dispatched' | 'sent' | 'delivered' | 'failed' | 'unknown' | 'received';
69
+ /** Free text match across the message body, recipient, and sender. */
70
+ search?: string;
71
+ /**
72
+ * Inclusive lower bound on when the platform stored the message. A string
73
+ * must carry an explicit timezone (`2026-08-01T00:00:00Z`); a bare date like
74
+ * `2026-08-01` is read as UTC midnight.
75
+ */
76
+ from?: string | Date;
77
+ /** Exclusive upper bound, same formats as `from`. */
78
+ to?: string | Date;
79
+ /** `desc` (default) for newest first, `asc` to walk forward when polling. */
80
+ order?: 'desc' | 'asc';
81
+ /** 1-based page number. Defaults to 1. Mutually exclusive with `cursor`. */
61
82
  page?: number;
62
83
  /** Items per page. Defaults to 50, capped at 100 by the API. */
63
84
  limit?: number;
64
- /** Free text match across the message body, recipient, and sender. */
65
- search?: string;
85
+ /**
86
+ * Opaque position from a previous response's `meta.nextCursor`. When set,
87
+ * `meta` switches to cursor mode and omits the total count.
88
+ */
89
+ cursor?: string;
66
90
  }
91
+ /** Everything `iterateMessages` accepts: a filter set, minus the paging knobs it drives itself. */
92
+ type IterateMessagesOptions = Omit<ListMessagesOptions, 'page' | 'cursor'>;
67
93
  interface Device {
68
94
  _id: string;
69
95
  name?: string;
@@ -82,14 +108,29 @@ interface Device {
82
108
  createdAt?: string;
83
109
  updatedAt?: string;
84
110
  }
111
+ interface MessageDevice {
112
+ _id: string;
113
+ brand?: string;
114
+ model?: string;
115
+ buildId?: string;
116
+ enabled?: boolean;
117
+ }
85
118
  interface Message {
86
119
  _id: string;
87
120
  message: string;
88
121
  /**
89
- * Direction, uppercase. Note the asymmetry with the `type` filter accepted by
90
- * getMessages, which is lowercase.
122
+ * Direction, uppercase.
123
+ *
124
+ * @deprecated Read `direction` instead: it is lowercase and matches the
125
+ * `direction` filter, so a response value feeds straight back into a query.
91
126
  */
92
127
  type: 'SENT' | 'RECEIVED';
128
+ /** Direction, lowercase. Matches the `direction` filter values. */
129
+ direction?: 'sent' | 'received';
130
+ /** Message channel. Currently always `sms`; absent means `sms`. */
131
+ channel?: string;
132
+ /** The sending or receiving device, populated by the history endpoints. */
133
+ device?: MessageDevice;
93
134
  /** Lowercase, and absent on messages stored before status tracking. */
94
135
  status?: 'pending' | 'dispatched' | 'sent' | 'delivered' | 'failed' | 'unknown' | 'received';
95
136
  sender?: string;
@@ -120,15 +161,23 @@ interface SmsBatch {
120
161
  createdAt?: string;
121
162
  updatedAt?: string;
122
163
  }
123
- interface PaginationMeta {
124
- page: number;
164
+ /**
165
+ * Meta for the account-level message list. Page mode carries the counters and
166
+ * the cursor fields; cursor mode carries `limit`, `nextCursor`, and `hasMore`
167
+ * only, skipping the expensive total count.
168
+ */
169
+ interface MessageListMeta {
125
170
  limit: number;
126
- total: number;
127
- totalPages: number;
171
+ page?: number;
172
+ total?: number;
173
+ totalPages?: number;
174
+ /** Opaque position after the last message. Null on the final page. */
175
+ nextCursor?: string | null;
176
+ hasMore?: boolean;
128
177
  }
129
- interface MessagesPage {
178
+ interface MessageList {
130
179
  data: Message[];
131
- meta: PaginationMeta;
180
+ meta: MessageListMeta;
132
181
  }
133
182
  interface SmsBatchResult {
134
183
  batch: SmsBatch;
@@ -148,8 +197,24 @@ declare class Textbee {
148
197
  getDevice(deviceId: string): Promise<Device>;
149
198
  /** Make a device the default sender for requests that omit `deviceId`. */
150
199
  setDefaultDevice(deviceId: string): Promise<Device>;
151
- /** Page through a device's sent and received messages. */
152
- getMessages(deviceId: string, options?: GetMessagesOptions): Promise<MessagesPage>;
200
+ /**
201
+ * Page through the account's sent and received messages, filtered by
202
+ * device, batch, direction, status, text, and time range.
203
+ */
204
+ getMessages(options?: ListMessagesOptions): Promise<MessageList>;
205
+ /**
206
+ * Iterate every message matching the filters, following the pagination
207
+ * cursor until the end. Use `order: 'asc'` with a `from` bound to drain
208
+ * forward when polling; keep the last message's `createdAt` (or track
209
+ * `meta.nextCursor` via `getMessages`) to resume the next poll.
210
+ *
211
+ * ```ts
212
+ * for await (const message of textbee.iterateMessages({ direction: 'received' })) {
213
+ * handle(message)
214
+ * }
215
+ * ```
216
+ */
217
+ iterateMessages(options?: IterateMessagesOptions): AsyncGenerator<Message, void, undefined>;
153
218
  /** Fetch a single message, including its delivery status. */
154
219
  getSms(deviceId: string, smsId: string): Promise<Message>;
155
220
  /** Fetch a batch and every message in it. Pair this with `smsBatchId` from a send. */
@@ -169,6 +234,97 @@ declare class TextbeeError extends Error {
169
234
  constructor(status: number, message: string, body?: unknown);
170
235
  }
171
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
+
172
328
  interface VerifyWebhookSignatureOptions {
173
329
  /**
174
330
  * The webhook request body. Prefer the raw body string.
@@ -191,4 +347,4 @@ interface VerifyWebhookSignatureOptions {
191
347
  */
192
348
  declare function verifyWebhookSignature(options: VerifyWebhookSignatureOptions): Promise<boolean>;
193
349
 
194
- export { type Device, type GetMessagesOptions, type Message, type MessagesPage, type PaginationMeta, 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.0.3"}`;
17
+ var CLIENT_ID = `textbee-js/${"0.2.0"}`;
18
18
  var Textbee = class {
19
19
  #apiKey;
20
20
  #baseUrl;
@@ -71,20 +71,46 @@ var Textbee = class {
71
71
  );
72
72
  return payload.data;
73
73
  }
74
- /** Page through a device's sent and received messages. */
75
- async getMessages(deviceId, options = {}) {
76
- return await this.#request(
77
- "GET",
78
- `/gateway/devices/${encodeURIComponent(deviceId)}/messages`,
79
- {
80
- query: {
81
- type: options.type,
82
- page: options.page,
83
- limit: options.limit,
84
- search: options.search
85
- }
74
+ /**
75
+ * Page through the account's sent and received messages, filtered by
76
+ * device, batch, direction, status, text, and time range.
77
+ */
78
+ async getMessages(options = {}) {
79
+ return await this.#request("GET", "/gateway/messages", {
80
+ query: {
81
+ deviceIds: options.deviceIds?.length ? options.deviceIds.join(",") : void 0,
82
+ smsBatchId: options.smsBatchId,
83
+ direction: options.direction,
84
+ status: options.status,
85
+ search: options.search,
86
+ from: toIsoString(options.from),
87
+ to: toIsoString(options.to),
88
+ order: options.order,
89
+ page: options.page,
90
+ limit: options.limit,
91
+ cursor: options.cursor
86
92
  }
87
- );
93
+ });
94
+ }
95
+ /**
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.
100
+ *
101
+ * ```ts
102
+ * for await (const message of textbee.iterateMessages({ direction: 'received' })) {
103
+ * handle(message)
104
+ * }
105
+ * ```
106
+ */
107
+ async *iterateMessages(options = {}) {
108
+ let page = await this.getMessages(options);
109
+ yield* page.data;
110
+ while (page.meta.nextCursor) {
111
+ page = await this.getMessages({ ...options, cursor: page.meta.nextCursor });
112
+ yield* page.data;
113
+ }
88
114
  }
89
115
  /** Fetch a single message, including its delivery status. */
90
116
  async getSms(deviceId, smsId) {
@@ -130,6 +156,9 @@ var Textbee = class {
130
156
  return payload;
131
157
  }
132
158
  };
159
+ function toIsoString(value) {
160
+ return value instanceof Date ? value.toISOString() : value;
161
+ }
133
162
  async function readBody(response) {
134
163
  const text = await response.text();
135
164
  if (!text) {
@@ -154,6 +183,226 @@ function errorMessage(payload, status) {
154
183
  return `HTTP ${status}`;
155
184
  }
156
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
+
157
406
  // src/webhooks.ts
158
407
  async function verifyWebhookSignature(options) {
159
408
  const { payload, signature, signingSecret } = options;
@@ -189,5 +438,11 @@ function constantTimeEqual(a, b) {
189
438
  export {
190
439
  Textbee,
191
440
  TextbeeError,
441
+ countSmsSegments,
442
+ findNonGsm7Characters,
443
+ getSmsEncoding,
444
+ isValidE164,
445
+ normalizePhoneNumber,
446
+ sanitizeForGsm7,
192
447
  verifyWebhookSignature
193
448
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@textbee/sdk",
3
- "version": "0.0.3",
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",