@stacksjs/sms 0.75.139 → 0.75.141
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/inbound.d.ts +43 -0
- package/dist/inbound.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -1
- package/dist/opt-out.d.ts +4 -1
- package/dist/opt-out.js +1 -1
- package/package.json +6 -6
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type InboundSmsKind } from './opt-out';
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
export declare function inboundSmsKind(fields: Record<string, string | undefined>): InboundSmsKind;
|
|
8
|
+
export declare function handleTwilioInbound(request: Request, options?: TwilioInboundOptions): Promise<Response>;
|
|
9
|
+
/**
|
|
10
|
+
* A Twilio "a message comes in" webhook, end to end: verify Twilio's
|
|
11
|
+
* signature, read the message, record STOP/START in `sms_opt_outs`, tell the
|
|
12
|
+
* app (so it can switch its own preferences), and answer with TwiML.
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* // routes: route.post('/sms/inbound', 'Actions/SmsInboundAction').skipCsrf()
|
|
16
|
+
* export default new Action({ method: 'POST', handle: request => handleTwilioInbound(request, {
|
|
17
|
+
* onConsentChange: ({ phone, kind }) => setMarketingSms(phone, kind === 'start'),
|
|
18
|
+
* }) })
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Point the number's (or messaging service's) incoming-message webhook at
|
|
22
|
+
* that route, as an HTTP POST.
|
|
23
|
+
*/
|
|
24
|
+
export declare interface InboundConsentChange {
|
|
25
|
+
phone: string
|
|
26
|
+
kind: 'stop' | 'start'
|
|
27
|
+
}
|
|
28
|
+
export declare interface InboundSmsMessage {
|
|
29
|
+
from: string
|
|
30
|
+
to: string
|
|
31
|
+
body: string
|
|
32
|
+
messageId?: string
|
|
33
|
+
kind: InboundSmsKind
|
|
34
|
+
}
|
|
35
|
+
export declare interface TwilioInboundOptions {
|
|
36
|
+
authToken?: string
|
|
37
|
+
publicUrl?: string
|
|
38
|
+
reply?: boolean
|
|
39
|
+
appName?: string
|
|
40
|
+
helpContact?: string
|
|
41
|
+
onConsentChange?: (change: InboundConsentChange) => unknown
|
|
42
|
+
onMessage?: (message: InboundSmsMessage) => unknown
|
|
43
|
+
}
|
package/dist/inbound.js
ADDED
|
@@ -0,0 +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)=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[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)}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
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"./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";
|
package/dist/opt-out.d.ts
CHANGED
|
@@ -4,10 +4,13 @@ export declare function optOutPhone(phone: string, reason?: string): Promise<voi
|
|
|
4
4
|
export declare function optInPhone(phone: string): Promise<void>;
|
|
5
5
|
/** Check before EVERY non-emergency send. */
|
|
6
6
|
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;
|
|
7
9
|
/**
|
|
8
10
|
* Process one inbound SMS (a provider webhook's job): record STOP/START,
|
|
9
11
|
* return the compliance reply to send. Ordinary messages pass through as
|
|
10
|
-
* `kind: 'message'` for the app to route (or ignore).
|
|
12
|
+
* `kind: 'message'` for the app to route (or ignore). For Twilio,
|
|
13
|
+
* `handleTwilioInbound` does this and the signature check in one call.
|
|
11
14
|
*/
|
|
12
15
|
export declare function handleInboundSms(input: { from: string, body: string }, options?: { appName?: string, helpContact?: string }): Promise<InboundSmsResult>;
|
|
13
16
|
export declare interface InboundSmsResult {
|
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
|
|
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}}
|
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.141",
|
|
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.141"
|
|
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.141",
|
|
70
|
+
"@stacksjs/config": "0.75.141",
|
|
71
|
+
"@stacksjs/error-handling": "0.75.141",
|
|
72
|
+
"@stacksjs/types": "0.75.141",
|
|
73
73
|
"better-dx": "^0.2.24"
|
|
74
74
|
}
|
|
75
75
|
}
|