diff --git a/README.md b/README.md index f363f14..3eb949e 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ import { Textbee } from '@textbee/sdk' const textbee = new Textbee({ apiKey: process.env.TEXTBEE_API_KEY }) await textbee.sendSms({ - recipients: ['+251912345678'], + recipients: ['+12025550123'], message: 'Hello from textbee!', }) ``` @@ -45,7 +45,7 @@ await textbee.sendSms({ ```js await textbee.sendSms({ - recipients: ['+251912345678'], + recipients: ['+12025550123'], message: 'Your appointment is tomorrow at 9am', deviceId: '65f0000000000000000000aa', simSubscriptionId: 2, @@ -127,6 +127,65 @@ app.post('/webhooks/textbee', express.raw({ type: 'application/json' }), async ( }) ``` +## SMS utilities + +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. + +### Segments and encoding + +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. + +```js +import { countSmsSegments, getSmsEncoding, findNonGsm7Characters } from '@textbee/sdk' + +countSmsSegments('Your code is 123456') +// { encoding: 'gsm-7', length: 19, segments: 1, remainingInSegment: 141 } + +countSmsSegments('Your code is 123456 🎉') +// { encoding: 'ucs-2', length: 22, segments: 1, remainingInSegment: 48 } + +getSmsEncoding('plain ascii') // 'gsm-7' +findNonGsm7Characters('Hi 🎉') // ['🎉'] +``` + +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. + +### Keeping messages in GSM-7 + +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. + +```js +import { sanitizeForGsm7, countSmsSegments } from '@textbee/sdk' + +const pasted = '“Your order shipped…”' +countSmsSegments(pasted).encoding // 'ucs-2' + +const clean = sanitizeForGsm7(pasted) // '"Your order shipped..."' +countSmsSegments(clean).encoding // 'gsm-7' + +// Optionally strip accents that GSM-7 does not carry. Letters it does carry, +// like é, ü, and ñ, are always left alone. +sanitizeForGsm7('naïve', { transliterateAccents: true }) // 'naive' +``` + +It is best effort: characters with no safe equivalent pass through untouched. Check the result with `getSmsEncoding` and see what is left with `findNonGsm7Characters`. + +### Phone number helpers + +```js +import { isValidE164, normalizePhoneNumber } from '@textbee/sdk' + +isValidE164('+12025550123') // true +isValidE164('202-555-0123') // false + +normalizePhoneNumber('+1 (202) 555-0123') // '+12025550123' +normalizePhoneNumber('0012025550123') // '+12025550123' +normalizePhoneNumber('(202) 555-0123', { defaultCountryCode: '1' }) // '+12025550123' +normalizePhoneNumber('not a number') // null +``` + +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`. + ## Errors 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 import { TextbeeError } from '@textbee/sdk' try { - await textbee.sendSms({ recipients: ['+251912345678'], message: 'hi' }) + await textbee.sendSms({ recipients: ['+12025550123'], message: 'hi' }) } catch (error) { if (error instanceof TextbeeError) { console.error(error.status, error.message) diff --git a/package.json b/package.json index b3b163b..5a53569 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@textbee/sdk", - "version": "0.1.0", + "version": "0.2.0", "description": "Official JavaScript SDK for textbee.dev, the open source SMS gateway", "license": "MIT", "packageManager": "pnpm@9.14.2", @@ -44,7 +44,10 @@ "sms", "sms-gateway", "textbee", - "android" + "android", + "sms-segments", + "gsm-7", + "e164" ], "scripts": { "build": "tsup", diff --git a/src/index.ts b/src/index.ts index bab1eb1..3c39065 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,21 @@ export { Textbee } from './client' export { TextbeeError } from './errors' +export { + countSmsSegments, + findNonGsm7Characters, + getSmsEncoding, + isValidE164, + normalizePhoneNumber, + sanitizeForGsm7, +} from './sms-utils' export { verifyWebhookSignature } from './webhooks' +export type { + NormalizePhoneNumberOptions, + SanitizeForGsm7Options, + SmsEncoding, + SmsSegmentInfo, +} from './sms-utils' export type { VerifyWebhookSignatureOptions } from './webhooks' export type { Device, diff --git a/src/sms-utils.ts b/src/sms-utils.ts new file mode 100644 index 0000000..36dd1f8 --- /dev/null +++ b/src/sms-utils.ts @@ -0,0 +1,418 @@ +/** + * Pure SMS helpers. No API key, no network, no dependencies. They work with + * any SMS provider, not just textbee. + */ + +// --------------------------------------------------------------------------- +// GSM 03.38 character tables +// --------------------------------------------------------------------------- + +/** + * The GSM 03.38 default alphabet, one septet per character. + * + * @ £ $ ¥ è é ù ì ò Ç LF Ø ø CR Å å Δ _ Φ Γ Λ Ω Π Ψ Σ Θ Ξ Æ æ ß É + * space ! " # ¤ % & ' ( ) * + , - . / 0-9 : ; < = > ? + * ¡ A-Z Ä Ö Ñ Ü § ¿ a-z ä ö ñ ü à + * + * Non-ASCII entries are escaped so lookalike characters cannot sneak in. + */ +const GSM7_BASE = + '@\u00A3$\u00A5\u00E8\u00E9\u00F9\u00EC\u00F2\u00C7\n\u00D8\u00F8\r\u00C5\u00E5' + + '\u0394_\u03A6\u0393\u039B\u03A9\u03A0\u03A8\u03A3\u0398\u039E' + + '\u00C6\u00E6\u00DF\u00C9' + + ' !"#\u00A4%&\'()*+,-./0123456789:;<=>?' + + '\u00A1ABCDEFGHIJKLMNOPQRSTUVWXYZ\u00C4\u00D6\u00D1\u00DC\u00A7' + + '\u00BFabcdefghijklmnopqrstuvwxyz\u00E4\u00F6\u00F1\u00FC\u00E0' + +/** + * The GSM 03.38 extension table. Each of these costs two septets because it is + * sent as an escape byte followed by the character. + * + * form feed, ^ { } \ [ ~ ] | and the euro sign + */ +const GSM7_EXTENSION = '\f^{}\\[~]|\u20AC' + +const GSM7_BASE_SET = new Set(GSM7_BASE) +const GSM7_EXTENSION_SET = new Set(GSM7_EXTENSION) + +const GSM7_SINGLE_SEGMENT_LIMIT = 160 +const GSM7_CONCATENATED_SEGMENT_LIMIT = 153 +const UCS2_SINGLE_SEGMENT_LIMIT = 70 +const UCS2_CONCATENATED_SEGMENT_LIMIT = 67 + +/** Septets a character costs in GSM-7, or undefined when it is not encodable. */ +function gsm7Cost(char: string): 1 | 2 | undefined { + if (GSM7_BASE_SET.has(char)) { + return 1 + } + if (GSM7_EXTENSION_SET.has(char)) { + return 2 + } + return undefined +} + +function isGsm7Char(char: string): boolean { + return gsm7Cost(char) !== undefined +} + +// --------------------------------------------------------------------------- +// Segments and encoding +// --------------------------------------------------------------------------- + +/** How the message body will be encoded on the wire. */ +export type SmsEncoding = 'gsm-7' | 'ucs-2' + +export interface SmsSegmentInfo { + /** `gsm-7` when every character fits the GSM 03.38 alphabet, else `ucs-2`. */ + encoding: SmsEncoding + + /** + * Encoded length: septets for gsm-7 (extension characters cost 2), UTF-16 + * code units for ucs-2 (emoji and other astral characters cost 2). + */ + length: number + + /** Number of SMS segments the message occupies. 0 for an empty string. */ + segments: number + + /** + * How many more single-unit characters fit before another segment is needed. + * A two-unit character (extension character or emoji) may not fit even when + * this is 1. + */ + remainingInSegment: number +} + +/** + * Detect the encoding a message requires: `gsm-7` when every character is in + * the GSM 03.38 alphabet or its extension table, `ucs-2` otherwise. An empty + * string reports `gsm-7`. + */ +export function getSmsEncoding(message: string): SmsEncoding { + for (const char of message) { + if (!isGsm7Char(char)) { + return 'ucs-2' + } + } + return 'gsm-7' +} + +/** + * Compute encoding, encoded length, segment count, and remaining room the way + * a standards-following handset splits a message. + * + * Single segment limits are 160 septets (gsm-7) and 70 code units (ucs-2). + * Concatenated messages carry a header, shrinking each segment to 153 and 67. + * A two-unit character never straddles a boundary: it moves whole to the next + * segment and wastes one unit. + */ +export function countSmsSegments(message: string): SmsSegmentInfo { + const encoding = getSmsEncoding(message) + const costs = characterCosts(message, encoding) + const length = costs.reduce((total, cost) => total + cost, 0) + + if (length === 0) { + return { + encoding, + length: 0, + segments: 0, + remainingInSegment: + encoding === 'gsm-7' + ? GSM7_SINGLE_SEGMENT_LIMIT + : UCS2_SINGLE_SEGMENT_LIMIT, + } + } + + const singleLimit = + encoding === 'gsm-7' ? GSM7_SINGLE_SEGMENT_LIMIT : UCS2_SINGLE_SEGMENT_LIMIT + + if (length <= singleLimit) { + return { + encoding, + length, + segments: 1, + remainingInSegment: singleLimit - length, + } + } + + const concatenatedLimit = + encoding === 'gsm-7' + ? GSM7_CONCATENATED_SEGMENT_LIMIT + : UCS2_CONCATENATED_SEGMENT_LIMIT + + let segments = 1 + let usedInSegment = 0 + + for (const cost of costs) { + if (usedInSegment + cost > concatenatedLimit) { + segments += 1 + usedInSegment = cost + } else { + usedInSegment += cost + } + } + + return { + encoding, + length, + segments, + remainingInSegment: concatenatedLimit - usedInSegment, + } +} + +/** + * The unique characters forcing a message into ucs-2, in first appearance + * order. Empty when the message is already gsm-7 safe. Pair it with + * {@link sanitizeForGsm7} to see what is left after sanitizing. + */ +export function findNonGsm7Characters(message: string): string[] { + const found = new Set() + + for (const char of message) { + if (!isGsm7Char(char)) { + found.add(char) + } + } + + return Array.from(found) +} + +/** Per-character unit costs, in message order. */ +function characterCosts(message: string, encoding: SmsEncoding): number[] { + const costs: number[] = [] + + for (const char of message) { + costs.push(encoding === 'gsm-7' ? (gsm7Cost(char) ?? 1) : char.length) + } + + return costs +} + +// --------------------------------------------------------------------------- +// GSM-7 sanitization +// --------------------------------------------------------------------------- + +/** + * Unicode characters that have a safe GSM-7 equivalent. Keys are escaped so + * this file stays readable and never carries the very characters it replaces. + */ +const GSM7_REPLACEMENTS: Record = { + // curly single quotes, prime, acute accent, backtick + '\u2018': "'", + '\u2019': "'", + '\u201A': "'", + '\u201B': "'", + '\u2032': "'", + '\u00B4': "'", + '`': "'", + + // curly double quotes, double prime, guillemets + '\u201C': '"', + '\u201D': '"', + '\u201E': '"', + '\u201F': '"', + '\u2033': '"', + '\u00AB': '"', + '\u00BB': '"', + + // ellipsis + '\u2026': '...', + + // hyphen variants, en dash, em dash, horizontal bar, minus sign + '\u2010': '-', + '\u2011': '-', + '\u2012': '-', + '\u2013': '-', + '\u2014': '-', + '\u2015': '-', + '\u2212': '-', + + // non-breaking and typographic spaces + '\u00A0': ' ', + '\u2000': ' ', + '\u2001': ' ', + '\u2002': ' ', + '\u2003': ' ', + '\u2004': ' ', + '\u2005': ' ', + '\u2006': ' ', + '\u2007': ' ', + '\u2008': ' ', + '\u2009': ' ', + '\u200A': ' ', + '\u202F': ' ', + '\u205F': ' ', + '\u3000': ' ', + + // invisible characters worth dropping outright + '\u200B': '', + '\uFEFF': '', + '\u00AD': '', + + // fraction slash + '\u2044': '/', +} + +/** Letters NFD cannot decompose into an ASCII base letter. */ +const TRANSLITERATION_EXTRAS: Record = { + '\u0152': 'OE', + '\u0153': 'oe', + '\u0141': 'L', + '\u0142': 'l', + '\u0110': 'D', + '\u0111': 'd', + '\u0131': 'i', +} + +const COMBINING_MARKS = /[\u0300-\u036F]/g + +export interface SanitizeForGsm7Options { + /** + * Strip accents from Latin letters that GSM-7 does not carry, so `ï` becomes + * `i`. Letters GSM-7 already has (é, ü, ñ, à and friends) are never touched. + * Defaults to false. + */ + transliterateAccents?: boolean +} + +/** + * Replace common Unicode lookalikes (curly quotes, ellipsis, exotic spaces and + * dashes) with GSM-7 equivalents, so pasted text does not silently fall into + * ucs-2 and more than double its segment count. + * + * Best effort by design: characters with no safe replacement are left alone. + * Check the result with {@link getSmsEncoding} and inspect leftovers with + * {@link findNonGsm7Characters}. + */ +export function sanitizeForGsm7( + message: string, + options: SanitizeForGsm7Options = {}, +): string { + const { transliterateAccents = false } = options + let result = '' + + for (const char of message) { + const replacement = GSM7_REPLACEMENTS[char] + if (replacement !== undefined) { + result += replacement + continue + } + + if (isGsm7Char(char)) { + result += char + continue + } + + result += transliterateAccents ? transliterate(char) : char + } + + return result +} + +/** Best-effort ASCII form of a single character, or the character unchanged. */ +function transliterate(char: string): string { + const extra = TRANSLITERATION_EXTRAS[char] + if (extra !== undefined) { + return extra + } + + const stripped = char.normalize('NFD').replace(COMBINING_MARKS, '') + if (stripped.length === 1 && /[A-Za-z]/.test(stripped)) { + return stripped + } + + return char +} + +// --------------------------------------------------------------------------- +// Phone numbers +// --------------------------------------------------------------------------- + +const E164_PATTERN = /^\+[1-9]\d{1,14}$/ +const FORMATTING_CHARACTERS = /[\s.\-()]/g +const DIGITS_ONLY = /^\d+$/ +const COUNTRY_CODE_PATTERN = /^[1-9]\d{0,2}$/ + +/** + * Check that a string is strictly E.164: a plus sign, a leading digit 1-9, then + * 1 to 14 more digits, nothing else. + * + * This is a format check only. It does not know country dialing plans, so a + * well-formed but unassigned number still passes. + */ +export function isValidE164(input: string): boolean { + return E164_PATTERN.test(input) +} + +export interface NormalizePhoneNumberOptions { + /** + * Country calling code applied to national-format input, with or without a + * leading plus: `1` and `+1` both work. With it, `(202) 555-0123` becomes + * `+12025550123` and one leading trunk zero is dropped. Without it, + * national-format input returns null. + */ + defaultCountryCode?: string +} + +/** + * Normalize free-form phone input to E.164, or return null when it cannot be + * done safely. Strips spaces, dots, hyphens and parentheses, converts a leading + * `00` to a plus, and optionally applies a default country code. + * + * This is a formatter, not libphonenumber: it never validates a number against + * a country dialing plan. Bad input returns null, a bad + * {@link NormalizePhoneNumberOptions.defaultCountryCode} throws. + */ +export function normalizePhoneNumber( + input: string, + options: NormalizePhoneNumberOptions = {}, +): string | null { + const trimmed = input.trim() + if (!trimmed) { + return null + } + + const hasPlus = trimmed.startsWith('+') + const withoutPlus = hasPlus ? trimmed.slice(1) : trimmed + const digits = withoutPlus.replace(FORMATTING_CHARACTERS, '') + + if (!DIGITS_ONLY.test(digits)) { + return null + } + + if (hasPlus) { + return asE164OrNull(`+${digits}`) + } + + if (digits.startsWith('00')) { + return asE164OrNull(`+${digits.slice(2)}`) + } + + const countryCode = normalizeCountryCode(options.defaultCountryCode) + if (countryCode === undefined) { + return null + } + + const national = digits.startsWith('0') ? digits.slice(1) : digits + + return asE164OrNull(`+${countryCode}${national}`) +} + +function asE164OrNull(candidate: string): string | null { + return isValidE164(candidate) ? candidate : null +} + +function normalizeCountryCode(value: string | undefined): string | undefined { + if (value === undefined) { + return undefined + } + + const code = value.startsWith('+') ? value.slice(1) : value + if (!COUNTRY_CODE_PATTERN.test(code)) { + throw new TypeError( + `defaultCountryCode must be 1 to 3 digits starting with 1-9, got ${JSON.stringify(value)}`, + ) + } + + return code +} diff --git a/test/client.test.ts b/test/client.test.ts index 2711951..7ae9e2d 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -52,7 +52,7 @@ describe('sendSms', () => { await client().sendSms({ message: 'Hello from textbee', - recipients: ['+251912345678'], + recipients: ['+12025550123'], }) const call = lastCall() @@ -61,7 +61,7 @@ describe('sendSms', () => { expect(call.headers['x-api-key']).toBe(API_KEY) expect(call.body).toEqual({ message: 'Hello from textbee', - recipients: ['+251912345678'], + recipients: ['+12025550123'], }) expect(call.body).not.toHaveProperty('smsBody') expect(call.body).not.toHaveProperty('receivers') @@ -81,7 +81,7 @@ describe('sendSms', () => { const result = await client().sendSms({ message: 'queued', - recipients: ['+251912345678', '+251912345679'], + recipients: ['+12025550123', '+12025550124'], }) expect('smsBatchId' in result).toBe(true) @@ -96,19 +96,19 @@ describe('sendSms', () => { await client().sendSms({ message: 'with options', - recipients: ['+251912345678'], + recipients: ['+12025550123'], deviceId: DEVICE_ID, simSubscriptionId: 2, }) expect(lastCall().body).toEqual({ message: 'with options', - recipients: ['+251912345678'], + recipients: ['+12025550123'], deviceId: DEVICE_ID, simSubscriptionId: 2, }) - await client().sendSms({ message: 'bare', recipients: ['+251912345678'] }) + await client().sendSms({ message: 'bare', recipients: ['+12025550123'] }) const keys = Object.keys(lastCall().body) expect(keys).not.toContain('deviceId') @@ -125,7 +125,7 @@ describe('sendSms', () => { await client().sendSms({ message: 'later', - recipients: ['+251912345678'], + recipients: ['+12025550123'], scheduledAt: farFuture, }) @@ -140,7 +140,7 @@ describe('errors', () => { ) await expect( - client().sendSms({ message: 'x', recipients: ['+251912345678'] }), + client().sendSms({ message: 'x', recipients: ['+12025550123'] }), ).rejects.toMatchObject({ name: 'TextbeeError', status: 400, diff --git a/test/sms-utils.test.ts b/test/sms-utils.test.ts new file mode 100644 index 0000000..7fb3417 --- /dev/null +++ b/test/sms-utils.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, it } from 'vitest' + +import { + countSmsSegments, + findNonGsm7Characters, + getSmsEncoding, + isValidE164, + normalizePhoneNumber, + sanitizeForGsm7, +} from '../src/index' + +const EURO = '\u20AC' +const THUMBS_UP = '\u{1F44D}' +const EM_DASH = '\u2014' +const EN_DASH = '\u2013' +const ELLIPSIS = '\u2026' +const LEFT_DOUBLE_QUOTE = '\u201C' +const RIGHT_DOUBLE_QUOTE = '\u201D' +const LEFT_SINGLE_QUOTE = '\u2018' +const RIGHT_SINGLE_QUOTE = '\u2019' +const NON_BREAKING_SPACE = '\u00A0' +const THIN_SPACE = '\u2009' +const ZERO_WIDTH_SPACE = '\u200B' +const BYTE_ORDER_MARK = '\uFEFF' +const SOFT_HYPHEN = '\u00AD' +const ZERO_WIDTH_JOINER = '\u200D' +const TURKISH_S_CEDILLA = '\u015F' +const I_DIAERESIS = '\u00EF' +const E_ACUTE = '\u00E9' +const LONE_SURROGATE = '\uD83D' + +describe('getSmsEncoding', () => { + it('treats an empty string as gsm-7', () => { + expect(getSmsEncoding('')).toBe('gsm-7') + }) + + it('accepts plain ascii and the accented letters gsm-7 carries', () => { + expect(getSmsEncoding('hello')).toBe('gsm-7') + expect(getSmsEncoding('é ü ñ à Ω')).toBe('gsm-7') + }) + + it('accepts every extension table character', () => { + for (const char of ['|', '^', EURO, '{', '}', '[', ']', '~', '\\']) { + expect(getSmsEncoding(char)).toBe('gsm-7') + } + }) + + it('falls back to ucs-2 for characters outside the alphabet', () => { + expect(getSmsEncoding(TURKISH_S_CEDILLA)).toBe('ucs-2') + expect(getSmsEncoding(EM_DASH)).toBe('ucs-2') + expect(getSmsEncoding(THUMBS_UP)).toBe('ucs-2') + expect(getSmsEncoding('`')).toBe('ucs-2') + expect(getSmsEncoding(LONE_SURROGATE)).toBe('ucs-2') + }) +}) + +describe('countSmsSegments, gsm-7', () => { + it('reports zero segments for an empty message', () => { + expect(countSmsSegments('')).toEqual({ + encoding: 'gsm-7', + length: 0, + segments: 0, + remainingInSegment: 160, + }) + }) + + it('counts a short message', () => { + expect(countSmsSegments('hello')).toEqual({ + encoding: 'gsm-7', + length: 5, + segments: 1, + remainingInSegment: 155, + }) + }) + + it('fits exactly 160 characters in one segment', () => { + expect(countSmsSegments('a'.repeat(160))).toEqual({ + encoding: 'gsm-7', + length: 160, + segments: 1, + remainingInSegment: 0, + }) + }) + + it('splits at 161 characters and drops to the 153 limit', () => { + expect(countSmsSegments('a'.repeat(161))).toEqual({ + encoding: 'gsm-7', + length: 161, + segments: 2, + remainingInSegment: 145, + }) + }) + + it('fills two segments exactly at 306 and needs a third at 307', () => { + expect(countSmsSegments('a'.repeat(306)).segments).toBe(2) + expect(countSmsSegments('a'.repeat(306)).remainingInSegment).toBe(0) + expect(countSmsSegments('a'.repeat(307)).segments).toBe(3) + }) + + it('charges two septets for extension characters', () => { + expect(countSmsSegments(EURO).length).toBe(2) + expect(countSmsSegments('{}').length).toBe(4) + }) + + it('keeps an extension character inside a single segment at the limit', () => { + expect(countSmsSegments('a'.repeat(158) + EURO)).toEqual({ + encoding: 'gsm-7', + length: 160, + segments: 1, + remainingInSegment: 0, + }) + expect(countSmsSegments('a'.repeat(159) + EURO).segments).toBe(2) + }) + + it('never straddles an extension character across a boundary', () => { + const message = 'a'.repeat(152) + EURO + 'a'.repeat(152) + + // 306 septets would fit two 153 septet segments, but the euro sign cannot + // be split, so one septet is wasted and a third segment is needed. + expect(countSmsSegments(message)).toEqual({ + encoding: 'gsm-7', + length: 306, + segments: 3, + remainingInSegment: 152, + }) + }) + + it('counts newline and carriage return as one septet each', () => { + expect(countSmsSegments('a\nb\rc')).toEqual({ + encoding: 'gsm-7', + length: 5, + segments: 1, + remainingInSegment: 155, + }) + }) +}) + +describe('countSmsSegments, ucs-2', () => { + it('counts an astral character as two code units', () => { + expect(countSmsSegments(THUMBS_UP)).toEqual({ + encoding: 'ucs-2', + length: 2, + segments: 1, + remainingInSegment: 68, + }) + }) + + it('flips the whole message to ucs-2 when one character requires it', () => { + expect(countSmsSegments(`hello ${THUMBS_UP}`)).toEqual({ + encoding: 'ucs-2', + length: 8, + segments: 1, + remainingInSegment: 62, + }) + }) + + it('fits exactly 70 code units in one segment and splits at 71', () => { + expect(countSmsSegments(TURKISH_S_CEDILLA.repeat(70))).toEqual({ + encoding: 'ucs-2', + length: 70, + segments: 1, + remainingInSegment: 0, + }) + expect(countSmsSegments(TURKISH_S_CEDILLA.repeat(71))).toEqual({ + encoding: 'ucs-2', + length: 71, + segments: 2, + remainingInSegment: 63, + }) + }) + + it('never straddles a surrogate pair across a boundary', () => { + const message = 'a'.repeat(66) + THUMBS_UP + 'a'.repeat(100) + + expect(countSmsSegments(message)).toEqual({ + encoding: 'ucs-2', + length: 168, + segments: 3, + remainingInSegment: 32, + }) + }) + + it('handles a lone surrogate without crashing', () => { + expect(countSmsSegments(LONE_SURROGATE)).toEqual({ + encoding: 'ucs-2', + length: 1, + segments: 1, + remainingInSegment: 69, + }) + }) +}) + +describe('findNonGsm7Characters', () => { + it('returns nothing for a gsm-7 safe message', () => { + expect(findNonGsm7Characters('hello, world!')).toEqual([]) + }) + + it('lists unique offenders in first appearance order', () => { + const message = `${LEFT_DOUBLE_QUOTE}hi${RIGHT_DOUBLE_QUOTE} ${EM_DASH} ${THUMBS_UP} ${LEFT_DOUBLE_QUOTE}again${RIGHT_DOUBLE_QUOTE}` + + expect(findNonGsm7Characters(message)).toEqual([ + LEFT_DOUBLE_QUOTE, + RIGHT_DOUBLE_QUOTE, + EM_DASH, + THUMBS_UP, + ]) + }) + + it('reports a lone surrogate', () => { + expect(findNonGsm7Characters(LONE_SURROGATE)).toEqual([LONE_SURROGATE]) + }) +}) + +describe('sanitizeForGsm7', () => { + it('returns gsm-7 safe input unchanged', () => { + expect(sanitizeForGsm7('')).toBe('') + expect(sanitizeForGsm7('Your code is 1234.')).toBe('Your code is 1234.') + expect(sanitizeForGsm7('café über señor')).toBe('café über señor') + }) + + it('straightens curly quotes', () => { + const message = `${LEFT_SINGLE_QUOTE}a${RIGHT_SINGLE_QUOTE} ${LEFT_DOUBLE_QUOTE}b${RIGHT_DOUBLE_QUOTE}` + + expect(sanitizeForGsm7(message)).toBe(`'a' "b"`) + }) + + it('replaces dashes and the ellipsis', () => { + expect(sanitizeForGsm7(`a${EN_DASH}b${EM_DASH}c`)).toBe('a-b-c') + expect(sanitizeForGsm7(`wait${ELLIPSIS}`)).toBe('wait...') + }) + + it('normalizes exotic spaces and drops invisible characters', () => { + expect(sanitizeForGsm7(`a${NON_BREAKING_SPACE}b${THIN_SPACE}c`)).toBe( + 'a b c', + ) + expect( + sanitizeForGsm7(`a${ZERO_WIDTH_SPACE}${BYTE_ORDER_MARK}${SOFT_HYPHEN}b`), + ).toBe('ab') + }) + + it('rescues a message from ucs-2', () => { + const message = `${LEFT_DOUBLE_QUOTE}Hi${RIGHT_DOUBLE_QUOTE}${ELLIPSIS}` + + expect(countSmsSegments(message).encoding).toBe('ucs-2') + expect(countSmsSegments(sanitizeForGsm7(message)).encoding).toBe('gsm-7') + }) + + it('preserves zero width joiners so emoji sequences survive', () => { + const family = `👨${ZERO_WIDTH_JOINER}👩${ZERO_WIDTH_JOINER}👧` + + expect(sanitizeForGsm7(family)).toBe(family) + }) + + it('leaves unmapped characters alone by default', () => { + expect(sanitizeForGsm7(`na${I_DIAERESIS}ve`)).toBe(`na${I_DIAERESIS}ve`) + expect(sanitizeForGsm7(TURKISH_S_CEDILLA)).toBe(TURKISH_S_CEDILLA) + }) + + it('strips accents when asked', () => { + const options = { transliterateAccents: true } + + expect(sanitizeForGsm7(`na${I_DIAERESIS}ve`, options)).toBe('naive') + expect(sanitizeForGsm7(TURKISH_S_CEDILLA, options)).toBe('s') + expect(sanitizeForGsm7('Łódź', options)).toBe('Lodz') + expect(sanitizeForGsm7('Œuvre', options)).toBe('OEuvre') + }) + + it('keeps letters gsm-7 already carries even when transliterating', () => { + const options = { transliterateAccents: true } + + expect(sanitizeForGsm7(`caf${E_ACUTE}`, options)).toBe(`caf${E_ACUTE}`) + expect(sanitizeForGsm7('über señor à', options)).toBe('über señor à') + }) + + it('does not touch non-latin scripts or emoji when transliterating', () => { + const options = { transliterateAccents: true } + + expect(sanitizeForGsm7('こんにちは', options)).toBe('こんにちは') + expect(sanitizeForGsm7('مرحبا', options)).toBe('مرحبا') + expect(sanitizeForGsm7(THUMBS_UP, options)).toBe(THUMBS_UP) + }) + + it('is idempotent', () => { + const message = `${LEFT_DOUBLE_QUOTE}a${RIGHT_DOUBLE_QUOTE}${EM_DASH}b${ELLIPSIS}${NON_BREAKING_SPACE}c` + const once = sanitizeForGsm7(message) + + expect(sanitizeForGsm7(once)).toBe(once) + }) +}) + +describe('isValidE164', () => { + it('accepts well formed numbers', () => { + expect(isValidE164('+12025550123')).toBe(true) + expect(isValidE164('+12')).toBe(true) + expect(isValidE164('+123456789012345')).toBe(true) + }) + + it('rejects anything else', () => { + expect(isValidE164('')).toBe(false) + expect(isValidE164('+')).toBe(false) + expect(isValidE164('+1')).toBe(false) + expect(isValidE164('+02025550123')).toBe(false) + expect(isValidE164('12025550123')).toBe(false) + expect(isValidE164('+1202 555 0123')).toBe(false) + expect(isValidE164('+1234567890123456')).toBe(false) + expect(isValidE164('0012025550123')).toBe(false) + expect(isValidE164(' +12 ')).toBe(false) + }) +}) + +describe('normalizePhoneNumber', () => { + it('strips formatting from an international number', () => { + expect(normalizePhoneNumber('+1 202-555-0123')).toBe('+12025550123') + expect(normalizePhoneNumber('+1 (202) 555.0123')).toBe('+12025550123') + expect(normalizePhoneNumber(' +12025550123 ')).toBe('+12025550123') + }) + + it('converts a leading 00 to a plus', () => { + expect(normalizePhoneNumber('0012025550123')).toBe('+12025550123') + expect(normalizePhoneNumber('00 1 202 555 0123')).toBe('+12025550123') + }) + + it('applies a default country code to national input', () => { + expect( + normalizePhoneNumber('(202) 555-0123', { defaultCountryCode: '1' }), + ).toBe('+12025550123') + expect( + normalizePhoneNumber('2025550123', { defaultCountryCode: '+1' }), + ).toBe('+12025550123') + }) + + it('drops a single trunk zero', () => { + expect( + normalizePhoneNumber('020 7946 0123', { defaultCountryCode: '44' }), + ).toBe('+442079460123') + }) + + it('returns null for national input without a country code', () => { + expect(normalizePhoneNumber('2025550123')).toBeNull() + expect(normalizePhoneNumber('020 7946 0123')).toBeNull() + }) + + it('returns null for input it cannot normalize', () => { + expect(normalizePhoneNumber('')).toBeNull() + expect(normalizePhoneNumber(' ')).toBeNull() + expect(normalizePhoneNumber('not a number')).toBeNull() + expect(normalizePhoneNumber('+1 202 555 0123 ext 22')).toBeNull() + expect(normalizePhoneNumber('+1+2025550123')).toBeNull() + expect(normalizePhoneNumber('+02025550123')).toBeNull() + expect(normalizePhoneNumber('+1202555012345678')).toBeNull() + }) + + it('throws on an unusable default country code', () => { + expect(() => + normalizePhoneNumber('2025550123', { defaultCountryCode: '0' }), + ).toThrow(TypeError) + expect(() => + normalizePhoneNumber('2025550123', { defaultCountryCode: 'abc' }), + ).toThrow(TypeError) + }) +}) diff --git a/test/webhooks.test.ts b/test/webhooks.test.ts index 94df3d1..a834685 100644 --- a/test/webhooks.test.ts +++ b/test/webhooks.test.ts @@ -7,7 +7,7 @@ const SIGNING_SECRET = 'whsec_test_secret' const payload = { event: 'MESSAGE_RECEIVED', smsId: '65f0000000000000000000bb', - sender: '+251912345678', + sender: '+12025550123', message: 'hello', }