@stacksjs/sms 0.75.145 → 0.75.146
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/dist/compliance.js +1 -1
- package/dist/inbound.d.ts +9 -6
- package/dist/inbound.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -1
- package/dist/keywords.d.ts +29 -0
- package/dist/keywords.js +1 -0
- package/dist/opt-out.d.ts +30 -4
- package/dist/opt-out.js +1 -1
- package/package.json +6 -6
package/dist/compliance.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{createHmac,timingSafeEqual}from"node:crypto";const
|
|
1
|
+
import{createHmac,timingSafeEqual}from"node:crypto";import{SMS_KEYWORDS}from"./keywords";const allKeywords=(kind)=>Object.values(SMS_KEYWORDS).flatMap((set)=>set[kind].map((word)=>word.toUpperCase())),DEFAULT_OPT_OUT=allKeywords("stop"),DEFAULT_OPT_IN=allKeywords("start"),DEFAULT_HELP=allKeywords("help"),GSM_7_BASIC=new Set(`@\xA3$\xA5\xE8\xE9\xF9\xEC\xF2\xC7
|
|
2
2
|
\xD8\xF8\r\xC5\xE5\u0394_\u03A6\u0393\u039B\u03A9\u03A0\u03A8\u03A3\u0398\u039E\xC6\xE6\xDF\xC9 !"#\xA4%&'()*+,-./0123456789:;<=>?\xA1ABCDEFGHIJKLMNOPQRSTUVWXYZ\xC4\xD6\xD1\xDC\xA7\xBFabcdefghijklmnopqrstuvwxyz\xE4\xF6\xF1\xFC\xE0`),GSM_7_EXTENDED=new Set("^{}\\[~]|\u20AC");function normalizedKeyword(body){return body.trim().split(/\s+/,1)[0]?.toUpperCase()??""}export function classifySmsIntent(body,options={}){const keyword=normalizedKeyword(body),includes=(values,defaults)=>(values??defaults).some((value)=>value.trim().toUpperCase()===keyword);if(includes(options.optOut,DEFAULT_OPT_OUT))return{intent:"opt-out",keyword};if(includes(options.optIn,DEFAULT_OPT_IN))return{intent:"opt-in",keyword};if(includes(options.help,DEFAULT_HELP))return{intent:"help",keyword};return{intent:"message"}}export function smsComplianceReply(intent,options={}){const name=options.appName?.trim()||"This service";if(intent==="opt-out")return`${name}: you are unsubscribed. No more messages will be sent. Reply START to resubscribe.`;if(intent==="opt-in")return`${name}: you are resubscribed. Reply STOP to unsubscribe.`;if(intent==="help")return`${name}: reply STOP to unsubscribe or START to resubscribe.${options.helpContact?` Contact ${options.helpContact}.`:""}`;return null}export function parseTwilioInbound(fields,options={}){const body=fields.Body??"";return{from:fields.From??"",to:fields.To??"",body,messageId:fields.MessageSid??fields.SmsSid,...classifySmsIntent(body,options)}}export function verifyTwilioWebhook(url,fields,signature,authToken){if(!url||!signature||!authToken)return!1;const payload=Object.keys(fields).sort().reduce((value,key)=>`${value}${key}${fields[key]??""}`,url),expected=createHmac("sha1",authToken).update(payload).digest("base64"),expectedBuffer=Buffer.from(expected),actualBuffer=Buffer.from(signature);if(expectedBuffer.length!==actualBuffer.length){timingSafeEqual(expectedBuffer,expectedBuffer);return!1}return timingSafeEqual(expectedBuffer,actualBuffer)}export function estimateSmsSegments(body){let septets=0,gsm7=!0;for(const character of body)if(GSM_7_BASIC.has(character))septets+=1;else if(GSM_7_EXTENDED.has(character))septets+=2;else{gsm7=!1;break}if(gsm7){const perSegment=septets<=160?160:153;return{encoding:"gsm-7",characters:body.length,segments:Math.max(1,Math.ceil(septets/perSegment)),perSegment}}const characters=[...body].length,perSegment=characters<=70?70:67;return{encoding:"ucs-2",characters,segments:Math.max(1,Math.ceil(characters/perSegment)),perSegment}}export function isWithinSmsQuietHours(at,options){const hour=Number(new Intl.DateTimeFormat("en-US",{hour:"numeric",hourCycle:"h23",timeZone:options.timezone??"UTC"}).format(at)),start=Math.min(23,Math.max(0,Math.floor(options.startHour))),end=Math.min(23,Math.max(0,Math.floor(options.endHour)));if(start===end)return!0;return start<end?hour>=start&&hour<end:hour>=start||hour<end}
|
package/dist/inbound.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { type InboundSmsKind } from './
|
|
1
|
+
import { type InboundSmsKind, type SmsKeywordMatch } from './keywords';
|
|
2
2
|
/**
|
|
3
|
-
* Which keyword a message is
|
|
4
|
-
* `OptOutType` (and knows any custom keywords
|
|
5
|
-
*
|
|
3
|
+
* Which keyword a message is, and whether Twilio handles it itself. Twilio's
|
|
4
|
+
* Advanced Opt-Out names it in `OptOutType` (and knows any custom keywords
|
|
5
|
+
* configured there); otherwise `SMS_KEYWORDS` decides, in every language it
|
|
6
|
+
* has, and only the English ones are Twilio's.
|
|
6
7
|
*/
|
|
7
|
-
export declare function inboundSmsKind(fields: Record<string, string | undefined>):
|
|
8
|
+
export declare function inboundSmsKind(fields: Record<string, string | undefined>): SmsKeywordMatch & { providerHandled: boolean };
|
|
8
9
|
export declare function handleTwilioInbound(request: Request, options?: TwilioInboundOptions): Promise<Response>;
|
|
9
10
|
/**
|
|
10
11
|
* A Twilio "a message comes in" webhook, end to end: verify Twilio's
|
|
@@ -24,6 +25,7 @@ export declare function handleTwilioInbound(request: Request, options?: TwilioIn
|
|
|
24
25
|
export declare interface InboundConsentChange {
|
|
25
26
|
phone: string
|
|
26
27
|
kind: 'stop' | 'start'
|
|
28
|
+
locale?: string
|
|
27
29
|
}
|
|
28
30
|
export declare interface InboundSmsMessage {
|
|
29
31
|
from: string
|
|
@@ -31,11 +33,12 @@ export declare interface InboundSmsMessage {
|
|
|
31
33
|
body: string
|
|
32
34
|
messageId?: string
|
|
33
35
|
kind: InboundSmsKind
|
|
36
|
+
locale?: string
|
|
34
37
|
}
|
|
35
38
|
export declare interface TwilioInboundOptions {
|
|
36
39
|
authToken?: string
|
|
37
40
|
publicUrl?: string
|
|
38
|
-
reply?: boolean
|
|
41
|
+
reply?: boolean | 'unhandled'
|
|
39
42
|
appName?: string
|
|
40
43
|
helpContact?: string
|
|
41
44
|
onConsentChange?: (change: InboundConsentChange) => unknown
|
package/dist/inbound.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import process from"node:process";import{verifyTwilioWebhook}from"./compliance";import{
|
|
1
|
+
import process from"node:process";import{verifyTwilioWebhook}from"./compliance";import{inboundSmsReply,matchSmsKeyword,PROVIDER_HANDLED_LOCALE}from"./keywords";import{normalizePhone,optInPhone,optOutPhone}from"./opt-out";function twiml(message){const escaped=message?.replace(/[&<>"']/g,(c)=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[c]),body=escaped?`<?xml version="1.0" encoding="UTF-8"?><Response><Message>${escaped}</Message></Response>`:'<?xml version="1.0" encoding="UTF-8"?><Response></Response>';return new Response(body,{status:200,headers:{"Content-Type":"text/xml; charset=utf-8"}})}function signedUrl(request,publicUrl){if(publicUrl)return publicUrl;const url=new URL(request.url,"http://localhost"),base=String(process.env.APP_URL||"").replace(/\/$/,"");return base?`${base}${url.pathname}${url.search}`:url.href}export function inboundSmsKind(fields){const optOutType=String(fields.OptOutType||"").toUpperCase(),match=matchSmsKeyword(fields.Body??"");if(optOutType==="STOP"||optOutType==="START"||optOutType==="HELP"){const kind=optOutType.toLowerCase();return{...match.kind===kind?match:{keyword:match.keyword},kind,providerHandled:!0}}return{...match,providerHandled:match.locale===PROVIDER_HANDLED_LOCALE}}function shouldReply(match,reply){if(match.kind==="message"||!reply)return!1;return reply===!0||!match.providerHandled}export async function handleTwilioInbound(request,options={}){const authToken=options.authToken??process.env.TWILIO_AUTH_TOKEN??"";if(!authToken)return new Response("Inbound SMS is not configured",{status:503});const fields={};for(const[key,value]of new URLSearchParams(await request.text()))fields[key]=value;const signature=request.headers.get("x-twilio-signature")||"";if(!verifyTwilioWebhook(signedUrl(request,options.publicUrl),fields,signature,authToken))return new Response("Invalid signature",{status:403});const from=normalizePhone(fields.From??"");if(!from)return twiml();const match=inboundSmsKind(fields),{kind}=match;if(kind==="stop")await optOutPhone(from);if(kind==="start")await optInPhone(from);if(kind==="stop"||kind==="start")await options.onConsentChange?.({phone:from,kind,...match.locale?{locale:match.locale}:{}});await options.onMessage?.({from,to:fields.To??"",body:fields.Body??"",messageId:fields.MessageSid??fields.SmsSid,kind,...match.locale?{locale:match.locale}:{}});return twiml(shouldReply(match,options.reply)?inboundSmsReply(kind,{...options,locale:match.locale}):void 0)}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export*from"./inbound";export*from"./opt-out";export*from"./sms";export{default as SMS}from"./sms";export*from"./drivers";export{TwilioDriver,createTwilioDriver}from"./drivers/twilio";export{VonageDriver,createVonageDriver}from"./drivers/vonage";export*from"./compliance";
|
|
1
|
+
export*from"./inbound";export*from"./keywords";export*from"./opt-out";export*from"./sms";export{default as SMS}from"./sms";export*from"./drivers";export{TwilioDriver,createTwilioDriver}from"./drivers/twilio";export{VonageDriver,createVonageDriver}from"./drivers/vonage";export*from"./compliance";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Which keyword a message is: its first word, in any case, in any language. */
|
|
2
|
+
export declare function matchSmsKeyword(body: string, locales?: readonly string[]): SmsKeywordMatch;
|
|
3
|
+
/** The compliance reply to a keyword in `locale` (English when there is none), or undefined for an ordinary message. */
|
|
4
|
+
export declare function inboundSmsReply(kind: InboundSmsKind, options?: { appName?: string, helpContact?: string, locale?: string }): string | undefined;
|
|
5
|
+
export declare const SMS_KEYWORDS: Readonly<Record<string, SmsKeywordSet>>;
|
|
6
|
+
/** The keywords Twilio (and US carriers) handle themselves, replies included. */
|
|
7
|
+
export declare const PROVIDER_HANDLED_LOCALE: 'en';
|
|
8
|
+
export declare interface SmsKeywordSet {
|
|
9
|
+
stop: readonly string[]
|
|
10
|
+
start: readonly string[]
|
|
11
|
+
help: readonly string[]
|
|
12
|
+
}
|
|
13
|
+
export declare interface SmsKeywordMatch {
|
|
14
|
+
kind: InboundSmsKind
|
|
15
|
+
locale?: string
|
|
16
|
+
keyword?: string
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The keywords a reply can carry, per language, and the replies to them.
|
|
20
|
+
*
|
|
21
|
+
* English is what carriers and Twilio's own opt-out handling recognise
|
|
22
|
+
* (CTIA). Other languages are recognised too, because a member in Germany
|
|
23
|
+
* answers "STOPP", not "STOP", and that has to stop the texts just the same.
|
|
24
|
+
* Every language is checked on every message: a reply says nothing about
|
|
25
|
+
* which language its sender reads, and these words do not collide.
|
|
26
|
+
*
|
|
27
|
+
* Pure: no database, so the compliance helpers can use it too.
|
|
28
|
+
*/
|
|
29
|
+
export type InboundSmsKind = 'stop' | 'start' | 'help' | 'message';
|
package/dist/keywords.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const SMS_KEYWORDS={en:{stop:["stop","stopall","unsubscribe","cancel","end","quit"],start:["start","yes","unstop"],help:["help","info"]},de:{stop:["stopp","abmelden","abbestellen"],start:["starten"],help:["hilfe"]}},PROVIDER_HANDLED_LOCALE="en";export function matchSmsKeyword(body,locales=Object.keys(SMS_KEYWORDS)){const word=String(body??"").trim().toLowerCase().split(/\s+/)[0]??"";if(!word)return{kind:"message"};for(const kind of["stop","start","help"])for(const locale of locales)if(SMS_KEYWORDS[locale]?.[kind].includes(word))return{kind,locale,keyword:word};return{kind:"message"}}const REPLIES={en:{stop:(name)=>`You are unsubscribed from ${name} texts. No more messages will be sent. Reply START to resubscribe.`,start:(name)=>`You are resubscribed to ${name} texts. Reply STOP to unsubscribe.`,help:(name,contact)=>`${name}: reply STOP to unsubscribe, START to resubscribe.${contact?` Contact: ${contact}`:""}`},de:{stop:(name)=>`Du bist von den SMS von ${name} abgemeldet und bekommst keine weiteren. Antworte STARTEN, um sie wieder zu erhalten.`,start:(name)=>`Du bekommst wieder SMS von ${name}. Antworte STOPP zum Abmelden.`,help:(name,contact)=>`${name}: Antworte STOPP zum Abmelden oder STARTEN zum erneuten Anmelden.${contact?` Kontakt: ${contact}`:""}`}};export function inboundSmsReply(kind,options={}){if(kind==="message")return;const base=String(options.locale||"").toLowerCase().split(/[-_]/)[0]??"",copy=REPLIES[base]??REPLIES.en,name=options.appName??(base==="de"?"diesem Dienst":"this service");return copy[kind](name,options.helpContact)}
|
package/dist/opt-out.d.ts
CHANGED
|
@@ -1,20 +1,46 @@
|
|
|
1
|
+
import { type InboundSmsKind } from './keywords';
|
|
1
2
|
export declare function normalizePhone(raw: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Which keyword a message is, in any language `SMS_KEYWORDS` knows
|
|
5
|
+
* (`STOP` and the German `STOPP` alike). See `matchSmsKeyword` for the
|
|
6
|
+
* language that matched.
|
|
7
|
+
*/
|
|
2
8
|
export declare function classifyInboundSms(body: string): InboundSmsKind;
|
|
9
|
+
/**
|
|
10
|
+
* Record that a number replied STOP. Idempotent, and safe under concurrency:
|
|
11
|
+
* one insert that does nothing when the number is already there (`phone` is
|
|
12
|
+
* unique), so a provider retrying its webhook, or two STOPs handled at once,
|
|
13
|
+
* leave exactly one row. The first opt-out's time and reason are kept.
|
|
14
|
+
*
|
|
15
|
+
* It used to delete and re-insert, and two webhooks racing between those two
|
|
16
|
+
* statements made the second insert fail on the unique index: the webhook
|
|
17
|
+
* answered 500 and the provider retried a STOP that had already been recorded.
|
|
18
|
+
*/
|
|
3
19
|
export declare function optOutPhone(phone: string, reason?: string): Promise<void>;
|
|
4
20
|
export declare function optInPhone(phone: string): Promise<void>;
|
|
5
21
|
/** Check before EVERY non-emergency send. */
|
|
6
22
|
export declare function isPhoneOptedOut(phone: string): Promise<boolean>;
|
|
7
|
-
/** The compliance reply to a keyword, or undefined for an ordinary message. */
|
|
8
|
-
export declare function inboundSmsReply(kind: InboundSmsKind, options?: { appName?: string, helpContact?: string }): string | undefined;
|
|
9
23
|
/**
|
|
10
24
|
* Process one inbound SMS (a provider webhook's job): record STOP/START,
|
|
11
25
|
* return the compliance reply to send. Ordinary messages pass through as
|
|
12
26
|
* `kind: 'message'` for the app to route (or ignore). For Twilio,
|
|
13
|
-
* `handleTwilioInbound` does this and the signature check in one call.
|
|
27
|
+
* `handleTwilioInbound` does this and the signature check in one call. The
|
|
28
|
+
* reply is in the language of the keyword (STOPP gets German).
|
|
14
29
|
*/
|
|
15
30
|
export declare function handleInboundSms(input: { from: string, body: string }, options?: { appName?: string, helpContact?: string }): Promise<InboundSmsResult>;
|
|
31
|
+
/**
|
|
32
|
+
* US SMS compliance: STOP must stop, START must restart, HELP must answer.
|
|
33
|
+
* Carriers enforce this at the shortcode/10DLC level, but the sender is
|
|
34
|
+
* still required to honor it in their own records - and a school that
|
|
35
|
+
* keeps texting after STOP is one parent complaint from a filtered number.
|
|
36
|
+
*
|
|
37
|
+
* Opt-outs are keyed by PHONE NUMBER, not user: the reply comes from a
|
|
38
|
+
* handset, and whoever holds it has spoken for it regardless of which
|
|
39
|
+
* family member the CRM thinks owns it.
|
|
40
|
+
*
|
|
41
|
+
* Table: `sms_opt_outs` (phone unique, opted_out_at, reason).
|
|
42
|
+
*/
|
|
16
43
|
export declare interface InboundSmsResult {
|
|
17
44
|
kind: InboundSmsKind
|
|
18
45
|
reply?: string
|
|
19
46
|
}
|
|
20
|
-
export type InboundSmsKind = 'stop' | 'start' | 'help' | 'message';
|
package/dist/opt-out.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{db,sqlDateTime}from"@stacksjs/database/runtime";
|
|
1
|
+
import{db,sqlDateTime}from"@stacksjs/database/runtime";import{inboundSmsReply,matchSmsKeyword}from"./keywords";export function normalizePhone(raw){const digits=raw.replace(/[^\d+]/g,"");if(/^\d{10}$/.test(digits))return`+1${digits}`;if(/^1\d{10}$/.test(digits))return`+${digits}`;return digits}export function classifyInboundSms(body){return matchSmsKeyword(body).kind}export async function optOutPhone(phone,reason="stop-keyword"){const normalized=normalizePhone(phone);if(!normalized)return;const now=sqlDateTime(new Date);await db.insertOrIgnore("sms_opt_outs",{phone:normalized,reason,opted_out_at:now,created_at:now,updated_at:now})}export async function optInPhone(phone){const normalized=normalizePhone(phone);if(!normalized)return;await db.deleteFrom("sms_opt_outs").where("phone","=",normalized).execute()}export async function isPhoneOptedOut(phone){const normalized=normalizePhone(phone);if(!normalized)return!1;return!!await db.selectFrom("sms_opt_outs").where("phone","=",normalized).select(["id"]).executeTakeFirst()}export async function handleInboundSms(input,options={}){const{kind,locale}=matchSmsKeyword(input.body);if(kind==="stop")await optOutPhone(input.from);if(kind==="start")await optInPhone(input.from);const reply=inboundSmsReply(kind,{...options,locale});return reply?{kind,reply}:{kind}}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/sms",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.146",
|
|
6
6
|
"description": "The Stacks SMS integration. Painlessly create & manage your inboxes, templates, and send sms.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -63,13 +63,13 @@
|
|
|
63
63
|
"prepublishOnly": "bun run build"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"@stacksjs/database": "0.75.
|
|
66
|
+
"@stacksjs/database": "0.75.146"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
|
-
"@stacksjs/cli": "0.75.
|
|
70
|
-
"@stacksjs/config": "0.75.
|
|
71
|
-
"@stacksjs/error-handling": "0.75.
|
|
72
|
-
"@stacksjs/types": "0.75.
|
|
69
|
+
"@stacksjs/cli": "0.75.146",
|
|
70
|
+
"@stacksjs/config": "0.75.146",
|
|
71
|
+
"@stacksjs/error-handling": "0.75.146",
|
|
72
|
+
"@stacksjs/types": "0.75.146",
|
|
73
73
|
"better-dx": "^0.2.24"
|
|
74
74
|
}
|
|
75
75
|
}
|