@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.
@@ -1,2 +1,2 @@
1
- import{createHmac,timingSafeEqual}from"node:crypto";const DEFAULT_OPT_OUT=["STOP","UNSUBSCRIBE","CANCEL","END","QUIT"],DEFAULT_OPT_IN=["START","UNSTOP","YES"],DEFAULT_HELP=["HELP","INFO"],GSM_7_BASIC=new Set(`@\xA3$\xA5\xE8\xE9\xF9\xEC\xF2\xC7
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 './opt-out';
1
+ import { type InboundSmsKind, type SmsKeywordMatch } from './keywords';
2
2
  /**
3
- * Which keyword a message is. Twilio's Advanced Opt-Out names it in
4
- * `OptOutType` (and knows any custom keywords configured there); otherwise
5
- * the standard CTIA keywords decide.
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>): InboundSmsKind;
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{classifyInboundSms,inboundSmsReply,normalizePhone,optInPhone,optOutPhone}from"./opt-out";function twiml(message){const escaped=message?.replace(/[&<>"']/g,(c)=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&apos;"})[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();if(optOutType==="STOP")return"stop";if(optOutType==="START")return"start";if(optOutType==="HELP")return"help";return classifyInboundSms(fields.Body??"")}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 kind=inboundSmsKind(fields);if(kind==="stop")await optOutPhone(from);if(kind==="start")await optInPhone(from);if(kind==="stop"||kind==="start")await options.onConsentChange?.({phone:from,kind});await options.onMessage?.({from,to:fields.To??"",body:fields.Body??"",messageId:fields.MessageSid??fields.SmsSid,kind});return twiml(options.reply?inboundSmsReply(kind,options):void 0)}
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)=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&apos;"})[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
@@ -5,6 +5,7 @@
5
5
  */
6
6
  // Main SMS facade
7
7
  export * from './inbound';
8
+ export * from './keywords';
8
9
  export * from './opt-out';
9
10
  export * from './sms';
10
11
  export { default as SMS } from './sms';
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';
@@ -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";const STOP_WORDS=new Set(["stop","stopall","unsubscribe","cancel","end","quit"]),START_WORDS=new Set(["start","yes","unstop"]),HELP_WORDS=new Set(["help","info"]);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){const word=body.trim().toLowerCase().split(/\s+/)[0]??"";if(STOP_WORDS.has(word))return"stop";if(START_WORDS.has(word))return"start";if(HELP_WORDS.has(word))return"help";return"message"}export async function optOutPhone(phone,reason="stop-keyword"){const normalized=normalizePhone(phone);if(!normalized)return;await db.deleteFrom("sms_opt_outs").where("phone","=",normalized).execute();await db.insertInto("sms_opt_outs").values({phone:normalized,reason,opted_out_at:sqlDateTime(new Date),created_at:sqlDateTime(new Date),updated_at:sqlDateTime(new Date)}).execute()}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 function inboundSmsReply(kind,options={}){const name=options.appName??"this service";if(kind==="stop")return`You are unsubscribed from ${name} texts. No more messages will be sent. Reply START to resubscribe.`;if(kind==="start")return`You are resubscribed to ${name} texts. Reply STOP to unsubscribe.`;if(kind==="help")return`${name}: reply STOP to unsubscribe, START to resubscribe.${options.helpContact?` Contact: ${options.helpContact}`:""}`;return}export async function handleInboundSms(input,options={}){const kind=classifyInboundSms(input.body);if(kind==="stop")await optOutPhone(input.from);if(kind==="start")await optInPhone(input.from);const reply=inboundSmsReply(kind,options);return reply?{kind,reply}:{kind}}
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.145",
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.145"
66
+ "@stacksjs/database": "0.75.146"
67
67
  },
68
68
  "devDependencies": {
69
- "@stacksjs/cli": "0.75.145",
70
- "@stacksjs/config": "0.75.145",
71
- "@stacksjs/error-handling": "0.75.145",
72
- "@stacksjs/types": "0.75.145",
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
  }