diff --git a/modules/sms/config.ts b/modules/sms/config.ts new file mode 100644 index 00000000..75477364 --- /dev/null +++ b/modules/sms/config.ts @@ -0,0 +1,9 @@ +import { TestConfig } from "./utils/providers/test.ts"; +import { TwilioConfig } from "./utils/providers/twilio.ts"; + +export interface Config { + provider: Provider; + convertCurrencyTo?: string; +} + +export type Provider = { test: TestConfig } | { twilio: TwilioConfig }; diff --git a/modules/sms/module.json b/modules/sms/module.json new file mode 100644 index 00000000..3f838e37 --- /dev/null +++ b/modules/sms/module.json @@ -0,0 +1,29 @@ +{ + "name": "SMS", + "description": "Send SMS messages using multiple providers.", + "icon": "envelope", + "tags": [ + "sms" + ], + "authors": [ + "rivet-gg", + "Blckbrry-Pi" + ], + "status": "stable", + "scripts": { + "send_sms": { + "name": "Send SMS" + }, + "get_message": { + "name": "Get metadata of message, such as cost, from, to, and when it was sent." + } + }, + "errors": { + "twilio_error": { + "name": "Twilio SMS Error" + }, + "currency_error": { + "name": "Currency Conversion or Recognition Error" + } + } +} \ No newline at end of file diff --git a/modules/sms/scripts/get_message.ts b/modules/sms/scripts/get_message.ts new file mode 100644 index 00000000..5f6926ae --- /dev/null +++ b/modules/sms/scripts/get_message.ts @@ -0,0 +1,27 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; +import { Message } from "../utils/types.ts"; +import { TestProvider } from "../utils/providers/test.ts"; +import { TwilioProvider } from "../utils/providers/twilio.ts"; +import { SmsProvider } from "../utils/providers/index.ts"; + +export interface Request { + id: string; +} + +export type Response = Message; + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + let provider: SmsProvider; + if ("test" in ctx.config.provider) { + provider = new TestProvider(ctx.config.provider.test); + } else if ("twilio" in ctx.config.provider) { + provider = new TwilioProvider(ctx.config.provider.twilio); + } else { + throw new RuntimeError("unreachable"); + } + + return await provider.fetch(req.id); +} diff --git a/modules/sms/scripts/send_sms.ts b/modules/sms/scripts/send_sms.ts new file mode 100644 index 00000000..5e230227 --- /dev/null +++ b/modules/sms/scripts/send_sms.ts @@ -0,0 +1,29 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; +import { Message } from "../utils/types.ts"; +import { TestProvider } from "../utils/providers/test.ts"; +import { TwilioProvider } from "../utils/providers/twilio.ts"; +import { SmsProvider } from "../utils/providers/index.ts"; + +export interface Request { + to: string; + content: string; + useGSM7?: boolean; +} + +export type Response = Message; + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + let provider: SmsProvider; + if ("test" in ctx.config.provider) { + provider = new TestProvider(ctx.config.provider.test); + } else if ("twilio" in ctx.config.provider) { + provider = new TwilioProvider(ctx.config.provider.twilio); + } else { + throw new RuntimeError("unreachable"); + } + + return await provider.send(req.to, req.content, req.useGSM7 ?? false); +} diff --git a/modules/sms/tests/e2e.ts b/modules/sms/tests/e2e.ts new file mode 100644 index 00000000..3ac9fce7 --- /dev/null +++ b/modules/sms/tests/e2e.ts @@ -0,0 +1,40 @@ +import { test, TestContext } from "../module.gen.ts"; +import { assertExists, assertEquals, assertNotEquals, assertGreaterOrEqual } from "https://deno.land/std@0.217.0/assert/mod.ts"; +import { faker } from "https://deno.land/x/deno_faker@v1.0.3/mod.ts"; +import { MessageStatus } from "../utils/types.ts"; + +test("Test sms send E2E", async (ctx: TestContext) => { + const username = faker.internet.userName(); + const fakeCode = faker.random.alphaNumeric(6); + + const line0 = `Hello, ${username}! This is a test message for OpenGB.`; + const line1 = `Have a cool code: ${fakeCode}!`; + const messageContent = `${line0}\n${line1}`; + + const phoneNumber = Deno.env.get("TEST_SMS_NUMBER") ?? "+1234567890"; + + const initialMessage = await ctx.modules.sms.sendSms({ + content: messageContent, + to: phoneNumber, + }); + assertExists(initialMessage); + assertEquals(initialMessage.body, messageContent); + assertEquals(initialMessage.to, phoneNumber); + + while (true) { + const message = await ctx.modules.sms.getMessage({ id: initialMessage.id }); + assertExists(message); + assertEquals(message.body, initialMessage.body); + assertEquals(message.id, initialMessage.id); + assertEquals(message.to, initialMessage.to); + + assertNotEquals(message.status, MessageStatus.FAILED); + if (message.status === MessageStatus.SENT) break; + } + + const message = await ctx.modules.sms.getMessage({ id: initialMessage.id }); + + if (message.price) { + assertGreaterOrEqual(message.price[0], 0); + } +}); diff --git a/modules/sms/utils/currency.ts b/modules/sms/utils/currency.ts new file mode 100644 index 00000000..49488eb9 --- /dev/null +++ b/modules/sms/utils/currency.ts @@ -0,0 +1,16 @@ +import { RuntimeError } from "../module.gen.ts"; + +export async function convertCurrencyTo(from: string, to: string, amount: number): Promise { + if (from === to) return amount; + + const url = `https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/${to}.json`; + try { + const res = await fetch(url); + const data = await res.json(); + const fromPerTo = data[to][from]; + if (!fromPerTo || typeof fromPerTo !== "number") throw new Error(); + return amount / fromPerTo; + } catch { + throw new RuntimeError("currency_conversion_error", { meta: { from, to } }); + } +} diff --git a/modules/sms/utils/providers/index.ts b/modules/sms/utils/providers/index.ts new file mode 100644 index 00000000..6986bce0 --- /dev/null +++ b/modules/sms/utils/providers/index.ts @@ -0,0 +1,23 @@ +export enum MessageStatus { + QUEUED = "queued", + SENT = "sent", + FAILED = "failed", +} + +export interface Message { + id: string; + + to: string; + from: string; + body: string; + segments: number; + price?: [number, string]; + priceUSD?: number; + + status: MessageStatus; +} + +export abstract class SmsProvider { + public abstract send(to: string, content: string, useGSM7: boolean): Promise; + public abstract fetch(id: string): Promise; +} diff --git a/modules/sms/utils/providers/test.ts b/modules/sms/utils/providers/test.ts new file mode 100644 index 00000000..6ff237f1 --- /dev/null +++ b/modules/sms/utils/providers/test.ts @@ -0,0 +1,57 @@ +import { Empty } from "../../module.gen.ts"; +import { Message, MessageStatus, SmsProvider } from "./index.ts"; + +export type TestConfig = Empty; + +export class TestProvider extends SmsProvider { + protected config: TestConfig; + + public constructor(config: TestConfig) { + super(); + + this.config = config; + } + + public async send(to: string, body: string): Promise { + const isGSM7 = body.match(/^[A-Za-z0-9\+\/\=]*$/); + const gsm7Segments = Math.ceil(body.length / 160); + const unicodeSegments = Math.ceil(body.length / 70); + const segments = isGSM7 ? gsm7Segments : unicodeSegments; + const from = "+1 (000) 000-0000"; + + // Encode the metadata into the ID so we can pull it out later + const id = btoa(JSON.stringify({ + to, + from, + segments, + body, + date: new Date().toISOString(), + })); + + return await Promise.resolve({ + id, + from, + to, + status: MessageStatus.SENT, + segments, + body, + price: [0, "usd"], + priceUSD: 0, + }); + } + + public async fetch(id: string): Promise { + const metadata = JSON.parse(atob(id)); + + return await Promise.resolve({ + id, + from: metadata.from, + to: metadata.to, + segments: metadata.segments, + status: MessageStatus.SENT, + body: metadata.body, + price: [0, "usd"], + priceUSD: 0, + }); + } +} diff --git a/modules/sms/utils/providers/twilio.ts b/modules/sms/utils/providers/twilio.ts new file mode 100644 index 00000000..08f8eb2d --- /dev/null +++ b/modules/sms/utils/providers/twilio.ts @@ -0,0 +1,220 @@ +import { RuntimeError } from "../../module.gen.ts"; +import { Message, MessageStatus, SmsProvider } from "./index.ts"; + +export interface TwilioConfig { + accountSID?: string; + authToken?: string; + from?: string; + useGSM7?: boolean; +} +interface FullTwilioConfig { + accountSID: string; + authToken: string; + from: string; + useGSM7: boolean; +} + +export class TwilioProvider extends SmsProvider { + protected config: FullTwilioConfig; + + public constructor(config: TwilioConfig) { + super(); + + const from = config.from ?? Deno.env.get("SMS_TWILIO_FROM"); + const accountSID = config.accountSID ?? Deno.env.get("SMS_TWILIO_ACCOUNT_SID"); + const authToken = config.authToken ?? Deno.env.get("SMS_TWILIO_AUTH_TOKEN"); + + if (!from) throw new RuntimeError("twilio_error", { cause: "from" }); + if (!accountSID) throw new RuntimeError("twilio_error", { cause: "accountSID" }); + if (!authToken) throw new RuntimeError("twilio_error", { cause: "authToken" }); + + this.config = { + accountSID, + authToken, + from, + useGSM7: config.useGSM7 ?? false, + }; + } + + public async send(to: string, content: string, useGSM7?: boolean): Promise { + const willUseGSM7 = useGSM7 ?? this.config.useGSM7; + + const body = new URLSearchParams({ + "To": to, + "From": this.config.from, + "Body": content, + "SmartEncoded": willUseGSM7 ? "true" : "false", + }); + + const data = await this.makeRequest(this.sendURL, "POST", body); + return this.handleResponse(data); + } + + public async fetch(sid: string): Promise { + const data = await this.makeRequest(this.fetchURL(sid), "GET"); + return this.handleResponse(data); + } + + private async makeRequest(url: URL, method: string, body?: BodyInit): Promise { + const response = await fetch(url, { + method, + headers: { + "Authorization": this.authHeader, + }, + body, + }); + + if (!response.ok) { + throw new RuntimeError( + "twilio_error", + { + statusCode: response.status, + cause: "twilio", + // cause: await response.json(), + }, + ); + } + + let data: unknown; + try { + data = await response.json(); + } catch { + throw new RuntimeError( + "twilio_error", + { + statusCode: 500, + cause: "response_parse", + }, + ); + } + + return data; + } + + private handleResponse(data: unknown): Message { + const getDataError = () => new RuntimeError("twilio_error", { cause: "response_data", meta: { data } }); + + if (typeof data !== "object" || data === null) throw getDataError(); + + + const { + sid: id, + to, + from, + body, + num_segments: segmentsStr, + status: resStatus, + price: resPrice, + price_unit: resPriceUnit, + } = data as Record; + + if (typeof id !== "string" || typeof to !== "string" || typeof from !== "string") throw getDataError(); + if (typeof body !== "string") throw getDataError(); + if (typeof resStatus !== "string") throw getDataError(); + const status = TwilioProvider.messageStatus(resStatus); + + if (typeof segmentsStr !== "string") throw getDataError(); + const segments = Number(segmentsStr); + if (typeof segments !== "number" || !Number.isSafeInteger(segments)) throw getDataError(); + + if (typeof resPrice !== "number" && resPrice !== null) throw getDataError(); + if (typeof resPriceUnit !== "string" && resPriceUnit !== null) throw getDataError(); + const priceData: [number, string] | undefined = resPrice && resPriceUnit ? [resPrice, resPriceUnit] : undefined; + + return { + id, + to, + from, + body, + segments, + price: priceData, + status, + }; + } + + private get baseURL() { + return new URL(`https://api.twilio.com/2010-04-01/Accounts/${this.config.accountSID}`); + } + private get sendURL() { + return new URL(`${this.baseURL.toString()}/Messages.json`); + } + private fetchURL(sid: string) { + return new URL(`${this.baseURL.toString()}/Messages/${sid}.json`); + } + private get authHeader() { + const val = `${this.config.accountSID}:${this.config.authToken}`; + return `Basic ${btoa(val)}`; + } + + private static messageStatus(twilioStatus: string): MessageStatus { + switch (twilioStatus) { + case "accepted": + case "scheduled": + case "queued": + case "sending": + return MessageStatus.QUEUED; + + case "sent": + case "delivered": + return MessageStatus.SENT; + + case "canceled": + case "failed": + case "undelivered": + return MessageStatus.FAILED; + + case "receiving": + case "received": + case "read": + default: + // This should never happen + return MessageStatus.FAILED; + } + } +} + + +// async function useSendGrid(config: ProviderSendGrid, req: Request) { +// const apiKeyVariable = config.apiKeyVariable ?? "SENDGRID_API_KEY"; +// const apiKey = Deno.env.get(apiKeyVariable); +// assertExists(apiKey, `Missing environment variable: ${apiKeyVariable}`); + +// const content = []; +// if (req.text) { +// content.push({ type: "text/plain", value: req.text }); +// } +// if (req.html) { +// content.push({ type: "text/html", value: req.html }); +// } + +// const response = await fetch("https://api.sendgrid.com/v3/mail/send", { +// method: "POST", +// headers: { +// "Authorization": `Bearer ${apiKey}`, +// "Content-Type": "application/json", +// }, +// body: JSON.stringify({ +// personalizations: [{ +// to: req.to.map(({ email, name }) => ({ email, name })), +// cc: req.cc?.map(({ email, name }) => ({ email, name })), +// bcc: req.bcc?.map(({ email, name }) => ({ email, name })), +// }], +// from: { email: req.from.email, name: req.from.name }, +// reply_to_list: req.replyTo?.map(({ email, name }) => ({ email, name })), +// subject: req.subject, +// content: [ +// { type: "text/plain", value: req.text }, +// { type: "text/html", value: req.html }, +// ], +// }), +// }); +// if (!response.ok) { +// throw new RuntimeError("SENDGRID_ERROR", { +// meta: { +// status: response.status, +// text: await response.text(), +// }, +// }); +// } +// } + diff --git a/modules/sms/utils/types.ts b/modules/sms/utils/types.ts new file mode 100644 index 00000000..51e62465 --- /dev/null +++ b/modules/sms/utils/types.ts @@ -0,0 +1,18 @@ +export enum MessageStatus { + QUEUED = "queued", + SENT = "sent", + FAILED = "failed", +} + +export interface Message { + id: string; + + to: string; + from: string; + body: string; + segments: number; + price?: [number, string]; + priceUSD?: number; + + status: MessageStatus; +} diff --git a/tests/basic/backend.json b/tests/basic/backend.json index 87db3449..97f7cf5a 100644 --- a/tests/basic/backend.json +++ b/tests/basic/backend.json @@ -51,6 +51,14 @@ "test": {} } } + }, + "sms": { + "registry": "local", + "config": { + "provider": { + "test": {} + } + } } } }