From b7824b61e90b2292689b62c87ef55b78507aeeb6 Mon Sep 17 00:00:00 2001 From: danohn <82357071+danohn@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:46:17 +1000 Subject: [PATCH] Add SMS spam and scam filter template --- env-variables.manifest.json | 63 ++++ package-lock.json | 58 +++- package.json | 3 +- sms-spam-scam-filter/.owners | 1 + sms-spam-scam-filter/CHANGELOG.md | 9 + sms-spam-scam-filter/README.md | 93 ++++++ sms-spam-scam-filter/assets/index.html | 135 +++++++++ .../sms-spam-scam-filter.protected.js | 191 +++++++++++++ sms-spam-scam-filter/package.json | 8 + .../tests/sms-spam-scam-filter.test.js | 268 ++++++++++++++++++ templates.json | 5 + 11 files changed, 832 insertions(+), 2 deletions(-) create mode 100644 sms-spam-scam-filter/.owners create mode 100644 sms-spam-scam-filter/CHANGELOG.md create mode 100644 sms-spam-scam-filter/README.md create mode 100644 sms-spam-scam-filter/assets/index.html create mode 100644 sms-spam-scam-filter/functions/sms-spam-scam-filter.protected.js create mode 100644 sms-spam-scam-filter/package.json create mode 100644 sms-spam-scam-filter/tests/sms-spam-scam-filter.test.js diff --git a/env-variables.manifest.json b/env-variables.manifest.json index b5ba8888a..7c2d40552 100644 --- a/env-variables.manifest.json +++ b/env-variables.manifest.json @@ -219,6 +219,7 @@ "contentKey": null } ], + "send-receive-sms-messages": [], "stripe-payment-link-sms": [ { "key": "STRIPE_SECRET_KEY", @@ -1949,6 +1950,68 @@ "configurable": true, "contentKey": null } + ], + "sms-spam-scam-filter": [ + { + "key": "OPENAI_API_KEY", + "required": true, + "format": "secret", + "description": "API key used to call OpenAI for SMS spam and scam classification", + "link": null, + "default": null, + "configurable": true, + "contentKey": null + }, + { + "key": "OPENAI_MODEL", + "required": false, + "format": "text", + "description": "OpenAI model used for SMS classification", + "link": null, + "default": "gpt-5.6-luna", + "configurable": true, + "contentKey": null + }, + { + "key": "FORWARD_WEBHOOK_URL", + "required": true, + "format": "url", + "description": "Webhook URL that should receive inbound SMS requests classified as genuine", + "link": null, + "default": null, + "configurable": true, + "contentKey": null + }, + { + "key": "FORWARD_WEBHOOK_METHOD", + "required": false, + "format": "text", + "description": "HTTP method used when redirecting genuine SMS requests to the forwarding webhook", + "link": null, + "default": "POST", + "configurable": true, + "contentKey": null + }, + { + "key": "OPENAI_TIMEOUT_MS", + "required": false, + "format": "number", + "description": "Timeout in milliseconds for OpenAI classification requests", + "link": null, + "default": "7000", + "configurable": true, + "contentKey": null + }, + { + "key": "TWILIO_SMS_WEBHOOK_URL", + "required": true, + "format": "text", + "description": "The path to the webhook", + "link": null, + "default": "/sms-spam-scam-filter", + "configurable": false, + "contentKey": null + } ] } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e6251af4e..fa34741cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,7 +78,9 @@ "block-spam-calls", "ai-assistants-samples", "mcp-server", - "email-events-sms" + "email-events-sms", + "send-receive-sms-messages", + "sms-spam-scam-filter" ], "devDependencies": { "@twilio-labs/runtime-helpers": "^0.1.2", @@ -24130,6 +24132,46 @@ "dependencies": { "moment": "^2.30.1" } + }, + "node_modules/sms-spam-scam-filter": { + "resolved": "sms-spam-scam-filter", + "link": true + }, + "sms-spam-scam-filter": { + "version": "1.0.0", + "dependencies": { + "openai": "^6.0.0" + } + }, + "sms-spam-scam-filter/node_modules/openai": { + "version": "6.49.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.49.0.tgz", + "integrity": "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } } }, "dependencies": { @@ -41430,6 +41472,20 @@ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "requires": {} + }, + "sms-spam-scam-filter": { + "version": "file:sms-spam-scam-filter", + "requires": { + "openai": "^6.0.0" + }, + "dependencies": { + "openai": { + "version": "6.49.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.49.0.tgz", + "integrity": "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==", + "requires": {} + } + } } } } diff --git a/package.json b/package.json index b91639fbf..3923ce3a0 100644 --- a/package.json +++ b/package.json @@ -143,6 +143,7 @@ "ai-assistants-samples", "mcp-server", "email-events-sms", - "send-receive-sms-messages" + "send-receive-sms-messages", + "sms-spam-scam-filter" ] } diff --git a/sms-spam-scam-filter/.owners b/sms-spam-scam-filter/.owners new file mode 100644 index 000000000..0274f082b --- /dev/null +++ b/sms-spam-scam-filter/.owners @@ -0,0 +1 @@ +danohn diff --git a/sms-spam-scam-filter/CHANGELOG.md b/sms-spam-scam-filter/CHANGELOG.md new file mode 100644 index 000000000..144828c26 --- /dev/null +++ b/sms-spam-scam-filter/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## [Unreleased] + +## [1.0.0] + +### Added + +- Initial release. diff --git a/sms-spam-scam-filter/README.md b/sms-spam-scam-filter/README.md new file mode 100644 index 000000000..1cde5c8db --- /dev/null +++ b/sms-spam-scam-filter/README.md @@ -0,0 +1,93 @@ +# SMS Spam and Scam Filter + +This application filters inbound SMS messages before they reach your downstream webhook. Incoming messages are classified with OpenAI as either `spam_scam` or `genuine`. Spam and scam messages are ignored with an empty TwiML response. Genuine messages are redirected to your webhook with TwiML ``. + +## Pre-requisites + +- A [Twilio account](https://www.twilio.com/try-twilio) with an active phone number that can receive SMS +- An OpenAI API key +- A webhook URL that should receive genuine inbound SMS requests + +### Environment variables + +`.env` is included with placeholder values. Update the values before running. Never commit real secrets in `.env`. + +| Variable | Description | Required | +| :------- | :---------- | :------- | +| `OPENAI_API_KEY` | API key used to call OpenAI for SMS spam and scam classification | Yes | +| `OPENAI_MODEL` | OpenAI model used for SMS classification. Defaults to `gpt-5.6-luna` | No | +| `FORWARD_WEBHOOK_URL` | Webhook URL that should receive inbound SMS requests classified as genuine | Yes | +| `FORWARD_WEBHOOK_METHOD` | HTTP method used when redirecting genuine SMS requests. Defaults to `POST` | No | +| `OPENAI_TIMEOUT_MS` | Timeout in milliseconds for OpenAI classification requests. Defaults to `7000` | No | +| `TWILIO_SMS_WEBHOOK_URL` | The webhook path — pre-set to `/sms-spam-scam-filter`, do not change | Yes | + +## Running the project + +1. Install the [Twilio CLI](https://www.twilio.com/docs/twilio-cli/quickstart#install-twilio-cli) +1. Install the [serverless toolkit](https://www.twilio.com/docs/labs/serverless-toolkit/getting-started) + +```shell +twilio plugins:install @twilio-labs/plugin-serverless +``` + +1. Initialize the project from the template + +```shell +twilio serverless:init example --template=sms-spam-scam-filter --skip-credentials && cd example +``` + +1. Edit `.env` and set `OPENAI_API_KEY` and `FORWARD_WEBHOOK_URL` +1. Start the server + +```shell +twilio serverless:start +``` + +Check the developer console and terminal for any errors, and make sure you've set your environment variables. + +### Configuring your Twilio phone number + +You can configure the webhook via the Twilio CLI (recommended) or the Console. + +**Via CLI:** + +Find your phone number SID: + +```shell +twilio api:core:incoming-phone-numbers:list +``` + +Then update the SMS webhook: + +```shell +twilio api:core:incoming-phone-numbers:update \ + --sid \ + --sms-url https://.ngrok.app/sms-spam-scam-filter \ + --sms-method POST +``` + +**Via Console:** + +1. Go to your [Twilio Console Phone Numbers](https://console.twilio.com/us1/develop/phone-numbers/manage/incoming) +2. Select the phone number you want to use +3. Under "Messaging Configuration": + - Set "A Message Comes In" to **Webhook** + - Enter your URL: `https://.ngrok.app/sms-spam-scam-filter` (or your deployed URL) + - Set HTTP method to **POST** +4. Click **Save** + +## Cost estimate + +This estimate covers only the OpenAI classification request. It does not include Twilio messaging, carrier, phone number, or webhook hosting costs. + +Using `gpt-5.6-luna` pricing of `$0.20` per 1M input tokens and `$1.20` per 1M output tokens, a typical one-segment SMS classification costs about `$0.0001` per checked message. + +## Deploying + +Deploy your functions and assets with the following command. Note: you must run this command from inside your project folder. [More information about the serverless toolkit is available in the docs.](https://www.twilio.com/docs/labs/serverless-toolkit) + +With the [Twilio CLI](https://www.twilio.com/docs/twilio-cli/quickstart): + +```shell +twilio serverless:deploy --service-name sms-spam-scam-filter +``` diff --git a/sms-spam-scam-filter/assets/index.html b/sms-spam-scam-filter/assets/index.html new file mode 100644 index 000000000..d3f13a23f --- /dev/null +++ b/sms-spam-scam-filter/assets/index.html @@ -0,0 +1,135 @@ + + + + + + + SMS Spam and Scam Filter + + + + + + + +
+
+ + +
+
+
+
+

+ +
+

Welcome!

+

Your SMS spam and scam filter is ready to use.

+
+

+
+

Get started with your application

+

+ Follow the setup steps located in the + SMS Spam and Scam Filter functions template repo + to get started. +

+ +

+ This app classifies inbound SMS messages with OpenAI. Spam and scam + messages are blocked, and genuine messages are redirected to your + configured webhook. +

+
+
+ +
+
+

Troubleshooting

+
    +
  • + Check the + + phone number configuration + + and make sure the Twilio phone number you want for your app has an + SMS webhook configured to point at the following URL +
    + + +
    +
  • +
+
+
+
+
+ We can't wait to see what you build. +
+ + diff --git a/sms-spam-scam-filter/functions/sms-spam-scam-filter.protected.js b/sms-spam-scam-filter/functions/sms-spam-scam-filter.protected.js new file mode 100644 index 000000000..9550fd9ec --- /dev/null +++ b/sms-spam-scam-filter/functions/sms-spam-scam-filter.protected.js @@ -0,0 +1,191 @@ +/* eslint-disable camelcase */ + +const DEFAULT_MODEL = 'gpt-5.6-luna'; +const DEFAULT_TIMEOUT_MS = 7000; +const MAX_SMS_CHARS_SENT_TO_MODEL = 2000; + +function buildPrompt(event) { + const body = String(event.Body || '').slice(0, MAX_SMS_CHARS_SENT_TO_MODEL); + + return { + from: event.From || null, + to: event.To || null, + body, + numMedia: event.NumMedia || '0', + }; +} + +function buildOpenAIRequestBody(model, event) { + return { + model, + store: false, + max_output_tokens: 120, + reasoning: { + effort: 'none', + }, + input: [ + { + role: 'system', + content: [ + { + type: 'input_text', + text: [ + 'Classify an inbound SMS as either spam/scam or genuine.', + 'Spam/scam includes phishing, impersonation, fake delivery/banking notices, credential harvesting, prize scams, investment scams, malware links, and unsolicited promotional spam.', + 'Genuine includes expected personal, operational, customer, appointment, account, support, or transactional messages.', + 'Prefer spam_scam when the message is unsolicited and asks for money, credentials, urgent action, or link clicks.', + 'Return only the requested JSON schema.', + ].join(' '), + }, + ], + }, + { + role: 'user', + content: [ + { + type: 'input_text', + text: JSON.stringify(buildPrompt(event)), + }, + ], + }, + ], + text: { + format: { + type: 'json_schema', + name: 'sms_spam_classification', + strict: true, + schema: { + type: 'object', + additionalProperties: false, + required: ['classification', 'confidence', 'reason'], + properties: { + classification: { + type: 'string', + enum: ['spam_scam', 'genuine'], + }, + confidence: { + type: 'number', + minimum: 0, + maximum: 1, + }, + reason: { + type: 'string', + }, + }, + }, + }, + }, + }; +} + +function loadOpenAI() { + const OpenAI = require('openai'); + return OpenAI.default || OpenAI; +} + +function extractResponseText(response) { + if (typeof response.output_text === 'string') { + return response.output_text; + } + + if (!Array.isArray(response.output)) { + return ''; + } + + return response.output + .flatMap((item) => (Array.isArray(item.content) ? item.content : [])) + .map((content) => content.text || '') + .join(''); +} + +function parseClassification(response) { + const text = extractResponseText(response).trim(); + + if (!text) { + throw new Error('OpenAI response did not include output text'); + } + + const parsed = JSON.parse(text); + + if (!['spam_scam', 'genuine'].includes(parsed.classification)) { + throw new Error(`Unexpected classification: ${parsed.classification}`); + } + + return parsed; +} + +async function callOpenAIClassifier(context, event, OpenAIOverride) { + const apiKey = context.OPENAI_API_KEY; + const model = context.OPENAI_MODEL || DEFAULT_MODEL; + const timeoutMs = Number(context.OPENAI_TIMEOUT_MS || DEFAULT_TIMEOUT_MS); + const requestTimeoutMs = + Number.isFinite(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : DEFAULT_TIMEOUT_MS; + + if (!apiKey) { + throw new Error('OPENAI_API_KEY is required'); + } + + const OpenAI = OpenAIOverride || loadOpenAI(); + const client = new OpenAI({ + apiKey, + timeout: requestTimeoutMs, + maxRetries: 0, + }); + + const response = await client.responses.create( + buildOpenAIRequestBody(model, event), + { + timeout: requestTimeoutMs, + maxRetries: 0, + } + ); + + return parseClassification(response); +} + +function createHandler(classifier) { + return async function smsSpamScamFilter(context, event, callback) { + const twiml = new Twilio.twiml.MessagingResponse(); + const forwardUrl = context.FORWARD_WEBHOOK_URL; + const forwardMethod = context.FORWARD_WEBHOOK_METHOD || 'POST'; + + if (!forwardUrl) { + console.error('FORWARD_WEBHOOK_URL is required'); + return callback(null, twiml); + } + + if (!event.Body || !String(event.Body).trim()) { + console.log('Blocked SMS with empty body'); + return callback(null, twiml); + } + + try { + const result = await classifier(context, event); + console.log( + `SMS classification: ${result.classification} (${result.confidence})` + ); + + if (result.classification === 'genuine') { + twiml.redirect({ method: forwardMethod }, forwardUrl); + } + + return callback(null, twiml); + } catch (err) { + console.error(`SMS classification failed: ${err.message}`); + return callback(null, twiml); + } + }; +} + +exports.handler = createHandler(callOpenAIClassifier); + +exports._test = { + buildOpenAIRequestBody, + callOpenAIClassifier, + createHandler, + extractResponseText, + loadOpenAI, + parseClassification, +}; diff --git a/sms-spam-scam-filter/package.json b/sms-spam-scam-filter/package.json new file mode 100644 index 000000000..597a6b690 --- /dev/null +++ b/sms-spam-scam-filter/package.json @@ -0,0 +1,8 @@ +{ + "name": "sms-spam-scam-filter", + "version": "1.0.0", + "private": true, + "dependencies": { + "openai": "^6.0.0" + } +} diff --git a/sms-spam-scam-filter/tests/sms-spam-scam-filter.test.js b/sms-spam-scam-filter/tests/sms-spam-scam-filter.test.js new file mode 100644 index 000000000..62660e437 --- /dev/null +++ b/sms-spam-scam-filter/tests/sms-spam-scam-filter.test.js @@ -0,0 +1,268 @@ +/* eslint-disable camelcase */ + +const helpers = require('../../test/test-helper'); + +const smsSpamScamFilter = + require('../functions/sms-spam-scam-filter.protected').handler; +const { _test } = require('../functions/sms-spam-scam-filter.protected'); + +const context = { + FORWARD_WEBHOOK_URL: 'https://example.com/twilio/inbound-sms', + FORWARD_WEBHOOK_METHOD: 'POST', +}; + +beforeAll(() => { + helpers.setup(context); +}); + +afterAll(() => { + helpers.teardown(); +}); + +function invoke(handler, event, contextOverride) { + return new Promise((resolve, reject) => { + handler(contextOverride || context, event, (err, result) => { + if (err) { + reject(err); + return; + } + + resolve(result); + }); + }); +} + +test('returns an empty MessagingResponse for empty messages', async () => { + const twiml = await invoke(smsSpamScamFilter, { Body: '' }); + + expect(twiml.toString()).toBe( + '' + ); +}); + +test('redirects genuine messages to the configured webhook', async () => { + const handler = _test.createHandler(async () => ({ + classification: 'genuine', + confidence: 0.93, + reason: 'Expected support message.', + })); + + const twiml = await invoke(handler, { + Body: 'Can you call me about ticket 123?', + }); + + expect(twiml.toString()).toBe( + 'https://example.com/twilio/inbound-sms' + ); +}); + +test('does not redirect spam or scam messages', async () => { + const handler = _test.createHandler(async () => ({ + classification: 'spam_scam', + confidence: 0.98, + reason: 'Credential phishing.', + })); + + const twiml = await invoke(handler, { + Body: 'Your bank is locked. Verify now at http://bad.example', + }); + + expect(twiml.toString()).toBe( + '' + ); +}); + +test('does not redirect when classification fails', async () => { + const handler = _test.createHandler(async () => { + throw new Error('network unavailable'); + }); + + const twiml = await invoke(handler, { Body: 'Hi, are you available?' }); + + expect(twiml.toString()).toBe( + '' + ); +}); + +test('does not redirect when the forwarding webhook is missing', async () => { + const handler = _test.createHandler(async () => ({ + classification: 'genuine', + confidence: 0.99, + reason: 'Expected message.', + })); + + const twiml = await invoke(handler, { Body: 'Hi there' }, {}); + + expect(twiml.toString()).toBe( + '' + ); +}); + +test('parses structured output from the Responses API', () => { + const parsed = _test.parseClassification({ + output_text: JSON.stringify({ + classification: 'genuine', + confidence: 0.85, + reason: 'Conversation-like message.', + }), + }); + + expect(parsed.classification).toBe('genuine'); +}); + +test('extracts structured output from nested response content', () => { + const outputText = _test.extractResponseText({ + output: [ + { + content: [ + { + text: JSON.stringify({ + classification: 'spam_scam', + confidence: 0.8, + reason: 'Promotional link.', + }), + }, + ], + }, + ], + }); + + expect(JSON.parse(outputText).classification).toBe('spam_scam'); +}); + +test('returns empty output text when the Responses API output is absent', () => { + expect(_test.extractResponseText({})).toBe(''); +}); + +test('rejects structured output without response text', () => { + expect(() => _test.parseClassification({})).toThrow( + 'OpenAI response did not include output text' + ); +}); + +test('rejects unexpected classifications', () => { + expect(() => + _test.parseClassification({ + output_text: JSON.stringify({ + classification: 'unknown', + confidence: 0.1, + reason: 'Invalid test value.', + }), + }) + ).toThrow('Unexpected classification: unknown'); +}); + +test('uses gpt-5.6-luna and a strict spam classification schema', () => { + const body = _test.buildOpenAIRequestBody('gpt-5.6-luna', { + From: '+15551234567', + To: '+15557654321', + Body: 'Delivery failed. Pay a fee now.', + }); + + expect(body.model).toBe('gpt-5.6-luna'); + expect(body.text.format.strict).toBe(true); + expect(body.text.format.schema.properties.classification.enum).toEqual([ + 'spam_scam', + 'genuine', + ]); +}); + +test('loads the OpenAI SDK constructor', () => { + expect(typeof _test.loadOpenAI()).toBe('function'); +}); + +test('classifies through the OpenAI SDK responses API', async () => { + const calls = []; + + class FakeOpenAI { + constructor(options) { + calls.push({ type: 'constructor', options }); + this.responses = { + create: async (body, options) => { + calls.push({ type: 'responses.create', body, options }); + return { + output_text: JSON.stringify({ + classification: 'spam_scam', + confidence: 0.91, + reason: 'Fake delivery phishing.', + }), + }; + }, + }; + } + } + + const result = await _test.callOpenAIClassifier( + { + OPENAI_API_KEY: 'test-key', + OPENAI_MODEL: 'gpt-5.6-luna', + OPENAI_TIMEOUT_MS: '1234', + }, + { + From: '+15551234567', + To: '+15557654321', + Body: 'Delivery failed. Pay a fee now.', + }, + FakeOpenAI + ); + + expect(result.classification).toBe('spam_scam'); + expect(calls[0]).toEqual({ + type: 'constructor', + options: { + apiKey: 'test-key', + timeout: 1234, + maxRetries: 0, + }, + }); + expect(calls[1].type).toBe('responses.create'); + expect(calls[1].body.model).toBe('gpt-5.6-luna'); + expect(calls[1].options).toEqual({ + timeout: 1234, + maxRetries: 0, + }); +}); + +test('uses default model and timeout when context values are absent', async () => { + const calls = []; + + class FakeOpenAI { + constructor(options) { + calls.push({ type: 'constructor', options }); + this.responses = { + create: async (body, options) => { + calls.push({ type: 'responses.create', body, options }); + return { + output_text: JSON.stringify({ + classification: 'genuine', + confidence: 0.91, + reason: 'Expected message.', + }), + }; + }, + }; + } + } + + const result = await _test.callOpenAIClassifier( + { + OPENAI_API_KEY: 'test-key', + OPENAI_TIMEOUT_MS: 'not-a-number', + }, + { + Body: 'Can you call me back?', + }, + FakeOpenAI + ); + + expect(result.classification).toBe('genuine'); + expect(calls[0].options.timeout).toBe(7000); + expect(calls[1].body.model).toBe('gpt-5.6-luna'); + expect(calls[1].options.timeout).toBe(7000); +}); + +test('requires an OpenAI API key', async () => { + await expect( + _test.callOpenAIClassifier({}, { Body: 'Hello' }) + ).rejects.toThrow('OPENAI_API_KEY is required'); +}); diff --git a/templates.json b/templates.json index 1c889af68..26faa50be 100644 --- a/templates.json +++ b/templates.json @@ -369,6 +369,11 @@ "id": "send-receive-sms-messages", "name": "Send & receive SMS messages with Twilio", "description": "Demonstrates how to send & receive SMS messages with Twilio" + }, + { + "id": "sms-spam-scam-filter", + "name": "SMS Spam and Scam Filter", + "description": "Filters inbound SMS for spam and scams with OpenAI, then redirects genuine messages to your webhook." } ] }