@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.
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Checks one text message before it is sent: how many segments it will be, what
3
+ * forces it into UCS-2, whether it lands inside the recipient's allowed hours, and
4
+ * a few things carriers and recipients object to. Runs locally, touches no network.
5
+ *
6
+ * @param {string} text
7
+ * @param {CheckOptions} [options]
8
+ * @returns {Result}
9
+ */
10
+ export function checkMessage(text: string, options?: CheckOptions): Result;
11
+ /**
12
+ * Shows invisible and whitespace characters by code point, the rest as themselves.
13
+ *
14
+ * @param {Ucs2Character} c
15
+ * @returns {string}
16
+ */
17
+ export function displayChar(c: Ucs2Character): string;
18
+ export { parseNumber } from "./phone.js";
19
+ /**
20
+ * @typedef {import('./segments.js').Encoding} Encoding
21
+ * @typedef {import('./segments.js').SegmentCount} SegmentCount
22
+ * @typedef {import('./segments.js').Ucs2Character} Ucs2Character
23
+ * @typedef {import('./split.js').Part} Part
24
+ * @typedef {import('./split.js').SplitOptions} SplitOptions
25
+ * @typedef {import('./phone.js').LocalTime} LocalTime
26
+ *
27
+ * @typedef {'keep'|'review'|'fail'} Verdict
28
+ *
29
+ * @typedef {'empty'|'too_many_segments'|'invalid_number'|'quiet_hours'
30
+ * |'timezone_ambiguous'|'timezone_unknown'|'url_shortener'|'no_opt_out'
31
+ * |'ucs2'} ReasonCode
32
+ *
33
+ * @typedef {object} Reason
34
+ * @property {ReasonCode} code Stable, safe to match on.
35
+ * @property {Verdict} verdict What this reason alone would make of the message.
36
+ * @property {string} detail Plain English.
37
+ *
38
+ * @typedef {object} Recipient
39
+ * @property {string} input As given.
40
+ * @property {string | null} number E.164, or null when it could not be read.
41
+ * @property {boolean} assumedNanp Read as +1 because it had no country code and
42
+ * 10 digits (or 11 starting with 1).
43
+ * @property {string | null} region State, province or country, when known.
44
+ * @property {string[]} timezones Every IANA zone the number could be in.
45
+ * @property {string | null} timezone The zone, when there is exactly one.
46
+ * @property {Array<LocalTime & { inWindow: boolean }>} local Local send time in each
47
+ * of the zones.
48
+ * @property {boolean | null} inWindow Whether the send time is inside the window in
49
+ * every zone; null when no zone is known.
50
+ * @property {string | null} nextWindowStart ISO 8601, the first minute after the send
51
+ * time that is inside the window in every zone, when the send time is not.
52
+ *
53
+ * @typedef {object} Facts
54
+ * @property {Encoding} encoding
55
+ * @property {number} characters Unicode code points.
56
+ * @property {number} units Septets for GSM-7, UTF-16 code units for UCS-2.
57
+ * @property {number} segments
58
+ * @property {number} perSegment Units per segment at this length.
59
+ * @property {number} maxSegments
60
+ * @property {Ucs2Character[]} ucs2Characters What forces UCS-2, if anything.
61
+ * @property {string} sendAt ISO 8601.
62
+ * @property {string} window HH:MM-HH:MM, recipient local time.
63
+ * @property {'checked'|'skipped'} timeCheck Whether the send time was checked
64
+ * against the recipient's local time. Skipped with no recipient, or when their
65
+ * time zone is not known.
66
+ * @property {Recipient | null} recipient
67
+ *
68
+ * @typedef {object} Result
69
+ * @property {Verdict} verdict The worst verdict among the reasons; 'keep' with none.
70
+ * @property {Reason[]} reasons
71
+ * @property {Facts} facts
72
+ * @property {string[]} notes What the reader needs to know to trust the result,
73
+ * such as checks that did not run.
74
+ *
75
+ * @typedef {object} CheckOptions
76
+ * @property {string} [to] Recipient phone number, E.164 preferred.
77
+ * @property {Date | string | number} [sendAt] When it will be sent. A string must be
78
+ * ISO 8601 with an offset or Z. Default now.
79
+ * @property {number} [maxSegments] Fail above this many segments. Default 5.
80
+ * @property {string} [window] Allowed local send time, HH:MM-HH:MM, start inclusive,
81
+ * end exclusive. Default 08:00-21:00.
82
+ * @property {boolean} [firstMessage] The first text to this person: look for opt-out
83
+ * wording. Default false.
84
+ * @property {Date | (() => Date)} [now] The clock, for tests.
85
+ */
86
+ export const DEFAULT_WINDOW: "08:00-21:00";
87
+ export class GuardError extends Error {
88
+ /**
89
+ * @param {'invalid_option'} code
90
+ * @param {string} message
91
+ */
92
+ constructor(code: "invalid_option", message: string);
93
+ code: "invalid_option";
94
+ }
95
+ export type Encoding = import("./segments.js").Encoding;
96
+ export type SegmentCount = import("./segments.js").SegmentCount;
97
+ export type Ucs2Character = import("./segments.js").Ucs2Character;
98
+ export type Part = import("./split.js").Part;
99
+ export type SplitOptions = import("./split.js").SplitOptions;
100
+ export type LocalTime = import("./phone.js").LocalTime;
101
+ export type Verdict = "keep" | "review" | "fail";
102
+ export type ReasonCode = "empty" | "too_many_segments" | "invalid_number" | "quiet_hours" | "timezone_ambiguous" | "timezone_unknown" | "url_shortener" | "no_opt_out" | "ucs2";
103
+ export type Reason = {
104
+ /**
105
+ * Stable, safe to match on.
106
+ */
107
+ code: ReasonCode;
108
+ /**
109
+ * What this reason alone would make of the message.
110
+ */
111
+ verdict: Verdict;
112
+ /**
113
+ * Plain English.
114
+ */
115
+ detail: string;
116
+ };
117
+ export type Recipient = {
118
+ /**
119
+ * As given.
120
+ */
121
+ input: string;
122
+ /**
123
+ * E.164, or null when it could not be read.
124
+ */
125
+ number: string | null;
126
+ /**
127
+ * Read as +1 because it had no country code and
128
+ * 10 digits (or 11 starting with 1).
129
+ */
130
+ assumedNanp: boolean;
131
+ /**
132
+ * State, province or country, when known.
133
+ */
134
+ region: string | null;
135
+ /**
136
+ * Every IANA zone the number could be in.
137
+ */
138
+ timezones: string[];
139
+ /**
140
+ * The zone, when there is exactly one.
141
+ */
142
+ timezone: string | null;
143
+ /**
144
+ * Local send time in each
145
+ * of the zones.
146
+ */
147
+ local: Array<LocalTime & {
148
+ inWindow: boolean;
149
+ }>;
150
+ /**
151
+ * Whether the send time is inside the window in
152
+ * every zone; null when no zone is known.
153
+ */
154
+ inWindow: boolean | null;
155
+ /**
156
+ * ISO 8601, the first minute after the send
157
+ * time that is inside the window in every zone, when the send time is not.
158
+ */
159
+ nextWindowStart: string | null;
160
+ };
161
+ export type Facts = {
162
+ encoding: Encoding;
163
+ /**
164
+ * Unicode code points.
165
+ */
166
+ characters: number;
167
+ /**
168
+ * Septets for GSM-7, UTF-16 code units for UCS-2.
169
+ */
170
+ units: number;
171
+ segments: number;
172
+ /**
173
+ * Units per segment at this length.
174
+ */
175
+ perSegment: number;
176
+ maxSegments: number;
177
+ /**
178
+ * What forces UCS-2, if anything.
179
+ */
180
+ ucs2Characters: Ucs2Character[];
181
+ /**
182
+ * ISO 8601.
183
+ */
184
+ sendAt: string;
185
+ /**
186
+ * HH:MM-HH:MM, recipient local time.
187
+ */
188
+ window: string;
189
+ /**
190
+ * Whether the send time was checked
191
+ * against the recipient's local time. Skipped with no recipient, or when their
192
+ * time zone is not known.
193
+ */
194
+ timeCheck: "checked" | "skipped";
195
+ recipient: Recipient | null;
196
+ };
197
+ export type Result = {
198
+ /**
199
+ * The worst verdict among the reasons; 'keep' with none.
200
+ */
201
+ verdict: Verdict;
202
+ reasons: Reason[];
203
+ facts: Facts;
204
+ /**
205
+ * What the reader needs to know to trust the result,
206
+ * such as checks that did not run.
207
+ */
208
+ notes: string[];
209
+ };
210
+ export type CheckOptions = {
211
+ /**
212
+ * Recipient phone number, E.164 preferred.
213
+ */
214
+ to?: string | undefined;
215
+ /**
216
+ * When it will be sent. A string must be
217
+ * ISO 8601 with an offset or Z. Default now.
218
+ */
219
+ sendAt?: string | number | Date | undefined;
220
+ /**
221
+ * Fail above this many segments. Default 5.
222
+ */
223
+ maxSegments?: number | undefined;
224
+ /**
225
+ * Allowed local send time, HH:MM-HH:MM, start inclusive,
226
+ * end exclusive. Default 08:00-21:00.
227
+ */
228
+ window?: string | undefined;
229
+ /**
230
+ * The first text to this person: look for opt-out
231
+ * wording. Default false.
232
+ */
233
+ firstMessage?: boolean | undefined;
234
+ /**
235
+ * The clock, for tests.
236
+ */
237
+ now?: Date | (() => Date) | undefined;
238
+ };
239
+ export { encodingOf, countSegments, ucs2Characters, LIMITS } from "./segments.js";
240
+ export { splitMessage, DEFAULT_MAX_SEGMENTS } from "./split.js";
package/types/mcp.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @typedef {object} ServeOptions
3
+ * @property {NodeJS.ReadableStream} [input]
4
+ * @property {NodeJS.WritableStream} [output]
5
+ * @property {Partial<import('./index.js').CheckOptions>} [checkOptions] Overrides for
6
+ * tests, such as a fixed clock.
7
+ */
8
+ /**
9
+ * Serves until the input stream closes.
10
+ *
11
+ * @param {ServeOptions} [options]
12
+ * @returns {Promise<void>}
13
+ */
14
+ export function serve(options?: ServeOptions): Promise<void>;
15
+ export type ServeOptions = {
16
+ input?: NodeJS.ReadableStream | undefined;
17
+ output?: NodeJS.WritableStream | undefined;
18
+ /**
19
+ * Overrides for
20
+ * tests, such as a fixed clock.
21
+ */
22
+ checkOptions?: Partial<import("./index.js").CheckOptions> | undefined;
23
+ };
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Reads a phone number. Accepts E.164 with or without formatting (+1 (305) 555-0100,
3
+ * +44 20 7946 0000). Without a leading +, only a 10-digit number, or an 11-digit
4
+ * number starting with 1, is accepted, and it is read as NANP (US, Canada and the
5
+ * rest of +1). Anything else without a + is refused rather than guessed at.
6
+ *
7
+ * @param {string} raw
8
+ * @returns {{ ok: true, number: ParsedNumber } | { ok: false, error: string }}
9
+ */
10
+ export function parseNumber(raw: string): {
11
+ ok: true;
12
+ number: ParsedNumber;
13
+ } | {
14
+ ok: false;
15
+ error: string;
16
+ };
17
+ /**
18
+ * The time zone, or zones, a number could be in.
19
+ *
20
+ * @param {ParsedNumber} number
21
+ * @returns {ZoneLookup}
22
+ */
23
+ export function zonesFor(number: ParsedNumber): ZoneLookup;
24
+ /**
25
+ * @typedef {object} Window
26
+ * @property {number} start Minutes after local midnight, inclusive.
27
+ * @property {number} end Minutes after local midnight, exclusive.
28
+ * @property {string} label As given, HH:MM-HH:MM.
29
+ */
30
+ /**
31
+ * Parses HH:MM-HH:MM. The start is inclusive and the end exclusive, so 08:00-21:00
32
+ * allows 08:00 and 20:59 but not 21:00. A window that wraps midnight (22:00-06:00)
33
+ * is allowed.
34
+ *
35
+ * @param {string} window
36
+ * @returns {Window | null}
37
+ */
38
+ export function parseWindow(window: string): Window | null;
39
+ /**
40
+ * @param {number} minutes Minutes after local midnight.
41
+ * @param {Window} window
42
+ * @returns {boolean}
43
+ */
44
+ export function inWindow(minutes: number, window: Window): boolean;
45
+ /**
46
+ * The wall-clock time in a zone, from the runtime's own time zone data.
47
+ *
48
+ * @param {Date} date
49
+ * @param {string} timezone
50
+ * @returns {LocalTime}
51
+ */
52
+ export function localTime(date: Date, timezone: string): LocalTime;
53
+ /**
54
+ * The first minute at or after `from` when it is inside the window in every one of
55
+ * the zones, or null if there is none in the next two days.
56
+ *
57
+ * @param {Date} from
58
+ * @param {string[]} zones
59
+ * @param {Window} window
60
+ * @returns {Date | null}
61
+ */
62
+ export function nextWindowStart(from: Date, zones: string[], window: Window): Date | null;
63
+ export type Window = {
64
+ /**
65
+ * Minutes after local midnight, inclusive.
66
+ */
67
+ start: number;
68
+ /**
69
+ * Minutes after local midnight, exclusive.
70
+ */
71
+ end: number;
72
+ /**
73
+ * As given, HH:MM-HH:MM.
74
+ */
75
+ label: string;
76
+ };
77
+ export type ParsedNumber = {
78
+ /**
79
+ * The number as +<digits>.
80
+ */
81
+ e164: string;
82
+ /**
83
+ * '1' for NANP, otherwise as matched in the country
84
+ * table, or an empty string when no row matches.
85
+ */
86
+ countryCode: string;
87
+ /**
88
+ * True when the number had no + and was read as a
89
+ * US or Canadian number because it had 10 digits (or 11 starting with 1).
90
+ */
91
+ assumedNanp: boolean;
92
+ };
93
+ export type ZoneLookup = {
94
+ /**
95
+ * State, province or country, when known.
96
+ */
97
+ region: string | null;
98
+ /**
99
+ * IANA zones. None means unknown; more than one, ambiguous.
100
+ */
101
+ zones: string[];
102
+ /**
103
+ * What the zones were worked out from, in plain English.
104
+ */
105
+ basis: string;
106
+ };
107
+ export type LocalTime = {
108
+ timezone: string;
109
+ /**
110
+ * Local wall-clock time, YYYY-MM-DDTHH:MM.
111
+ */
112
+ time: string;
113
+ /**
114
+ * Such as "Sun 19:30 EDT".
115
+ */
116
+ label: string;
117
+ /**
118
+ * Minutes after local midnight.
119
+ */
120
+ minutes: number;
121
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @param {Result} result
3
+ * @param {{ colour?: boolean, width?: number }} [options]
4
+ * @returns {string}
5
+ */
6
+ export function formatText(result: Result, options?: {
7
+ colour?: boolean;
8
+ width?: number;
9
+ }): string;
10
+ /**
11
+ * @param {Part[]} parts
12
+ * @param {{ maxSegments: number, colour?: boolean }} options
13
+ * @returns {string}
14
+ */
15
+ export function formatSplit(parts: Part[], options: {
16
+ maxSegments: number;
17
+ colour?: boolean;
18
+ }): string;
19
+ export type Result = import("./index.js").Result;
20
+ export type ReasonCode = import("./index.js").ReasonCode;
21
+ export type Verdict = import("./index.js").Verdict;
22
+ export type Part = import("./split.js").Part;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * GSM-7 when every character is in the default alphabet or its extension table,
3
+ * UCS-2 otherwise. One character outside both is enough to switch the whole message.
4
+ *
5
+ * @param {string} text
6
+ * @returns {Encoding}
7
+ */
8
+ export function encodingOf(text: string): Encoding;
9
+ /**
10
+ * What one character costs in the given encoding.
11
+ *
12
+ * @param {string} char One code point.
13
+ * @param {Encoding} encoding
14
+ * @returns {number}
15
+ */
16
+ export function unitCost(char: string, encoding: Encoding): number;
17
+ /**
18
+ * Counts segments the way the network does. A GSM-7 escape pair, or a UTF-16
19
+ * surrogate pair, is never cut across two parts: when it does not fit in what is left
20
+ * of a part, the part is sent short and the pair starts the next one.
21
+ *
22
+ * @param {string} text
23
+ * @returns {SegmentCount}
24
+ */
25
+ export function countSegments(text: string): SegmentCount;
26
+ /**
27
+ * The characters that force a message into UCS-2, each once, in order of first
28
+ * appearance, with a GSM-7 replacement where one reads the same.
29
+ *
30
+ * @param {string} text
31
+ * @returns {Ucs2Character[]}
32
+ */
33
+ export function ucs2Characters(text: string): Ucs2Character[];
34
+ /**
35
+ * Applies the typographic replacements, and nothing else.
36
+ *
37
+ * @param {string} text
38
+ * @returns {string}
39
+ */
40
+ export function replaceTypographic(text: string): string;
41
+ /**
42
+ * @param {string} char
43
+ * @returns {string}
44
+ */
45
+ export function codePointOf(char: string): string;
46
+ /**
47
+ * @typedef {'GSM-7'|'UCS-2'} Encoding
48
+ *
49
+ * @typedef {object} SegmentCount
50
+ * @property {Encoding} encoding
51
+ * @property {number} characters Unicode code points, what a person would count.
52
+ * @property {number} units What the network counts: septets for GSM-7 (an extension
53
+ * character is two), UTF-16 code units for UCS-2 (an emoji is usually two).
54
+ * @property {number} segments Parts the message is sent as. 0 for an empty message.
55
+ * @property {number} perSegment Units that fit in each part: 160 or 70 alone, 153 or
56
+ * 67 once a message is split, since the rest of each part carries the header that
57
+ * joins them back together.
58
+ *
59
+ * @typedef {object} Ucs2Character
60
+ * @property {string} char
61
+ * @property {string} codePoint As U+XXXX.
62
+ * @property {number} count Times it appears.
63
+ * @property {string | null} replacement A GSM-7 equivalent, offered only for
64
+ * typographic characters such as curly quotes, dashes and odd spaces.
65
+ */
66
+ /** Units per message: [single, per part once concatenated]. */
67
+ export const LIMITS: {
68
+ 'GSM-7': {
69
+ single: number;
70
+ part: number;
71
+ };
72
+ 'UCS-2': {
73
+ single: number;
74
+ part: number;
75
+ };
76
+ };
77
+ export type Encoding = "GSM-7" | "UCS-2";
78
+ export type SegmentCount = {
79
+ encoding: Encoding;
80
+ /**
81
+ * Unicode code points, what a person would count.
82
+ */
83
+ characters: number;
84
+ /**
85
+ * What the network counts: septets for GSM-7 (an extension
86
+ * character is two), UTF-16 code units for UCS-2 (an emoji is usually two).
87
+ */
88
+ units: number;
89
+ /**
90
+ * Parts the message is sent as. 0 for an empty message.
91
+ */
92
+ segments: number;
93
+ /**
94
+ * Units that fit in each part: 160 or 70 alone, 153 or
95
+ * 67 once a message is split, since the rest of each part carries the header that
96
+ * joins them back together.
97
+ */
98
+ perSegment: number;
99
+ };
100
+ export type Ucs2Character = {
101
+ char: string;
102
+ /**
103
+ * As U+XXXX.
104
+ */
105
+ codePoint: string;
106
+ /**
107
+ * Times it appears.
108
+ */
109
+ count: number;
110
+ /**
111
+ * A GSM-7 equivalent, offered only for
112
+ * typographic characters such as curly quotes, dashes and odd spaces.
113
+ */
114
+ replacement: string | null;
115
+ };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Splits a message into parts that each fit within maxSegments, to be sent in order
3
+ * as separate texts. Breaks at a blank line where it can, then a line, then a
4
+ * sentence, then a word, and only cuts inside a word when a single word is too long.
5
+ * Parts are packed greedily, so there are as few as the breaks allow. Whitespace at
6
+ * each break is dropped. A GSM-7 escape pair or a surrogate pair is never cut, since
7
+ * the text is only ever cut between whole characters.
8
+ *
9
+ * @param {string} text
10
+ * @param {SplitOptions} [options]
11
+ * @returns {Part[]} Empty for a message with nothing but whitespace.
12
+ */
13
+ export function splitMessage(text: string, options?: SplitOptions): Part[];
14
+ /**
15
+ * @typedef {import('./segments.js').Encoding} Encoding
16
+ *
17
+ * @typedef {object} Part
18
+ * @property {string} text
19
+ * @property {Encoding} encoding Worked out for this part alone: a part with no
20
+ * accented letters can go back to GSM-7.
21
+ * @property {number} characters
22
+ * @property {number} units
23
+ * @property {number} segments
24
+ *
25
+ * @typedef {object} SplitOptions
26
+ * @property {number} [maxSegments] Most segments any one part may use. Default 5.
27
+ */
28
+ export const DEFAULT_MAX_SEGMENTS: 5;
29
+ export type Encoding = import("./segments.js").Encoding;
30
+ export type Part = {
31
+ text: string;
32
+ /**
33
+ * Worked out for this part alone: a part with no
34
+ * accented letters can go back to GSM-7.
35
+ */
36
+ encoding: Encoding;
37
+ characters: number;
38
+ units: number;
39
+ segments: number;
40
+ };
41
+ export type SplitOptions = {
42
+ /**
43
+ * Most segments any one part may use. Default 5.
44
+ */
45
+ maxSegments?: number | undefined;
46
+ };
@@ -0,0 +1,2 @@
1
+ /** The published version, read from package.json so there is one place to bump it. */
2
+ export const VERSION: string;