forklaunch 1.31.0

Launch faster with forklaunch
//! `voice`: outbound calls through the platform's Amazon Connect instance.
//!
//! The service gets a `VoiceClient` built by `createVoiceClient()` from the
//! managed env contract (no AWS credential, no Connect ids), and a handler
//! for the call events the platform relays (`voice.call.started`,
//! `voice.call.ended`, `voice.recording.ready`).

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,
    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<()> {
    // registrations.ts already imports OpenTelemetryCollector from
    // @forklaunch/core/http; inject_registration merges into that import.
    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<()> {
    // createVoiceClient and VoiceClient go with the registration.
    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);
        // Only ours: the whole import goes.
        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();
        // As `infra remove` sees it: voice is no longer among the capabilities.
        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");
    }
}