@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 +21 -0
- package/README.md +267 -0
- package/bin/cli.js +212 -0
- package/package.json +59 -0
- package/src/data/countries.js +78 -0
- package/src/data/gsm.js +53 -0
- package/src/data/nanp.js +271 -0
- package/src/data/shorteners.js +10 -0
- package/src/index.js +420 -0
- package/src/mcp.js +289 -0
- package/src/phone.js +213 -0
- package/src/report.js +165 -0
- package/src/segments.js +139 -0
- package/src/split.js +127 -0
- package/src/version.js +6 -0
- package/types/data/countries.d.ts +2 -0
- package/types/data/gsm.d.ts +17 -0
- package/types/data/nanp.d.ts +17 -0
- package/types/data/shorteners.d.ts +5 -0
- package/types/index.d.ts +240 -0
- package/types/mcp.d.ts +23 -0
- package/types/phone.d.ts +121 -0
- package/types/report.d.ts +22 -0
- package/types/segments.d.ts +115 -0
- package/types/split.d.ts +46 -0
- package/types/version.d.ts +2 -0
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
|
+

|
|
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
|
+
)
|