-
Notifications
You must be signed in to change notification settings - Fork 1
feat: Create the sms module
#131
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is too verbose. we need to provide a generic superset over other peoples' apis. it's better to expose less than more so we can maintain consistent behavior across providers. i'd recommend completely deleting this script unless you see a good reason otherwise. |
||
| ctx: ScriptContext, | ||
| req: Request, | ||
| ): Promise<Response> { | ||
| 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); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. afaik we don't need to provide this, it's done by default: https://www.twilio.com/docs/glossary/what-is-gsm-7-character-encoding#how-twilio-encodes-your-messages keep the api as minimal as possible |
||
| } | ||
|
|
||
| export type Response = Message; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. does not need to return anything. see comment in get_message |
||
|
|
||
| export async function run( | ||
| ctx: ScriptContext, | ||
| req: Request, | ||
| ): Promise<Response> { | ||
| 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"); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. use unreachableerror |
||
| } | ||
|
|
||
| return await provider.send(req.to, req.content, req.useGSM7 ?? false); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import { test, TestContext } from "../module.gen.ts"; | ||
| import { assertExists, assertEquals, assertNotEquals, assertGreaterOrEqual } from "https://deno.land/[email protected]/assert/mod.ts"; | ||
| import { faker } from "https://deno.land/x/[email protected]/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); | ||
| } | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { RuntimeError } from "../module.gen.ts"; | ||
|
|
||
| export async function convertCurrencyTo(from: string, to: string, amount: number): Promise<number> { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. remove |
||
| 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 } }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| export enum MessageStatus { | ||
| QUEUED = "queued", | ||
| SENT = "sent", | ||
| FAILED = "failed", | ||
| } | ||
|
|
||
| export interface Message { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. remoev |
||
| 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<Message>; | ||
| public abstract fetch(id: string): Promise<Message>; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Message> { | ||
| 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<Message>({ | ||
| id, | ||
| from, | ||
| to, | ||
| status: MessageStatus.SENT, | ||
| segments, | ||
| body, | ||
| price: [0, "usd"], | ||
| priceUSD: 0, | ||
| }); | ||
| } | ||
|
|
||
| public async fetch(id: string): Promise<Message> { | ||
| const metadata = JSON.parse(atob(id)); | ||
|
|
||
| return await Promise.resolve<Message>({ | ||
| id, | ||
| from: metadata.from, | ||
| to: metadata.to, | ||
| segments: metadata.segments, | ||
| status: MessageStatus.SENT, | ||
| body: metadata.body, | ||
| price: [0, "usd"], | ||
| priceUSD: 0, | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
remove me (see get_message)