Skip to content

Commit b69c442

Browse files
authored
Merge pull request #3 from textbee/feat/sms-utils
Add zero-dependency SMS utilities
2 parents 2fe4c83 + 80e244f commit b69c442

7 files changed

Lines changed: 869 additions & 14 deletions

File tree

README.md

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { Textbee } from '@textbee/sdk'
2828
const textbee = new Textbee({ apiKey: process.env.TEXTBEE_API_KEY })
2929

3030
await textbee.sendSms({
31-
recipients: ['+251912345678'],
31+
recipients: ['+12025550123'],
3232
message: 'Hello from textbee!',
3333
})
3434
```
@@ -45,7 +45,7 @@ await textbee.sendSms({
4545

4646
```js
4747
await textbee.sendSms({
48-
recipients: ['+251912345678'],
48+
recipients: ['+12025550123'],
4949
message: 'Your appointment is tomorrow at 9am',
5050
deviceId: '65f0000000000000000000aa',
5151
simSubscriptionId: 2,
@@ -127,6 +127,65 @@ app.post('/webhooks/textbee', express.raw({ type: 'application/json' }), async (
127127
})
128128
```
129129

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+
130189
## Errors
131190

132191
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
135194
import { TextbeeError } from '@textbee/sdk'
136195

137196
try {
138-
await textbee.sendSms({ recipients: ['+251912345678'], message: 'hi' })
197+
await textbee.sendSms({ recipients: ['+12025550123'], message: 'hi' })
139198
} catch (error) {
140199
if (error instanceof TextbeeError) {
141200
console.error(error.status, error.message)

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@textbee/sdk",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"description": "Official JavaScript SDK for textbee.dev, the open source SMS gateway",
55
"license": "MIT",
66
"packageManager": "pnpm@9.14.2",
@@ -44,7 +44,10 @@
4444
"sms",
4545
"sms-gateway",
4646
"textbee",
47-
"android"
47+
"android",
48+
"sms-segments",
49+
"gsm-7",
50+
"e164"
4851
],
4952
"scripts": {
5053
"build": "tsup",

src/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
11
export { Textbee } from './client'
22
export { TextbeeError } from './errors'
3+
export {
4+
countSmsSegments,
5+
findNonGsm7Characters,
6+
getSmsEncoding,
7+
isValidE164,
8+
normalizePhoneNumber,
9+
sanitizeForGsm7,
10+
} from './sms-utils'
311
export { verifyWebhookSignature } from './webhooks'
412

13+
export type {
14+
NormalizePhoneNumberOptions,
15+
SanitizeForGsm7Options,
16+
SmsEncoding,
17+
SmsSegmentInfo,
18+
} from './sms-utils'
519
export type { VerifyWebhookSignatureOptions } from './webhooks'
620
export type {
721
Device,

0 commit comments

Comments
 (0)