@askeleven/sms-guard 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AskEleven
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,267 @@
1
+ # sms-guard
2
+
3
+ ![sms-guard: check a text message before your agent sends it](https://raw.githubusercontent.com/askeleven/sms-guard/main/docs/social-preview.png)
4
+
5
+ **Check a text message before your agent sends it, and find out whether it will arrive.**
6
+
7
+ ```bash
8
+ npx @askeleven/sms-guard "Hi Ana, your table for 4 is booked for 7pm." --to +13055550100
9
+ ```
10
+
11
+ An agent that writes a long, friendly text does not know it just became twelve SMS
12
+ segments. The provider's API accepts it and bills every segment. Some carriers then
13
+ refuse to deliver it, and nothing tells the agent. Or it sends at 11pm, because it runs
14
+ in UTC and the customer does not. sms-guard is the check that runs first: a CLI for
15
+ pipelines, an MCP server for agents, and a library for everything else. It returns a
16
+ verdict, the facts behind it, and the reason for every problem it finds.
17
+
18
+ Zero dependencies. No signup, no account, no network.
19
+
20
+ ---
21
+
22
+ ## What it checks
23
+
24
+ | Check | Verdict |
25
+ |---|---|
26
+ | **Segments**: GSM-7 or UCS-2, counted the way the network counts them, over `--max-segments` (default 5) | Fail |
27
+ | **Send time** outside the recipient's local window (default 08:00-21:00, the federal TCPA calling hours) | Fail |
28
+ | **Invalid number**: not a phone number, or no country code | Fail |
29
+ | **Empty message** | Fail |
30
+ | **Time zone ambiguous**: the area code or country spans zones, and the send time is inside the window in some and outside in others | Review |
31
+ | **Time zone unknown**: toll-free and other non-geographic numbers, countries not in the table | Review |
32
+ | **Public link shorteners** (`bit.ly`, `tinyurl.com`, `t.co` and others), which carriers filter | Review |
33
+ | **No opt-out wording** on a first message (`--first-message`), in English or Spanish | Review |
34
+ | **UCS-2**: which characters force it, and a GSM-7 swap for typographic ones | Note |
35
+
36
+ **Keep** means nothing was found. **Review** means a person should decide. **Fail** means
37
+ do not send it as it stands.
38
+
39
+ Segment counting follows GSM 03.38: 160 septets in one segment, 153 per part once split,
40
+ with the extension characters (`^ { } \ [ ~ ] |` and the euro sign) costing two. One
41
+ character outside that alphabet switches the whole message to UCS-2: 70 in one segment,
42
+ 67 per part, counted in UTF-16 units, so most emoji cost two. An escape pair or a
43
+ surrogate pair never straddles two parts, which is why a message can be one segment
44
+ longer than dividing by 153 suggests.
45
+
46
+ For UCS-2, sms-guard lists every character that forced it, with a count. Curly quotes,
47
+ en and em dashes, the ellipsis character, bullets, and odd spaces get a GSM-7
48
+ replacement that reads the same. Accented letters never do: they are the words. A
49
+ message in Spanish will usually be UCS-2, and that is fine. Budget segments for it.
50
+
51
+ ## What it does not do
52
+
53
+ **It does not send anything, or change anything.** A message that is too long comes
54
+ with a split you can send instead (`--split`), not a rewrite.
55
+
56
+ **It does not know where the phone is.** The time zone comes from the area code, or the
57
+ country code outside +1. A person who moved from Miami to Los Angeles and kept their
58
+ number is on Eastern time as far as sms-guard knows. Where one code covers more than one
59
+ zone, it says so rather than picking one.
60
+
61
+ **It does not use national language shift tables.** Some networks can send Spanish or
62
+ Portuguese accents in GSM-7 with a shift table. Support is patchy, so sms-guard counts
63
+ the way nearly every provider actually sends: UCS-2.
64
+
65
+ **It is not legal advice.** The default window is the federal TCPA calling hours. Some
66
+ states are stricter, and consent is still yours to get.
67
+
68
+ ---
69
+
70
+ ## Example
71
+
72
+ ```
73
+ $ npx @askeleven/sms-guard message.txt --to +13055550100 --at 2026-09-28T01:30:00Z --first-message
74
+
75
+ FAIL Do not send this as it stands
76
+
77
+ Encoding UCS-2
78
+ Length 794 characters, 794 UTF-16 units
79
+ Segments 12 of up to 67 units each (limit 5)
80
+ Recipient +13055550100, Florida, America/New_York
81
+ Local time Sun 21:30 EDT, outside 08:00-21:00
82
+
83
+ FAIL
84
+ Too many segments
85
+ 12 segments (794 UTF-16 units, 67 per segment in UCS-2), over the
86
+ limit of 5. Providers usually accept a long message and bill every
87
+ segment, but some carriers refuse long multi-part messages later and
88
+ the recipient never gets it, so a success response from the API tells
89
+ you nothing. Send it as 4 shorter messages instead (splitMessage, or
90
+ --split), or cut it.
91
+ Outside allowed hours
92
+ It would arrive at Sun 21:30 EDT in America/New_York, outside
93
+ 08:00-21:00. The federal TCPA window for telephone solicitations,
94
+ which covers texts, is 8am to 9pm at the recipient's location, and
95
+ some states are stricter. Schedule it for the recipient's morning
96
+ instead. The window next opens at 2026-09-28T12:00:00.000Z (Mon 08:00
97
+ EDT).
98
+
99
+ REVIEW
100
+ No opt-out wording
101
+ This is the first text to this person and it does not say how to stop
102
+ them, such as "Reply STOP to opt out" (or "Responde STOP para no
103
+ recibir más mensajes"). Carriers and the recipient both expect it on a
104
+ first message.
105
+
106
+ NOTE
107
+ Sent as UCS-2
108
+ UCS-2, because of í U+00ED (7), ó U+00F3 (2), á U+00E1 (2). UCS-2 fits
109
+ 70 characters in one segment instead of 160, and 67 per part instead
110
+ of 153. Letters are never replaced: they are the words. A message in
111
+ Spanish will usually be UCS-2, and that is fine; just budget segments
112
+ for it.
113
+ ```
114
+
115
+ And the same message split:
116
+
117
+ ```
118
+ $ npx @askeleven/sms-guard message.txt --split
119
+
120
+ PASS 4 messages, each within 5 segments. Send them in order.
121
+
122
+ 1/4 3 segments, UCS-2, 198 characters
123
+ Hola María, soy Sofía de Clínica Dental Brisa. ¡Gracias por escribirnos! Vimos tu solicitud sobre la limpieza y el blanqueamiento, y queríamos contarte cómo funciona todo antes de tu primera visita.
124
+
125
+ 2/4 4 segments, UCS-2, 252 characters
126
+ La consulta inicial dura unos 45 minutos. Revisamos tu historial, hacemos una evaluación completa y te explicamos las opciones con precios claros, sin sorpresas. Si tienes seguro, tráelo: aceptamos la mayoría de los planes y te ayudamos con el papeleo.
127
+
128
+ 3/4 4 segments, UCS-2, 264 characters
129
+ Tenemos horarios disponibles este jueves a las 10:00 o a las 16:30, y el sábado por la mañana. Si ninguno te sirve, dinos qué día te queda mejor y buscamos un espacio. También puedes llamarnos o responder a este mensaje con cualquier pregunta, por pequeña que sea.
130
+
131
+ 4/4 2 segments, UCS-2, 74 characters
132
+ ¡Esperamos conocerte pronto! Un saludo del equipo de Clínica Dental Brisa.
133
+ ```
134
+
135
+ The split breaks at blank lines first, then lines, then sentences, then words, and only
136
+ cuts inside a word when one word is too long on its own. Parts are packed as full as the
137
+ breaks allow, and each is counted on its own, so a part with no accents goes back to
138
+ GSM-7.
139
+
140
+ ## Options
141
+
142
+ ```
143
+ --to <number> Recipient, E.164 preferred (+13055550100).
144
+ --at <time> When it will be sent, ISO 8601 with an offset or Z. Default now.
145
+ --max-segments <n> Fail above this many segments. Default 5.
146
+ --window <HH:MM-HH:MM>
147
+ Allowed recipient local time. Default 08:00-21:00.
148
+ --first-message First text to this person: look for opt-out wording.
149
+ --split Print the message split into parts that each fit.
150
+ --json Machine-readable output.
151
+ --no-colour Plain text.
152
+ ```
153
+
154
+ The message is the text on the command line, a file, or standard input (`-`, or piped
155
+ in with no text). Trailing newlines in a file or standard input are dropped.
156
+
157
+ A number without a `+` is only accepted with 10 digits, or 11 starting with 1, and is
158
+ then read as US or Canadian (+1). Anything else needs its country code. Formatting such
159
+ as spaces, dashes, dots and brackets is ignored.
160
+
161
+ Exit code `0` means OK to send, `1` means do not send it as it stands (it failed, or it
162
+ needs a person to review it), `2` means it could not run. With `--split`, `0` means every
163
+ part fits. So it gates a pipeline:
164
+
165
+ ```bash
166
+ npx @askeleven/sms-guard message.txt --to "$TO" && ./send-sms "$TO" message.txt
167
+ ```
168
+
169
+ ## For agents: MCP server
170
+
171
+ ```bash
172
+ npx @askeleven/sms-guard mcp
173
+ ```
174
+
175
+ It serves two tools over stdio: `check_sms` (one message, with an optional recipient,
176
+ send time, segment limit, window, and first-message flag) and `split_sms` (the parts to
177
+ send instead, in order). `check_sms` returns the `verdict`, the `reasons`, each with a
178
+ stable code and a plain-English explanation, and the `facts`, including
179
+ `nextWindowStart` when the send time is outside the window.
180
+
181
+ **Claude Code**
182
+
183
+ ```bash
184
+ claude mcp add sms-guard -- npx -y @askeleven/sms-guard mcp
185
+ ```
186
+
187
+ **Claude Desktop, Cursor, and other clients** that take a JSON config:
188
+
189
+ ```json
190
+ {
191
+ "mcpServers": {
192
+ "sms-guard": {
193
+ "command": "npx",
194
+ "args": ["-y", "@askeleven/sms-guard", "mcp"]
195
+ }
196
+ }
197
+ }
198
+ ```
199
+
200
+ The server tells the model how to use the result: never send a message that failed; if
201
+ it failed on segments, send the parts from `split_sms` instead, in order; if it failed on
202
+ quiet hours, schedule it for the recipient's morning rather than send it now; show
203
+ review reasons to a person rather than deciding.
204
+
205
+ ## As a library
206
+
207
+ ```js
208
+ import { checkMessage, splitMessage } from '@askeleven/sms-guard'
209
+
210
+ const to = '+13055550100'
211
+ const result = checkMessage(text, { to, firstMessage: true })
212
+
213
+ if (result.verdict === 'fail') {
214
+ if (result.reasons.some((r) => r.code === 'too_many_segments')) {
215
+ for (const part of splitMessage(text)) await send(to, part.text)
216
+ }
217
+ // quiet_hours: schedule for result.facts.recipient.nextWindowStart instead
218
+ }
219
+ ```
220
+
221
+ `checkMessage(text, options)` takes `to`, `sendAt` (a `Date`, or an ISO string with an
222
+ offset), `maxSegments`, `window`, `firstMessage`, and `now` (a clock, for tests). Every
223
+ reason has a stable `code` (`too_many_segments`, `quiet_hours`, `timezone_ambiguous`, and
224
+ so on) to match on, and a `detail` to show a person.
225
+
226
+ `encodingOf(text)` returns `'GSM-7'` or `'UCS-2'`. `countSegments(text)` returns the
227
+ encoding, characters, units and segments. `splitMessage(text, { maxSegments })` returns
228
+ the parts, each with its own encoding and segment count.
229
+
230
+ ## Privacy
231
+
232
+ Nothing leaves your machine. There is no network code at all: time zones come from a
233
+ bundled table and the time zone data built into Node.
234
+
235
+ ## Requirements
236
+
237
+ Node 20 or newer. No dependencies.
238
+
239
+ ---
240
+
241
+ ## Why we built it
242
+
243
+ [AskEleven](https://askeleven.com) runs AI employees that text candidates and customers
244
+ for small businesses. One of them wrote a long, friendly first text, with accents, as
245
+ asked. The provider's API accepted every one. A slice of the recipients never got it:
246
+ their carriers refused long multi-part messages, and the only sign was the replies that
247
+ did not come. Nothing about the agent was broken. Nobody told it to count segments.
248
+
249
+ This is that check, and the other ones we wished had run first.
250
+
251
+ ## Contributing
252
+
253
+ Issues and pull requests welcome, especially:
254
+
255
+ - **Wrong time zones.** If an area code or country maps to the wrong zone, or one we list
256
+ as ambiguous is really a single zone, open an issue with a source.
257
+ - **Missing area codes.** New overlays appear every year. A code sms-guard does not know
258
+ is reported as unknown, never guessed.
259
+ - **Opt-out wording** in other languages, and shorteners we are missing.
260
+ - **MCP clients** where the server does not work as documented.
261
+
262
+ Run the tests with `npm test`. They use a fixed clock and no network, so they are offline
263
+ and fast.
264
+
265
+ ## License
266
+
267
+ MIT. See [LICENSE](LICENSE).
package/bin/cli.js ADDED
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { existsSync, statSync } from 'node:fs'
5
+ import {
6
+ checkMessage,
7
+ splitMessage,
8
+ GuardError,
9
+ DEFAULT_MAX_SEGMENTS,
10
+ DEFAULT_WINDOW,
11
+ } from '../src/index.js'
12
+ import { formatText, formatSplit } from '../src/report.js'
13
+ import { serve } from '../src/mcp.js'
14
+ import { VERSION } from '../src/version.js'
15
+
16
+ const USAGE = `
17
+ sms-guard <"message text" | message.txt | ->
18
+ sms-guard mcp
19
+
20
+ Checks a text message before you send it, and says whether it is safe to.
21
+ Counts segments the way the network does (GSM-7 or UCS-2), names the characters
22
+ that force UCS-2, checks the send time against the recipient's local time, and
23
+ flags public link shorteners and a missing opt-out on a first message. Runs
24
+ locally, no network.
25
+
26
+ Options
27
+ --to <number> Recipient, E.164 preferred (+13055550100). A number with
28
+ no + and 10 digits is read as US or Canadian.
29
+ --at <time> When it will be sent, ISO 8601 with an offset or Z.
30
+ Default now.
31
+ --max-segments <n> Fail above this many segments. Default ${DEFAULT_MAX_SEGMENTS}.
32
+ --window <HH:MM-HH:MM>
33
+ Allowed recipient local time. Default ${DEFAULT_WINDOW}.
34
+ --first-message First text to this person: look for opt-out wording.
35
+ --split Print the message split into parts that each fit
36
+ --max-segments, to send in order.
37
+ --json Machine-readable output.
38
+ --no-colour Plain text.
39
+ --version, --help
40
+
41
+ Reads standard input when given "-", or no text and piped input. Trailing
42
+ newlines in a file or standard input are dropped.
43
+
44
+ Exit codes
45
+ 0 OK to send (with --split: every part fits)
46
+ 1 do not send as it stands: failed, or needs a person to review
47
+ 2 could not run
48
+
49
+ Examples
50
+ npx @askeleven/sms-guard "Hi Ana, your table for 4 is booked for 7pm." --to +13055550100
51
+ npx @askeleven/sms-guard message.txt --to +447700900123 --first-message --json
52
+ npx @askeleven/sms-guard message.txt --split
53
+ npx @askeleven/sms-guard mcp # MCP server over stdio, for agents
54
+ `
55
+
56
+ /**
57
+ * @param {string[]} argv
58
+ * @returns {Promise<number>}
59
+ */
60
+ async function main(argv) {
61
+ if (argv[0] === 'mcp') {
62
+ await serve()
63
+ return 0
64
+ }
65
+
66
+ let parsed
67
+ try {
68
+ parsed = parseArgs({
69
+ args: argv,
70
+ allowPositionals: true,
71
+ options: {
72
+ to: { type: 'string' },
73
+ at: { type: 'string' },
74
+ 'max-segments': { type: 'string' },
75
+ window: { type: 'string' },
76
+ 'first-message': { type: 'boolean', default: false },
77
+ split: { type: 'boolean', default: false },
78
+ json: { type: 'boolean', default: false },
79
+ // parseArgs has no --no-x negation, so both spellings are declared explicitly.
80
+ 'no-colour': { type: 'boolean', default: false },
81
+ 'no-color': { type: 'boolean', default: false },
82
+ version: { type: 'boolean', default: false },
83
+ help: { type: 'boolean', default: false },
84
+ },
85
+ })
86
+ } catch (error) {
87
+ process.stderr.write(`${/** @type {Error} */ (error).message}\n${USAGE}`)
88
+ return 2
89
+ }
90
+ const { values, positionals } = parsed
91
+
92
+ if (values.help) {
93
+ process.stdout.write(USAGE)
94
+ return 0
95
+ }
96
+ if (values.version) {
97
+ process.stdout.write(`${VERSION}\n`)
98
+ return 0
99
+ }
100
+
101
+ let maxSegments = DEFAULT_MAX_SEGMENTS
102
+ if (values['max-segments'] !== undefined) {
103
+ maxSegments = Number(values['max-segments'])
104
+ if (!Number.isInteger(maxSegments) || maxSegments < 1) {
105
+ process.stderr.write(`--max-segments must be a whole number of 1 or more, got ${values['max-segments']}\n`)
106
+ return 2
107
+ }
108
+ }
109
+
110
+ let text
111
+ try {
112
+ text = await readInput(positionals)
113
+ } catch (error) {
114
+ process.stderr.write(`${/** @type {Error} */ (error).message}\n`)
115
+ return 2
116
+ }
117
+
118
+ const colour = useColour(values)
119
+
120
+ if (values.split) {
121
+ const parts = splitMessage(text, { maxSegments })
122
+ if (values.json) {
123
+ process.stdout.write(`${JSON.stringify({ maxSegments, parts }, null, 2)}\n`)
124
+ } else if (parts.length === 0) {
125
+ process.stdout.write('\nThe message is empty. Nothing to split.\n\n')
126
+ } else {
127
+ process.stdout.write(formatSplit(parts, { maxSegments, colour }))
128
+ }
129
+ return parts.length > 0 && parts.every((p) => p.segments <= maxSegments) ? 0 : 1
130
+ }
131
+
132
+ let result
133
+ try {
134
+ result = checkMessage(text, {
135
+ to: values.to,
136
+ sendAt: values.at,
137
+ maxSegments,
138
+ window: values.window,
139
+ firstMessage: values['first-message'],
140
+ })
141
+ } catch (error) {
142
+ if (error instanceof GuardError) {
143
+ process.stderr.write(`${error.message}\n`)
144
+ return 2
145
+ }
146
+ throw error
147
+ }
148
+
149
+ if (values.json) {
150
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
151
+ } else {
152
+ process.stdout.write(formatText(result, { colour }))
153
+ }
154
+ return result.verdict === 'keep' ? 0 : 1
155
+ }
156
+
157
+ /**
158
+ * One positional that names a file is read from it; "-", or nothing with input piped
159
+ * in, reads standard input; anything else is the message itself, words joined by
160
+ * single spaces the way the shell split them.
161
+ *
162
+ * @param {string[]} positionals
163
+ * @returns {Promise<string>}
164
+ */
165
+ async function readInput(positionals) {
166
+ if (positionals.length === 0 || (positionals.length === 1 && positionals[0] === '-')) {
167
+ if (process.stdin.isTTY) throw new Error(`Give the message, a file, or pipe it in.\n${USAGE}`)
168
+ return dropTrailingNewlines(await readStdin())
169
+ }
170
+ if (positionals.length === 1 && existsSync(positionals[0]) && statSync(positionals[0]).isFile()) {
171
+ return dropTrailingNewlines(await readFile(positionals[0], 'utf8'))
172
+ }
173
+ return positionals.join(' ')
174
+ }
175
+
176
+ /**
177
+ * @param {string} text
178
+ * @returns {string}
179
+ */
180
+ function dropTrailingNewlines(text) {
181
+ return text.replace(/[\r\n]+$/, '')
182
+ }
183
+
184
+ /** @returns {Promise<string>} */
185
+ async function readStdin() {
186
+ /** @type {Buffer[]} */
187
+ const chunks = []
188
+ for await (const chunk of process.stdin) chunks.push(chunk)
189
+ return Buffer.concat(chunks).toString('utf8')
190
+ }
191
+
192
+ /**
193
+ * Honours --no-colour, --no-color, NO_COLOR, and a non-TTY stdout.
194
+ *
195
+ * @param {Record<string, unknown>} values
196
+ * @returns {boolean}
197
+ */
198
+ function useColour(values) {
199
+ if (values['no-colour'] || values['no-color']) return false
200
+ if (process.env.NO_COLOR) return false
201
+ return process.stdout.isTTY === true
202
+ }
203
+
204
+ main(process.argv.slice(2)).then(
205
+ (code) => {
206
+ process.exitCode = code
207
+ },
208
+ (error) => {
209
+ process.stderr.write(`${error?.stack ?? error}\n`)
210
+ process.exitCode = 2
211
+ },
212
+ )
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@askeleven/sms-guard",
3
+ "version": "0.1.0",
4
+ "description": "Check a text message before an agent (or a person) sends it: GSM-7 or UCS-2, segment count, the recipient's local time, shorteners and opt-out wording. Splits long messages. CLI, library, and MCP server. Zero dependencies.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "AskEleven",
8
+ "homepage": "https://github.com/askeleven/sms-guard",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/askeleven/sms-guard.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/askeleven/sms-guard/issues"
15
+ },
16
+ "keywords": [
17
+ "sms",
18
+ "text-message",
19
+ "gsm-7",
20
+ "ucs-2",
21
+ "sms-segments",
22
+ "tcpa",
23
+ "quiet-hours",
24
+ "deliverability",
25
+ "mcp",
26
+ "mcp-server",
27
+ "ai-agents"
28
+ ],
29
+ "bin": {
30
+ "sms-guard": "bin/cli.js"
31
+ },
32
+ "main": "src/index.js",
33
+ "exports": {
34
+ ".": {
35
+ "types": "./types/index.d.ts",
36
+ "default": "./src/index.js"
37
+ }
38
+ },
39
+ "files": [
40
+ "bin",
41
+ "src",
42
+ "types",
43
+ "README.md",
44
+ "LICENSE"
45
+ ],
46
+ "engines": {
47
+ "node": ">=20"
48
+ },
49
+ "scripts": {
50
+ "test": "node --test",
51
+ "build:types": "tsc -p tsconfig.json",
52
+ "prepublishOnly": "npm run build:types && npm test"
53
+ },
54
+ "types": "types/index.d.ts",
55
+ "devDependencies": {
56
+ "@types/node": "^22.20.2",
57
+ "typescript": "^5.9.3"
58
+ }
59
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Country calling codes (outside +1) mapped to IANA time zones.
3
+ *
4
+ * A country with more than one zone lists them all and is reported as ambiguous. The
5
+ * number alone cannot say whether a Spanish mobile is in Madrid or the Canary Islands,
6
+ * so sms-guard does not guess. Countries not listed here are reported as unknown.
7
+ *
8
+ * Calling codes are prefix-free, so a number matches at most one row.
9
+ *
10
+ * @type {Record<string, [string, string | string[]]>}
11
+ */
12
+ const ROWS = {
13
+ // Europe
14
+ 30: ['Greece', 'Europe/Athens'],
15
+ 31: ['Netherlands', 'Europe/Amsterdam'],
16
+ 32: ['Belgium', 'Europe/Brussels'],
17
+ 33: ['France', 'Europe/Paris'],
18
+ 34: ['Spain', ['Europe/Madrid', 'Atlantic/Canary']],
19
+ 36: ['Hungary', 'Europe/Budapest'],
20
+ 39: ['Italy', 'Europe/Rome'],
21
+ 40: ['Romania', 'Europe/Bucharest'],
22
+ 41: ['Switzerland', 'Europe/Zurich'],
23
+ 43: ['Austria', 'Europe/Vienna'],
24
+ 44: ['United Kingdom', 'Europe/London'],
25
+ 45: ['Denmark', 'Europe/Copenhagen'],
26
+ 46: ['Sweden', 'Europe/Stockholm'],
27
+ 47: ['Norway', 'Europe/Oslo'],
28
+ 48: ['Poland', 'Europe/Warsaw'],
29
+ 49: ['Germany', 'Europe/Berlin'],
30
+ 351: ['Portugal', ['Europe/Lisbon', 'Atlantic/Azores']],
31
+ 352: ['Luxembourg', 'Europe/Luxembourg'],
32
+ 353: ['Ireland', 'Europe/Dublin'],
33
+ 358: ['Finland', 'Europe/Helsinki'],
34
+ 420: ['Czechia', 'Europe/Prague'],
35
+
36
+ // Latin America
37
+ 52: ['Mexico', ['America/Mexico_City', 'America/Cancun', 'America/Chihuahua', 'America/Hermosillo', 'America/Mazatlan', 'America/Tijuana']],
38
+ 54: ['Argentina', 'America/Argentina/Buenos_Aires'],
39
+ 55: ['Brazil', ['America/Sao_Paulo', 'America/Manaus', 'America/Cuiaba', 'America/Rio_Branco', 'America/Noronha']],
40
+ 56: ['Chile', ['America/Santiago', 'America/Punta_Arenas', 'Pacific/Easter']],
41
+ 57: ['Colombia', 'America/Bogota'],
42
+ 58: ['Venezuela', 'America/Caracas'],
43
+ 502: ['Guatemala', 'America/Guatemala'],
44
+ 503: ['El Salvador', 'America/El_Salvador'],
45
+ 504: ['Honduras', 'America/Tegucigalpa'],
46
+ 505: ['Nicaragua', 'America/Managua'],
47
+ 506: ['Costa Rica', 'America/Costa_Rica'],
48
+ 507: ['Panama', 'America/Panama'],
49
+ 51: ['Peru', 'America/Lima'],
50
+ 591: ['Bolivia', 'America/La_Paz'],
51
+ 593: ['Ecuador', ['America/Guayaquil', 'Pacific/Galapagos']],
52
+ 595: ['Paraguay', 'America/Asuncion'],
53
+ 598: ['Uruguay', 'America/Montevideo'],
54
+
55
+ // Elsewhere
56
+ 27: ['South Africa', 'Africa/Johannesburg'],
57
+ 61: ['Australia', ['Australia/Sydney', 'Australia/Brisbane', 'Australia/Adelaide', 'Australia/Darwin', 'Australia/Perth']],
58
+ 63: ['Philippines', 'Asia/Manila'],
59
+ 64: ['New Zealand', ['Pacific/Auckland', 'Pacific/Chatham']],
60
+ 65: ['Singapore', 'Asia/Singapore'],
61
+ 81: ['Japan', 'Asia/Tokyo'],
62
+ 82: ['South Korea', 'Asia/Seoul'],
63
+ 86: ['China', 'Asia/Shanghai'],
64
+ 91: ['India', 'Asia/Kolkata'],
65
+ 234: ['Nigeria', 'Africa/Lagos'],
66
+ 254: ['Kenya', 'Africa/Nairobi'],
67
+ 852: ['Hong Kong', 'Asia/Hong_Kong'],
68
+ 971: ['United Arab Emirates', 'Asia/Dubai'],
69
+ 972: ['Israel', 'Asia/Jerusalem'],
70
+ }
71
+
72
+ /** @type {Map<string, import('./nanp.js').ZoneEntry>} */
73
+ export const COUNTRY_CODES = new Map(
74
+ Object.entries(ROWS).map(([code, [region, zone]]) => [
75
+ code,
76
+ { region, zones: Array.isArray(zone) ? zone : [zone] },
77
+ ]),
78
+ )