use anyhow::Result;
use super::{Capability, CapabilityEdit};
pub(crate) static VOICE: Capability = Capability {
id: "voice",
resource_type: "voice",
summary: "outbound phone calls (Amazon Connect, held by the platform)",
registration_key: "VoiceClient",
receives_events: true,
workers: true,
add,
remove,
};
const IMPORT_SOURCE: &str = "@forklaunch/core/http";
#[cfg(test)]
const IMPORT_NAMES: &[&str] = &["createVoiceClient", "VoiceClient"];
const IMPORT_LINE: &str = "import { createVoiceClient, VoiceClient } from \"@forklaunch/core/http\";";
const RUNTIME_BLOCK: &str = "const configInjector = createConfigInjector(SchemaValidator(), {
VoiceClient: {
lifetime: Lifetime.Scoped,
type: VoiceClient,
factory: ({ PLATFORM_GATEWAY_URL, INSTANCE_ID, INSTANCE_HMAC_KEY }) =>
createVoiceClient({
gatewayUrl: PLATFORM_GATEWAY_URL,
instanceId: INSTANCE_ID,
hmacKey: INSTANCE_HMAC_KEY
})
}
});";
const HANDLER_STUB: &str = "import type {
PlatformEvent,
VoiceCallEndedEvent,
VoiceCallStartedEvent,
VoiceRecordingReadyEvent
} from '@forklaunch/core/http';
/**
* Voice call events from the ForkLaunch platform (Amazon Connect), already
* verified as signed with this instance's key. Generated by
* `forklaunch infra add <service> voice`; fill in what the app does.
*
* Deliveries can repeat: dedupe on event.id. Throwing makes the platform
* retry. Place calls with `ci.resolve(tokens.VoiceClient).startOutboundCall(
* { to, flow, attributes })`; never put health information in attributes.
*/
export async function handle(event: PlatformEvent): Promise<void> {
switch (event.type) {
case 'voice.call.started': {
const { callId } = event.data as unknown as VoiceCallStartedEvent;
// The call is ringing. Record callId against what it is about.
void callId;
return;
}
case 'voice.call.ended': {
const { callId, durationSeconds, disconnectReason } =
event.data as unknown as VoiceCallEndedEvent;
// disconnectReason: customer, api, busy, no_answer, … Retry or follow up here.
void [callId, durationSeconds, disconnectReason];
return;
}
case 'voice.recording.ready': {
const { callId, recordingKey } =
event.data as unknown as VoiceRecordingReadyEvent;
// recordingKey is a key in this instance's object store.
void [callId, recordingKey];
return;
}
default:
return;
}
}
";
fn add(edit: &mut CapabilityEdit) -> Result<()> {
edit.inject_registration(IMPORT_LINE, IMPORT_SOURCE, None, Some(RUNTIME_BLOCK))?;
edit.ensure_platform_events("voice", HANDLER_STUB)?;
Ok(())
}
fn remove(edit: &mut CapabilityEdit) -> Result<()> {
edit.remove_registration(None, &[], &["VoiceClient"])
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
const REGISTRATIONS: &str = r#"import { number, optional, 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({
PORT: {
lifetime: Lifetime.Singleton,
type: number,
value: Number(getEnvVar("PORT"))
}
});
const runtimeDependencies = environmentConfig.chain({
OpenTelemetryCollector: {
lifetime: Lifetime.Singleton,
type: OpenTelemetryCollector,
value: new OpenTelemetryCollector("billing")
}
});
"#;
const SERVER: &str = "import { forklaunchExpress } from '@demo/core';\nimport { ci } from './bootstrapper';\n\nconst app = forklaunchExpress();\napp.use(billingRouter);\n";
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();
let edit = CapabilityEdit::new(
dir.path(),
"demo",
"billing",
&service,
vec!["voice".to_string()],
vec!["voice".to_string()],
);
(dir, edit)
}
#[test]
fn imports_merge_into_the_existing_source_and_come_back_out() {
use super::super::{add_named_imports, remove_named_imports};
let text = "import { OpenTelemetryCollector } from \"@forklaunch/core/http\";\nconst a = 1;\n";
let added = add_named_imports(text, IMPORT_SOURCE, IMPORT_NAMES);
assert!(added.contains(
"import { OpenTelemetryCollector, VoiceClient, createVoiceClient } from \"@forklaunch/core/http\";"
));
assert_eq!(add_named_imports(&added, IMPORT_SOURCE, IMPORT_NAMES), added);
assert_eq!(remove_named_imports(&added, IMPORT_SOURCE, IMPORT_NAMES), text);
let alone = add_named_imports("const a = 1;\n", IMPORT_SOURCE, IMPORT_NAMES);
assert_eq!(remove_named_imports(&alone, IMPORT_SOURCE, IMPORT_NAMES), "const a = 1;\n");
}
#[test]
fn add_registers_the_client_keyless_and_writes_the_event_handler() {
let (_dir, mut edit) = service();
add(&mut edit).unwrap();
let registrations = edit.read(&edit.registrations_path()).unwrap().unwrap();
assert!(registrations.contains("VoiceClient:"));
assert!(registrations.contains("createVoiceClient("));
assert!(registrations.contains("import { OpenTelemetryCollector, VoiceClient, createVoiceClient } from \"@forklaunch/core/http\";"));
assert!(registrations.contains("hmacKey: INSTANCE_HMAC_KEY"));
for forbidden in ["AWS_ACCESS_KEY_ID", "CONNECT_INSTANCE_ID", "client-connect", "TWILIO"] {
assert!(!registrations.contains(forbidden), "{forbidden} in registrations");
}
let handler = edit
.read(&edit.service_path.join("api/platformEvents/voice.ts"))
.unwrap()
.unwrap();
assert!(handler.contains("export async function handle(event: PlatformEvent)"));
assert!(handler.contains("voice.call.ended"));
let index = edit
.read(&edit.service_path.join("api/platformEvents/index.ts"))
.unwrap()
.unwrap();
assert!(index.contains("voice: voiceEvents"));
let server = edit.read(&edit.service_path.join("server.ts")).unwrap().unwrap();
assert!(server.contains("platformEventsRouter"));
}
#[test]
fn gateway_wiring_merges_into_the_services_import_instead_of_replacing_it() {
let (_dir, mut edit) = service();
add(&mut edit).unwrap();
edit.ensure_gateway_wiring("voice").unwrap();
let registrations = edit.read(&edit.registrations_path()).unwrap().unwrap();
assert!(registrations.contains(
"import { createConfigInjector, getEnvVar, Lifetime } from \"@forklaunch/core/services\";"
));
assert!(registrations.contains("INSTANCE_HMAC_KEY:"));
}
#[test]
fn remove_takes_the_registration_and_import_back_out() {
let (_dir, mut edit) = service();
add(&mut edit).unwrap();
edit.service_capabilities.clear();
edit.app_capabilities.clear();
remove(&mut edit).unwrap();
edit.remove_platform_events("voice").unwrap();
let server = edit.read(&edit.service_path.join("server.ts")).unwrap().unwrap();
assert_eq!(server, SERVER, "server.ts back to what it was");
let registrations = edit.read(&edit.registrations_path()).unwrap().unwrap();
assert!(!registrations.contains("VoiceClient"));
assert!(!registrations.contains("createVoiceClient"));
assert!(registrations.contains("OpenTelemetryCollector"));
assert_eq!(registrations, REGISTRATIONS, "registrations.ts back to what it was");
}
#[test]
fn voice_is_registered_as_a_capability() {
let found = super::super::find("voice").expect("voice registered");
assert_eq!(found.resource_type, "voice");
assert!(found.receives_events);
assert_eq!(found.registration_key, "VoiceClient");
}
}