@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/src/index.js
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { countSegments, ucs2Characters, replaceTypographic, LIMITS } from './segments.js'
|
|
2
|
+
import { splitMessage, DEFAULT_MAX_SEGMENTS } from './split.js'
|
|
3
|
+
import {
|
|
4
|
+
parseNumber,
|
|
5
|
+
zonesFor,
|
|
6
|
+
parseWindow,
|
|
7
|
+
inWindow,
|
|
8
|
+
localTime,
|
|
9
|
+
nextWindowStart,
|
|
10
|
+
} from './phone.js'
|
|
11
|
+
import { SHORTENER_DOMAINS } from './data/shorteners.js'
|
|
12
|
+
|
|
13
|
+
export { encodingOf, countSegments, ucs2Characters, LIMITS } from './segments.js'
|
|
14
|
+
export { splitMessage, DEFAULT_MAX_SEGMENTS } from './split.js'
|
|
15
|
+
export { parseNumber } from './phone.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {import('./segments.js').Encoding} Encoding
|
|
19
|
+
* @typedef {import('./segments.js').SegmentCount} SegmentCount
|
|
20
|
+
* @typedef {import('./segments.js').Ucs2Character} Ucs2Character
|
|
21
|
+
* @typedef {import('./split.js').Part} Part
|
|
22
|
+
* @typedef {import('./split.js').SplitOptions} SplitOptions
|
|
23
|
+
* @typedef {import('./phone.js').LocalTime} LocalTime
|
|
24
|
+
*
|
|
25
|
+
* @typedef {'keep'|'review'|'fail'} Verdict
|
|
26
|
+
*
|
|
27
|
+
* @typedef {'empty'|'too_many_segments'|'invalid_number'|'quiet_hours'
|
|
28
|
+
* |'timezone_ambiguous'|'timezone_unknown'|'url_shortener'|'no_opt_out'
|
|
29
|
+
* |'ucs2'} ReasonCode
|
|
30
|
+
*
|
|
31
|
+
* @typedef {object} Reason
|
|
32
|
+
* @property {ReasonCode} code Stable, safe to match on.
|
|
33
|
+
* @property {Verdict} verdict What this reason alone would make of the message.
|
|
34
|
+
* @property {string} detail Plain English.
|
|
35
|
+
*
|
|
36
|
+
* @typedef {object} Recipient
|
|
37
|
+
* @property {string} input As given.
|
|
38
|
+
* @property {string | null} number E.164, or null when it could not be read.
|
|
39
|
+
* @property {boolean} assumedNanp Read as +1 because it had no country code and
|
|
40
|
+
* 10 digits (or 11 starting with 1).
|
|
41
|
+
* @property {string | null} region State, province or country, when known.
|
|
42
|
+
* @property {string[]} timezones Every IANA zone the number could be in.
|
|
43
|
+
* @property {string | null} timezone The zone, when there is exactly one.
|
|
44
|
+
* @property {Array<LocalTime & { inWindow: boolean }>} local Local send time in each
|
|
45
|
+
* of the zones.
|
|
46
|
+
* @property {boolean | null} inWindow Whether the send time is inside the window in
|
|
47
|
+
* every zone; null when no zone is known.
|
|
48
|
+
* @property {string | null} nextWindowStart ISO 8601, the first minute after the send
|
|
49
|
+
* time that is inside the window in every zone, when the send time is not.
|
|
50
|
+
*
|
|
51
|
+
* @typedef {object} Facts
|
|
52
|
+
* @property {Encoding} encoding
|
|
53
|
+
* @property {number} characters Unicode code points.
|
|
54
|
+
* @property {number} units Septets for GSM-7, UTF-16 code units for UCS-2.
|
|
55
|
+
* @property {number} segments
|
|
56
|
+
* @property {number} perSegment Units per segment at this length.
|
|
57
|
+
* @property {number} maxSegments
|
|
58
|
+
* @property {Ucs2Character[]} ucs2Characters What forces UCS-2, if anything.
|
|
59
|
+
* @property {string} sendAt ISO 8601.
|
|
60
|
+
* @property {string} window HH:MM-HH:MM, recipient local time.
|
|
61
|
+
* @property {'checked'|'skipped'} timeCheck Whether the send time was checked
|
|
62
|
+
* against the recipient's local time. Skipped with no recipient, or when their
|
|
63
|
+
* time zone is not known.
|
|
64
|
+
* @property {Recipient | null} recipient
|
|
65
|
+
*
|
|
66
|
+
* @typedef {object} Result
|
|
67
|
+
* @property {Verdict} verdict The worst verdict among the reasons; 'keep' with none.
|
|
68
|
+
* @property {Reason[]} reasons
|
|
69
|
+
* @property {Facts} facts
|
|
70
|
+
* @property {string[]} notes What the reader needs to know to trust the result,
|
|
71
|
+
* such as checks that did not run.
|
|
72
|
+
*
|
|
73
|
+
* @typedef {object} CheckOptions
|
|
74
|
+
* @property {string} [to] Recipient phone number, E.164 preferred.
|
|
75
|
+
* @property {Date | string | number} [sendAt] When it will be sent. A string must be
|
|
76
|
+
* ISO 8601 with an offset or Z. Default now.
|
|
77
|
+
* @property {number} [maxSegments] Fail above this many segments. Default 5.
|
|
78
|
+
* @property {string} [window] Allowed local send time, HH:MM-HH:MM, start inclusive,
|
|
79
|
+
* end exclusive. Default 08:00-21:00.
|
|
80
|
+
* @property {boolean} [firstMessage] The first text to this person: look for opt-out
|
|
81
|
+
* wording. Default false.
|
|
82
|
+
* @property {Date | (() => Date)} [now] The clock, for tests.
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
export const DEFAULT_WINDOW = '08:00-21:00'
|
|
86
|
+
|
|
87
|
+
const VERDICT_ORDER = { keep: 0, review: 1, fail: 2 }
|
|
88
|
+
|
|
89
|
+
export class GuardError extends Error {
|
|
90
|
+
/**
|
|
91
|
+
* @param {'invalid_option'} code
|
|
92
|
+
* @param {string} message
|
|
93
|
+
*/
|
|
94
|
+
constructor(code, message) {
|
|
95
|
+
super(message)
|
|
96
|
+
this.name = 'GuardError'
|
|
97
|
+
this.code = code
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Checks one text message before it is sent: how many segments it will be, what
|
|
103
|
+
* forces it into UCS-2, whether it lands inside the recipient's allowed hours, and
|
|
104
|
+
* a few things carriers and recipients object to. Runs locally, touches no network.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} text
|
|
107
|
+
* @param {CheckOptions} [options]
|
|
108
|
+
* @returns {Result}
|
|
109
|
+
*/
|
|
110
|
+
export function checkMessage(text, options = {}) {
|
|
111
|
+
const {
|
|
112
|
+
to,
|
|
113
|
+
maxSegments = DEFAULT_MAX_SEGMENTS,
|
|
114
|
+
window = DEFAULT_WINDOW,
|
|
115
|
+
firstMessage = false,
|
|
116
|
+
} = options
|
|
117
|
+
if (!Number.isInteger(maxSegments) || maxSegments < 1) {
|
|
118
|
+
throw new GuardError('invalid_option', `maxSegments must be a whole number of 1 or more, got ${maxSegments}.`)
|
|
119
|
+
}
|
|
120
|
+
const win = parseWindow(window)
|
|
121
|
+
if (!win) {
|
|
122
|
+
throw new GuardError('invalid_option', `window must be HH:MM-HH:MM, such as ${DEFAULT_WINDOW}, got ${window}.`)
|
|
123
|
+
}
|
|
124
|
+
const sendAt = resolveSendAt(options.sendAt, options.now)
|
|
125
|
+
|
|
126
|
+
const body = String(text ?? '')
|
|
127
|
+
const count = countSegments(body)
|
|
128
|
+
const ucs2 = ucs2Characters(body)
|
|
129
|
+
|
|
130
|
+
/** @type {Reason[]} */
|
|
131
|
+
const reasons = []
|
|
132
|
+
/** @type {string[]} */
|
|
133
|
+
const notes = []
|
|
134
|
+
const add = (/** @type {ReasonCode} */ code, /** @type {Verdict} */ verdict, /** @type {string} */ detail) =>
|
|
135
|
+
reasons.push({ code, verdict, detail })
|
|
136
|
+
|
|
137
|
+
if (!body.trim()) {
|
|
138
|
+
add('empty', 'fail', 'The message is empty. Nothing would arrive, or a blank text would.')
|
|
139
|
+
} else {
|
|
140
|
+
if (count.segments > maxSegments) {
|
|
141
|
+
add('too_many_segments', 'fail', segmentsDetail(body, count, ucs2, maxSegments))
|
|
142
|
+
}
|
|
143
|
+
if (ucs2.length > 0) add('ucs2', 'keep', ucs2Detail(body, count, ucs2))
|
|
144
|
+
|
|
145
|
+
const shorteners = findShorteners(body)
|
|
146
|
+
if (shorteners.length > 0) {
|
|
147
|
+
add(
|
|
148
|
+
'url_shortener',
|
|
149
|
+
'review',
|
|
150
|
+
`Links on ${shorteners.join(', ')}: carriers filter public shorteners, since anyone ` +
|
|
151
|
+
'can hide anything behind one, and the text can be dropped without an error. ' +
|
|
152
|
+
'Use the full link, or a short link on your own domain.',
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
if (firstMessage && !hasOptOut(body)) {
|
|
156
|
+
add(
|
|
157
|
+
'no_opt_out',
|
|
158
|
+
'review',
|
|
159
|
+
'This is the first text to this person and it does not say how to stop them, such ' +
|
|
160
|
+
'as "Reply STOP to opt out" (or "Responde STOP para no recibir más mensajes"). ' +
|
|
161
|
+
'Carriers and the recipient both expect it on a first message.',
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** @type {Facts['timeCheck']} */
|
|
167
|
+
let timeCheck = 'skipped'
|
|
168
|
+
/** @type {Recipient | null} */
|
|
169
|
+
let recipient = null
|
|
170
|
+
if (to === undefined || to === null || String(to).trim() === '') {
|
|
171
|
+
notes.push('No recipient was given, so the send time was not checked against their local time.')
|
|
172
|
+
} else {
|
|
173
|
+
;({ recipient, timeCheck } = checkRecipient(String(to), sendAt, win, add, notes))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const verdict = reasons.reduce(
|
|
177
|
+
(worst, r) => (VERDICT_ORDER[r.verdict] > VERDICT_ORDER[worst] ? r.verdict : worst),
|
|
178
|
+
/** @type {Verdict} */ ('keep'),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
verdict,
|
|
183
|
+
reasons,
|
|
184
|
+
facts: {
|
|
185
|
+
encoding: count.encoding,
|
|
186
|
+
characters: count.characters,
|
|
187
|
+
units: count.units,
|
|
188
|
+
segments: count.segments,
|
|
189
|
+
perSegment: count.perSegment,
|
|
190
|
+
maxSegments,
|
|
191
|
+
ucs2Characters: ucs2,
|
|
192
|
+
sendAt: sendAt.toISOString(),
|
|
193
|
+
window: win.label,
|
|
194
|
+
timeCheck,
|
|
195
|
+
recipient,
|
|
196
|
+
},
|
|
197
|
+
notes,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {string} to
|
|
203
|
+
* @param {Date} sendAt
|
|
204
|
+
* @param {import('./phone.js').Window} win
|
|
205
|
+
* @param {(code: ReasonCode, verdict: Verdict, detail: string) => void} add
|
|
206
|
+
* @param {string[]} notes
|
|
207
|
+
* @returns {{ recipient: Recipient, timeCheck: Facts['timeCheck'] }}
|
|
208
|
+
*/
|
|
209
|
+
function checkRecipient(to, sendAt, win, add, notes) {
|
|
210
|
+
/** @type {Recipient} */
|
|
211
|
+
const recipient = {
|
|
212
|
+
input: to,
|
|
213
|
+
number: null,
|
|
214
|
+
assumedNanp: false,
|
|
215
|
+
region: null,
|
|
216
|
+
timezones: [],
|
|
217
|
+
timezone: null,
|
|
218
|
+
local: [],
|
|
219
|
+
inWindow: null,
|
|
220
|
+
nextWindowStart: null,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const parsed = parseNumber(to)
|
|
224
|
+
if (!parsed.ok) {
|
|
225
|
+
add('invalid_number', 'fail', parsed.error)
|
|
226
|
+
return { recipient, timeCheck: 'skipped' }
|
|
227
|
+
}
|
|
228
|
+
recipient.number = parsed.number.e164
|
|
229
|
+
recipient.assumedNanp = parsed.number.assumedNanp
|
|
230
|
+
if (parsed.number.assumedNanp) {
|
|
231
|
+
notes.push(`${to} had no country code, so it was read as a US or Canadian number, ${parsed.number.e164}.`)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const { region, zones, basis } = zonesFor(parsed.number)
|
|
235
|
+
recipient.region = region
|
|
236
|
+
recipient.timezones = zones
|
|
237
|
+
recipient.timezone = zones.length === 1 ? zones[0] : null
|
|
238
|
+
|
|
239
|
+
if (zones.length === 0) {
|
|
240
|
+
add(
|
|
241
|
+
'timezone_unknown',
|
|
242
|
+
'review',
|
|
243
|
+
`${basis} Its local time is unknown, so whether ${sendAt.toISOString()} is inside ` +
|
|
244
|
+
`${win.label} there could not be checked.`,
|
|
245
|
+
)
|
|
246
|
+
return { recipient, timeCheck: 'skipped' }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
recipient.local = zones.map((z) => {
|
|
250
|
+
const t = localTime(sendAt, z)
|
|
251
|
+
return { ...t, inWindow: inWindow(t.minutes, win) }
|
|
252
|
+
})
|
|
253
|
+
const inside = recipient.local.filter((t) => t.inWindow)
|
|
254
|
+
recipient.inWindow = inside.length === recipient.local.length
|
|
255
|
+
if (!recipient.inWindow) {
|
|
256
|
+
recipient.nextWindowStart = nextWindowStart(sendAt, zones, win)?.toISOString() ?? null
|
|
257
|
+
}
|
|
258
|
+
const where = region ? ` (${region})` : ''
|
|
259
|
+
const times = recipient.local.map((t) => `${t.label} in ${t.timezone}`).join(', ')
|
|
260
|
+
let next = ''
|
|
261
|
+
if (recipient.nextWindowStart) {
|
|
262
|
+
const opens = new Date(recipient.nextWindowStart)
|
|
263
|
+
const at = zones.map((z) => localTime(opens, z).label).join(', ')
|
|
264
|
+
next =
|
|
265
|
+
zones.length === 1
|
|
266
|
+
? ` The window next opens at ${recipient.nextWindowStart} (${at}).`
|
|
267
|
+
: ` The earliest time inside it in every one of them is ${recipient.nextWindowStart} (${at}).`
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (inside.length === 0) {
|
|
271
|
+
add(
|
|
272
|
+
'quiet_hours',
|
|
273
|
+
'fail',
|
|
274
|
+
`It would arrive at ${times}, outside ${win.label}. The federal TCPA window for ` +
|
|
275
|
+
'telephone solicitations, which covers texts, is 8am to 9pm at the recipient\'s ' +
|
|
276
|
+
'location, and some states are stricter. Schedule it for the recipient\'s ' +
|
|
277
|
+
`morning instead.${next}`,
|
|
278
|
+
)
|
|
279
|
+
} else if (zones.length > 1 && inside.length < zones.length) {
|
|
280
|
+
add(
|
|
281
|
+
'timezone_ambiguous',
|
|
282
|
+
'review',
|
|
283
|
+
`${parsed.number.e164}${where} could be in ${zones.length} time zones and the number ` +
|
|
284
|
+
`alone does not say which. It would arrive at ${times}: inside ${win.label} in ` +
|
|
285
|
+
`some, outside it in others.${next}`,
|
|
286
|
+
)
|
|
287
|
+
} else if (zones.length > 1) {
|
|
288
|
+
notes.push(
|
|
289
|
+
`${parsed.number.e164}${where} could be in ${zones.join(', ')}. The send time is ` +
|
|
290
|
+
`inside ${win.label} in every one of them.`,
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
return { recipient, timeCheck: 'checked' }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* @param {Date | string | number | undefined} sendAt
|
|
298
|
+
* @param {Date | (() => Date) | undefined} now
|
|
299
|
+
* @returns {Date}
|
|
300
|
+
*/
|
|
301
|
+
function resolveSendAt(sendAt, now) {
|
|
302
|
+
if (sendAt === undefined || sendAt === null) {
|
|
303
|
+
const d = typeof now === 'function' ? now() : (now ?? new Date())
|
|
304
|
+
return new Date(d.getTime())
|
|
305
|
+
}
|
|
306
|
+
if (typeof sendAt === 'string') {
|
|
307
|
+
const iso = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})$/i
|
|
308
|
+
if (!iso.test(sendAt.trim())) {
|
|
309
|
+
throw new GuardError(
|
|
310
|
+
'invalid_option',
|
|
311
|
+
`sendAt must be ISO 8601 with an offset or Z, such as 2026-09-27T14:30:00Z, got ${sendAt}.`,
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const date = sendAt instanceof Date ? new Date(sendAt.getTime()) : new Date(sendAt)
|
|
316
|
+
if (Number.isNaN(date.getTime())) {
|
|
317
|
+
throw new GuardError('invalid_option', `sendAt is not a valid time: ${String(sendAt)}.`)
|
|
318
|
+
}
|
|
319
|
+
return date
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* @param {string} body
|
|
324
|
+
* @param {SegmentCount} count
|
|
325
|
+
* @param {Ucs2Character[]} ucs2
|
|
326
|
+
* @param {number} maxSegments
|
|
327
|
+
* @returns {string}
|
|
328
|
+
*/
|
|
329
|
+
function segmentsDetail(body, count, ucs2, maxSegments) {
|
|
330
|
+
const unitName = count.encoding === 'GSM-7' ? 'septets' : 'UTF-16 units'
|
|
331
|
+
let detail =
|
|
332
|
+
`${count.segments} segments (${count.units} ${unitName}, ${count.perSegment} per ` +
|
|
333
|
+
`segment in ${count.encoding}), over the limit of ${maxSegments}. Providers usually ` +
|
|
334
|
+
'accept a long message and bill every segment, but some carriers refuse long ' +
|
|
335
|
+
'multi-part messages later and the recipient never gets it, so a success response ' +
|
|
336
|
+
'from the API tells you nothing.'
|
|
337
|
+
if (ucs2.length > 0 && ucs2.every((c) => c.replacement !== null)) {
|
|
338
|
+
const gsm = countSegments(replaceTypographic(body))
|
|
339
|
+
detail +=
|
|
340
|
+
` Replacing the typographic characters listed would make it GSM-7, ${gsm.segments} ` +
|
|
341
|
+
`segment${gsm.segments === 1 ? '' : 's'}.`
|
|
342
|
+
}
|
|
343
|
+
const parts = splitMessage(body, { maxSegments }).length
|
|
344
|
+
detail += ` Send it as ${parts} shorter messages instead (splitMessage, or --split), or cut it.`
|
|
345
|
+
return detail
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* @param {string} body
|
|
350
|
+
* @param {SegmentCount} count
|
|
351
|
+
* @param {Ucs2Character[]} ucs2
|
|
352
|
+
* @returns {string}
|
|
353
|
+
*/
|
|
354
|
+
function ucs2Detail(body, count, ucs2) {
|
|
355
|
+
const list = ucs2.map((c) => `${displayChar(c)} (${c.count})`).join(', ')
|
|
356
|
+
const replaceable = ucs2.filter((c) => c.replacement !== null)
|
|
357
|
+
const letters = ucs2.filter((c) => c.replacement === null)
|
|
358
|
+
let detail =
|
|
359
|
+
`UCS-2, because of ${list}. UCS-2 fits ${LIMITS['UCS-2'].single} characters in one ` +
|
|
360
|
+
`segment instead of ${LIMITS['GSM-7'].single}, and ${LIMITS['UCS-2'].part} per part ` +
|
|
361
|
+
`instead of ${LIMITS['GSM-7'].part}.`
|
|
362
|
+
if (replaceable.length > 0) {
|
|
363
|
+
const swaps = replaceable
|
|
364
|
+
.map((c) => `${displayChar(c)} with ${c.replacement === '' ? 'nothing' : `"${c.replacement}"`}`)
|
|
365
|
+
.join(', ')
|
|
366
|
+
detail += ` Typographic characters can be swapped for GSM-7 ones that read the same: ${swaps}.`
|
|
367
|
+
if (letters.length === 0) {
|
|
368
|
+
const gsm = countSegments(replaceTypographic(body))
|
|
369
|
+
detail += ` That makes it GSM-7, ${gsm.segments} segment${gsm.segments === 1 ? '' : 's'} instead of ${count.segments}.`
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (letters.length > 0) {
|
|
373
|
+
detail +=
|
|
374
|
+
' Letters are never replaced: they are the words. A message in Spanish will ' +
|
|
375
|
+
'usually be UCS-2, and that is fine; just budget segments for it.'
|
|
376
|
+
}
|
|
377
|
+
return detail
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Shows invisible and whitespace characters by code point, the rest as themselves.
|
|
382
|
+
*
|
|
383
|
+
* @param {Ucs2Character} c
|
|
384
|
+
* @returns {string}
|
|
385
|
+
*/
|
|
386
|
+
export function displayChar(c) {
|
|
387
|
+
return /^[\s\p{Cf}\p{Cc}]$/u.test(c.char) ? c.codePoint : `${c.char} ${c.codePoint}`
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const SHORTENER_PATTERN = new RegExp(
|
|
391
|
+
`(?<![\\p{L}\\p{N}.@-])(?:[a-z0-9-]+\\.)*(${[...SHORTENER_DOMAINS]
|
|
392
|
+
.map((d) => d.replace(/\./g, '\\.'))
|
|
393
|
+
.join('|')})(?![\\p{L}\\p{N}-]|\\.[\\p{L}\\p{N}])`,
|
|
394
|
+
'giu',
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* @param {string} text
|
|
399
|
+
* @returns {string[]} Shortener domains linked to, each once.
|
|
400
|
+
*/
|
|
401
|
+
function findShorteners(text) {
|
|
402
|
+
return [...new Set([...text.matchAll(SHORTENER_PATTERN)].map((m) => m[1].toLowerCase()))]
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Opt-out wording, in English and Spanish. */
|
|
406
|
+
const OPT_OUT_PATTERNS = [
|
|
407
|
+
// A keyword in capitals is an instruction, not a word in a sentence.
|
|
408
|
+
/\b(?:STOP|STOPALL|UNSUBSCRIBE|BAJA|ALTO|CANCELAR)\b/,
|
|
409
|
+
/\b(?:reply|text|txt|send|respond|responde|responda|contesta|conteste|env[ií]a|escribe|escriba)\s+(?:with\s+|con\s+)?["'\u2018\u2019\u201C\u201D\u00AB]?(?:stop|end|quit|cancel|unsubscribe|baja|alto|parar|salir)\b/i,
|
|
410
|
+
/\bopt[\s-]?out\b/i,
|
|
411
|
+
/\bdarte de baja\b|\bdarse de baja\b/i,
|
|
412
|
+
]
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* @param {string} text
|
|
416
|
+
* @returns {boolean}
|
|
417
|
+
*/
|
|
418
|
+
function hasOptOut(text) {
|
|
419
|
+
return OPT_OUT_PATTERNS.some((p) => p.test(text))
|
|
420
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline'
|
|
2
|
+
import {
|
|
3
|
+
checkMessage,
|
|
4
|
+
splitMessage,
|
|
5
|
+
GuardError,
|
|
6
|
+
DEFAULT_MAX_SEGMENTS,
|
|
7
|
+
DEFAULT_WINDOW,
|
|
8
|
+
} from './index.js'
|
|
9
|
+
import { VERSION } from './version.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A Model Context Protocol server over stdio, written against the spec directly so the
|
|
13
|
+
* package keeps zero runtime dependencies. Messages are newline-delimited JSON-RPC 2.0.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Newest first. The first is offered when a client asks for one we do not know. */
|
|
17
|
+
const PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05']
|
|
18
|
+
|
|
19
|
+
/** Characters in one message. A text this long is a document, not an SMS. */
|
|
20
|
+
const MAX_TEXT = 20_000
|
|
21
|
+
|
|
22
|
+
const INSTRUCTIONS =
|
|
23
|
+
'Call check_sms before sending any SMS, with the recipient number as to. Never send ' +
|
|
24
|
+
'a message whose verdict is fail. If it failed on too_many_segments, call split_sms ' +
|
|
25
|
+
'and send the parts it returns instead, in order, as separate messages. If it failed ' +
|
|
26
|
+
'on quiet_hours, schedule it for the recipient\'s morning (nextWindowStart) rather ' +
|
|
27
|
+
'than sending now. If the verdict is review, show the review reasons to a person and ' +
|
|
28
|
+
'let them decide; do not decide for them.'
|
|
29
|
+
|
|
30
|
+
const MAX_SEGMENTS_PROPERTY = {
|
|
31
|
+
type: 'integer',
|
|
32
|
+
minimum: 1,
|
|
33
|
+
description: `Most segments one message may use. Default ${DEFAULT_MAX_SEGMENTS}.`,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const TEXT_PROPERTY = {
|
|
37
|
+
type: 'string',
|
|
38
|
+
maxLength: MAX_TEXT,
|
|
39
|
+
description: 'The message, exactly as it will be sent.',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const TOOLS = [
|
|
43
|
+
{
|
|
44
|
+
name: 'check_sms',
|
|
45
|
+
title: 'Check a text message before sending',
|
|
46
|
+
description:
|
|
47
|
+
'Checks one SMS before you send it. Returns verdict (keep, review or fail), reasons ' +
|
|
48
|
+
'with a stable code and a plain-English detail, and facts: GSM-7 or UCS-2, the ' +
|
|
49
|
+
'characters forcing UCS-2, segment count, and the recipient\'s time zone and local ' +
|
|
50
|
+
'send time. Fails on too many segments (carriers refuse long multi-part messages ' +
|
|
51
|
+
'even when the provider API accepts them), a send time outside the recipient\'s ' +
|
|
52
|
+
'allowed hours, an invalid number, or an empty message. Flags public link ' +
|
|
53
|
+
'shorteners, an unknown or ambiguous time zone, and a first message with no ' +
|
|
54
|
+
'opt-out wording for review. Runs locally.',
|
|
55
|
+
inputSchema: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
text: TEXT_PROPERTY,
|
|
59
|
+
to: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
description:
|
|
62
|
+
'Recipient, E.164 such as +13055550100. Without it the send time is not checked.',
|
|
63
|
+
},
|
|
64
|
+
send_at: {
|
|
65
|
+
type: 'string',
|
|
66
|
+
description: 'When it will be sent, ISO 8601 with an offset or Z. Default now.',
|
|
67
|
+
},
|
|
68
|
+
max_segments: MAX_SEGMENTS_PROPERTY,
|
|
69
|
+
window: {
|
|
70
|
+
type: 'string',
|
|
71
|
+
description: `Allowed recipient local time, HH:MM-HH:MM. Default ${DEFAULT_WINDOW}.`,
|
|
72
|
+
},
|
|
73
|
+
first_message: {
|
|
74
|
+
type: 'boolean',
|
|
75
|
+
description: 'The first text to this person, so it needs opt-out wording. Default false.',
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
required: ['text'],
|
|
79
|
+
additionalProperties: false,
|
|
80
|
+
},
|
|
81
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'split_sms',
|
|
85
|
+
title: 'Split a long text message into parts',
|
|
86
|
+
description:
|
|
87
|
+
'Splits a message that is too long into parts that each fit within max_segments, ' +
|
|
88
|
+
'breaking at paragraphs, then lines, then sentences, then words. Send the parts in ' +
|
|
89
|
+
'order as separate messages. Each part comes with its own encoding and segment count.',
|
|
90
|
+
inputSchema: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: {
|
|
93
|
+
text: TEXT_PROPERTY,
|
|
94
|
+
max_segments: MAX_SEGMENTS_PROPERTY,
|
|
95
|
+
},
|
|
96
|
+
required: ['text'],
|
|
97
|
+
additionalProperties: false,
|
|
98
|
+
},
|
|
99
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
100
|
+
},
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @typedef {object} ServeOptions
|
|
105
|
+
* @property {NodeJS.ReadableStream} [input]
|
|
106
|
+
* @property {NodeJS.WritableStream} [output]
|
|
107
|
+
* @property {Partial<import('./index.js').CheckOptions>} [checkOptions] Overrides for
|
|
108
|
+
* tests, such as a fixed clock.
|
|
109
|
+
*/
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Serves until the input stream closes.
|
|
113
|
+
*
|
|
114
|
+
* @param {ServeOptions} [options]
|
|
115
|
+
* @returns {Promise<void>}
|
|
116
|
+
*/
|
|
117
|
+
export function serve(options = {}) {
|
|
118
|
+
const { input = process.stdin, output = process.stdout } = options
|
|
119
|
+
const send = (/** @type {object} */ message) => {
|
|
120
|
+
output.write(`${JSON.stringify(message)}\n`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** @type {Promise<void>[]} */
|
|
124
|
+
const pending = []
|
|
125
|
+
const lines = createInterface({ input, crlfDelay: Infinity })
|
|
126
|
+
lines.on('line', (line) => {
|
|
127
|
+
if (!line.trim()) return
|
|
128
|
+
pending.push(handleLine(line, send, options))
|
|
129
|
+
})
|
|
130
|
+
return new Promise((done) => {
|
|
131
|
+
lines.on('close', () => {
|
|
132
|
+
Promise.all(pending).then(() => done())
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {string} line
|
|
139
|
+
* @param {(message: object) => void} send
|
|
140
|
+
* @param {ServeOptions} options
|
|
141
|
+
* @returns {Promise<void>}
|
|
142
|
+
*/
|
|
143
|
+
async function handleLine(line, send, options) {
|
|
144
|
+
/** @type {any} */
|
|
145
|
+
let message
|
|
146
|
+
try {
|
|
147
|
+
message = JSON.parse(line)
|
|
148
|
+
} catch {
|
|
149
|
+
send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const isRequest = message && typeof message === 'object' && 'id' in message
|
|
154
|
+
if (!isRequest) return // Notifications, including notifications/initialized.
|
|
155
|
+
|
|
156
|
+
const { id, method, params } = message
|
|
157
|
+
try {
|
|
158
|
+
const result = await dispatch(method, params ?? {}, options)
|
|
159
|
+
send({ jsonrpc: '2.0', id, result })
|
|
160
|
+
} catch (error) {
|
|
161
|
+
const code = error instanceof RpcError ? error.code : -32603
|
|
162
|
+
send({ jsonrpc: '2.0', id, error: { code, message: /** @type {Error} */ (error).message } })
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
class RpcError extends Error {
|
|
167
|
+
/**
|
|
168
|
+
* @param {number} code
|
|
169
|
+
* @param {string} message
|
|
170
|
+
*/
|
|
171
|
+
constructor(code, message) {
|
|
172
|
+
super(message)
|
|
173
|
+
this.code = code
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @param {string} method
|
|
179
|
+
* @param {any} params
|
|
180
|
+
* @param {ServeOptions} options
|
|
181
|
+
* @returns {Promise<object>}
|
|
182
|
+
*/
|
|
183
|
+
async function dispatch(method, params, options) {
|
|
184
|
+
switch (method) {
|
|
185
|
+
case 'initialize':
|
|
186
|
+
return {
|
|
187
|
+
protocolVersion: PROTOCOL_VERSIONS.includes(params.protocolVersion)
|
|
188
|
+
? params.protocolVersion
|
|
189
|
+
: PROTOCOL_VERSIONS[0],
|
|
190
|
+
capabilities: { tools: {} },
|
|
191
|
+
serverInfo: { name: 'sms-guard', title: 'sms-guard', version: VERSION },
|
|
192
|
+
instructions: INSTRUCTIONS,
|
|
193
|
+
}
|
|
194
|
+
case 'ping':
|
|
195
|
+
return {}
|
|
196
|
+
case 'tools/list':
|
|
197
|
+
return { tools: TOOLS }
|
|
198
|
+
case 'tools/call':
|
|
199
|
+
return callTool(params.name, params.arguments ?? {}, options)
|
|
200
|
+
default:
|
|
201
|
+
throw new RpcError(-32601, `Method not found: ${method}`)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Tool failures are results with isError set, not protocol errors, so the model sees
|
|
207
|
+
* the message and can act on it.
|
|
208
|
+
*
|
|
209
|
+
* @param {string} name
|
|
210
|
+
* @param {any} args
|
|
211
|
+
* @param {ServeOptions} options
|
|
212
|
+
* @returns {Promise<object>}
|
|
213
|
+
*/
|
|
214
|
+
async function callTool(name, args, options) {
|
|
215
|
+
if (name !== 'check_sms' && name !== 'split_sms') {
|
|
216
|
+
throw new RpcError(-32602, `Unknown tool: ${name}`)
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
const payload = name === 'check_sms' ? checkSms(args, options) : splitSms(args)
|
|
220
|
+
return {
|
|
221
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
222
|
+
structuredContent: payload,
|
|
223
|
+
}
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (error instanceof GuardError || error instanceof ToolInputError) {
|
|
226
|
+
return { content: [{ type: 'text', text: error.message }], isError: true }
|
|
227
|
+
}
|
|
228
|
+
throw error
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
class ToolInputError extends Error {}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* @param {any} args
|
|
236
|
+
* @returns {string}
|
|
237
|
+
*/
|
|
238
|
+
function textArg(args) {
|
|
239
|
+
if (typeof args.text !== 'string') throw new ToolInputError('text must be a string.')
|
|
240
|
+
if (args.text.length > MAX_TEXT) {
|
|
241
|
+
throw new ToolInputError(`text is over ${MAX_TEXT} characters, which is not a text message.`)
|
|
242
|
+
}
|
|
243
|
+
return args.text
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* @param {any} args
|
|
248
|
+
* @returns {number | undefined}
|
|
249
|
+
*/
|
|
250
|
+
function maxSegmentsArg(args) {
|
|
251
|
+
if (args.max_segments === undefined) return undefined
|
|
252
|
+
if (!Number.isInteger(args.max_segments) || args.max_segments < 1) {
|
|
253
|
+
throw new ToolInputError('max_segments must be a whole number of 1 or more.')
|
|
254
|
+
}
|
|
255
|
+
return args.max_segments
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* @param {any} args
|
|
260
|
+
* @param {ServeOptions} options
|
|
261
|
+
*/
|
|
262
|
+
function checkSms(args, options) {
|
|
263
|
+
const text = textArg(args)
|
|
264
|
+
for (const key of ['to', 'send_at', 'window']) {
|
|
265
|
+
if (args[key] !== undefined && typeof args[key] !== 'string') {
|
|
266
|
+
throw new ToolInputError(`${key} must be a string.`)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const result = checkMessage(text, {
|
|
270
|
+
...options.checkOptions,
|
|
271
|
+
to: args.to,
|
|
272
|
+
sendAt: args.send_at,
|
|
273
|
+
maxSegments: maxSegmentsArg(args),
|
|
274
|
+
window: args.window,
|
|
275
|
+
firstMessage: args.first_message === true,
|
|
276
|
+
})
|
|
277
|
+
return { passed: result.verdict === 'keep', ...result }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* @param {any} args
|
|
282
|
+
*/
|
|
283
|
+
function splitSms(args) {
|
|
284
|
+
const text = textArg(args)
|
|
285
|
+
const maxSegments = maxSegmentsArg(args) ?? DEFAULT_MAX_SEGMENTS
|
|
286
|
+
const parts = splitMessage(text, { maxSegments })
|
|
287
|
+
if (parts.length === 0) throw new ToolInputError('The message is empty. Nothing to split.')
|
|
288
|
+
return { count: parts.length, maxSegments, parts }
|
|
289
|
+
}
|