use anyhow::Result;
use super::{Capability, CapabilityEdit};
pub(crate) static SMS: Capability = Capability {
id: "sms",
resource_type: "sms",
summary: "text messages through the platform (AWS End User Messaging SMS), with delivery and reply events",
registration_key: "SmsClient",
receives_events: true,
workers: true,
add,
remove,
};
const IMPORT_SOURCE: &str = "@forklaunch/core/http";
const IMPORT_LINE: &str = "import { createSmsClient, SmsClient } from \"@forklaunch/core/http\";";
const RUNTIME_BLOCK: &str = "const configInjector = createConfigInjector(SchemaValidator(), {
SmsClient: {
lifetime: Lifetime.Singleton,
type: SmsClient,
factory: ({ PLATFORM_GATEWAY_URL, INSTANCE_ID, INSTANCE_HMAC_KEY }) =>
createSmsClient({
gatewayUrl: PLATFORM_GATEWAY_URL,
instanceId: INSTANCE_ID,
hmacKey: INSTANCE_HMAC_KEY,
deferRefusal: true
})
}
});";
pub(crate) const HANDLER_STUB: &str = "import type {
PlatformEvent,
SmsDeliveredData,
SmsFailedData,
SmsOptedOutData,
SmsReceivedData
} from '@forklaunch/core/http';
/**
* SMS events from the ForkLaunch platform (AWS End User Messaging SMS),
* already verified as signed with this instance's key.
*
* Deliveries can repeat: dedupe on `event.id` before acting. Never log
* `data.body` of a received text — it is whatever the person typed.
*/
export async function handle(event: PlatformEvent): Promise<void> {
switch (event.type) {
case 'sms.delivered': {
const data = event.data as unknown as SmsDeliveredData;
// The carrier confirmed delivery of data.messageId.
void data;
return;
}
case 'sms.failed': {
const data = event.data as unknown as SmsFailedData;
// data.messageId was not delivered (data.reason); fall back to email?
void data;
return;
}
case 'sms.received': {
const data = event.data as unknown as SmsReceivedData;
// A reply from data.from. STOP/HELP are handled by the platform and the
// carrier; data.keyword says when the text was one.
void data;
return;
}
case 'sms.opted_out': {
const data = event.data as unknown as SmsOptedOutData;
// data.phone texted STOP: the platform now refuses sends to it (422).
// Record it so the product stops offering texts to that person.
void data;
return;
}
default:
return;
}
}
";
fn add(edit: &mut CapabilityEdit) -> Result<()> {
edit.inject_registration(IMPORT_LINE, IMPORT_SOURCE, None, Some(RUNTIME_BLOCK))?;
edit.ensure_platform_events("sms", HANDLER_STUB)?;
Ok(())
}
fn remove(edit: &mut CapabilityEdit) -> Result<()> {
edit.remove_registration(None, &[], &["SmsClient"])
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
const REGISTRATIONS: &str = "import { number, optional, schemaValidator, SchemaValidator, string } from '@demo/core';
import { OpenTelemetryCollector } from '@forklaunch/core/http';
import { createConfigInjector, getEnvVar, Lifetime } from '@forklaunch/core/services';
const configInjector = createConfigInjector(schemaValidator, {
SERVICE_METADATA: {
lifetime: Lifetime.Singleton,
type: { name: string, version: string },
value: { name: 'billing', version: '0.1.0' }
}
});
const environmentConfig = configInjector.chain({
HOST: {
lifetime: Lifetime.Singleton,
type: string,
value: getEnvVar('HOST')
}
});
const runtimeDependencies = environmentConfig.chain({
OtelCollector: {
lifetime: Lifetime.Singleton,
type: OpenTelemetryCollector,
factory: () => new OpenTelemetryCollector('billing')
}
});
export const createDependencyContainer = () => runtimeDependencies;
";
const SERVER: &str = "import { forklaunchExpress, schemaValidator } from '@demo/core';
import { ci, tokens } from './bootstrapper';
const app = forklaunchExpress(schemaValidator, ci.resolve(tokens.OtelCollector));
app.use(billingRouter);
";
#[test]
fn remove_restores_server_ts_mounted_at_the_top_level() {
let (_dir, mut edit) = service();
(SMS.add)(&mut edit).unwrap();
assert!(staged(&edit, "server.ts").contains("\napp.use(platformEventsRouter);\n"));
(SMS.remove)(&mut edit).unwrap();
edit.service_capabilities.clear();
edit.remove_platform_events("sms").unwrap();
assert_eq!(staged(&edit, "server.ts"), SERVER);
}
fn service() -> (tempfile::TempDir, CapabilityEdit) {
let dir = tempfile::tempdir().unwrap();
let service = dir.path().join("src/modules/billing");
fs::create_dir_all(&service).unwrap();
fs::write(service.join("registrations.ts"), REGISTRATIONS).unwrap();
fs::write(service.join("server.ts"), SERVER).unwrap();
fs::write(
dir.path().join("docker-compose.yaml"),
"services:\n billing:\n image: node:22\n environment:\n PORT: '8001'\n",
)
.unwrap();
let edit = CapabilityEdit::new(
dir.path(),
"demo",
"billing",
&service,
vec!["sms".to_string()],
vec!["sms".to_string()],
);
(dir, edit)
}
fn staged(edit: &CapabilityEdit, rel: &str) -> String {
edit.read(&edit.service_path.join(rel)).unwrap().unwrap_or_default()
}
#[test]
fn add_registers_a_keyless_client_and_an_event_handler() {
let (_dir, mut edit) = service();
(SMS.add)(&mut edit).unwrap();
edit.ensure_gateway_wiring("sms").unwrap();
let registrations = staged(&edit, "registrations.ts");
assert!(registrations.contains("SmsClient:"), "{registrations}");
assert!(registrations.contains("createSmsClient("));
assert!(registrations.contains("deferRefusal: true"));
assert!(registrations.contains("INSTANCE_HMAC_KEY:"));
assert!(registrations.contains(
"import { OpenTelemetryCollector, SmsClient, createSmsClient } from '@forklaunch/core/http';"
), "{registrations}");
assert!(registrations.contains(
"import { createConfigInjector, getEnvVar, Lifetime } from '@forklaunch/core/services';"
));
for key in ["AWS_ACCESS_KEY_ID", "TWILIO", "PINPOINT"] {
assert!(!registrations.contains(key), "{key} in registrations");
}
let handler = staged(&edit, "api/platformEvents/sms.ts");
assert!(handler.contains("export async function handle"));
assert!(handler.contains("'sms.opted_out'"));
assert!(staged(&edit, "api/platformEvents/index.ts").contains("sms: smsEvents"));
assert!(staged(&edit, "server.ts").contains("app.use(platformEventsRouter)"));
assert!(staged(&edit, ".env.local").contains("INSTANCE_ID=local-instance"));
}
#[test]
fn remove_drops_the_client_and_keeps_other_imports() {
let (_dir, mut edit) = service();
(SMS.add)(&mut edit).unwrap();
(SMS.remove)(&mut edit).unwrap();
let registrations = staged(&edit, "registrations.ts");
assert!(!registrations.contains("SmsClient"), "{registrations}");
assert!(!registrations.contains("createSmsClient"));
assert!(registrations.contains(
"import { OpenTelemetryCollector } from '@forklaunch/core/http';"
));
assert_eq!(registrations, REGISTRATIONS);
}
}