diff --git a/package.json b/package.json index be78397bad..3b715a4640 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,9 @@ "@commander-js/extra-typings": "12.0.0", "@next/eslint-plugin-next": "14.1.0", "@pulumi/command": "4.5.0", + "@pulumi/random": "^4.15.1", "@react-spring/rafz": "9.7.3", + "@tanstack/react-query": "4.36.1", "@types/bcryptjs": "2.4.6", "@types/memoizee": "0.4.11", "@types/pako": "2.0.3", @@ -127,7 +129,6 @@ "npm": "10.4.0", "pako": "2.1.0", "pnpm": "^8.0.0", - "@tanstack/react-query": "4.36.1", "seedrandom": "3.0.5", "selenium-webdriver": "4.17.0", "serve-handler": "6.1.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a38aa5e73f..03cecc8167 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ dependencies: '@pulumi/command': specifier: 4.5.0 version: 4.5.0 + '@pulumi/random': + specifier: ^4.15.1 + version: 4.15.1 '@react-spring/rafz': specifier: 9.7.3 version: 9.7.3 @@ -5034,6 +5037,14 @@ packages: /@pulumi/query@0.3.0: resolution: {integrity: sha512-xfo+yLRM2zVjVEA4p23IjQWzyWl1ZhWOGobsBqRpIarzLvwNH/RAGaoehdxlhx4X92302DrpdIFgTICMN4P38w==} + /@pulumi/random@4.15.1: + resolution: {integrity: sha512-qonG+7iE9haQ045hrHQEpF7SsAJLrlkyhhPs/UUOcdjcgoRh68suWxBNvTdtDZXY5Nfhwhh7mszaRbHscSbixg==} + dependencies: + '@pulumi/pulumi': 3.105.0 + transitivePeerDependencies: + - supports-color + dev: false + /@puppeteer/browsers@2.0.0: resolution: {integrity: sha512-3PS82/5+tnpEaUWonjAFFvlf35QHF15xqyGd34GBa5oP5EPVfFXRsbSxIGYf1M+vZlqBZ3oxT1kRg9OYhtt8ng==} engines: {node: '>=18'} diff --git a/ts/pulumi/BUILD.bazel b/ts/pulumi/BUILD.bazel index 22494e3038..6cc4248ffd 100644 --- a/ts/pulumi/BUILD.bazel +++ b/ts/pulumi/BUILD.bazel @@ -19,6 +19,7 @@ ts_project( deps = [ "//:node_modules/@pulumi/aws", "//:node_modules/@pulumi/pulumi", + "//:node_modules/@pulumi/random", "//:node_modules/@types/cross-spawn", "//:node_modules/@types/jest", "//:node_modules/cross-spawn", diff --git a/ts/pulumi/lib/contact_flow.ts b/ts/pulumi/lib/contact_flow.ts new file mode 100644 index 0000000000..1c21d2f999 --- /dev/null +++ b/ts/pulumi/lib/contact_flow.ts @@ -0,0 +1,120 @@ +import { + ContactFlow as BaseContactFlow, + ContactFlowArgs, +} from '@pulumi/aws/connect/index.js'; +import { + all, + ComponentResource, + ComponentResourceOptions, + Input, +} from '@pulumi/pulumi'; + +import { Err, Ok, Result } from '#root/ts/result.js'; + +interface ActionBase { + Identifier: string; + Type: string; + Parameters: unknown; + Transitions?: { + NextAction?: string; + Errors?: string[]; + Conditions?: string[]; + }; +} + +export interface EndFlowExecutionAction extends ActionBase { + Type: 'DisconnectParticipant'; + Parameters: Record; + Transitions?: Record; +} + +export interface MessageParticipantAction extends ActionBase { + Type: 'MessageParticipant'; + Parameters: { + /** + * A prompt ID or prompt ARN to play to the participant along with gathering input. May not be specified if Text or SSML is also specified. + * Must be specified either statically or as a single valid JSONPath identifier + */ + PromptId?: string; + /** + * An optional string that defines text to send to the participant along with gathering input. + * May not be specified if PromptId or SSML is also specified. May be specified statically or dynamically. + */ + Text?: string; + /** + * An optional string that defines SSML to send to the participant along with gathering input. May not be specified if Text or + * PromptId is also specified May be specified statically or dynamically. + */ + SSML?: string; + media?: { + uri: string; + SourceType: 'S3'; + MediaType: 'Audio'; + }; + }; + Transitions: { + NextAction: string; + }; +} + +export type ContactFlowAction = + | MessageParticipantAction + | EndFlowExecutionAction; + +export interface ContactFlowLanguage { + Version: '2019-10-30'; + StartAction: string; + Actions: ContactFlowAction[]; +} + +export interface Args extends Omit { + content: Input; +} + +/** + * Creates a ContactFlowModule, but it's typechecked. + */ +export class ContactFlow extends ComponentResource { + readonly value: BaseContactFlow; + constructor( + name: string, + { content, ...args }: Args, + opts?: ComponentResourceOptions + ) { + super('ts:pulumi:lib:ContactFlowModule', name, args, opts); + + void ContactFlow.validate(content).then(v => { + if (v instanceof Error) throw v; + }); + + this.value = new BaseContactFlow( + `${name}_contact_flow_module`, + { + ...args, + content: all([content]).apply(([v]) => JSON.stringify(v)), + }, + { parent: this } + ); + } + + private static async validateEntryPointSet( + flow: ContactFlowLanguage + ): Promise> { + if (!flow.Actions.some(v => v.Identifier == flow.StartAction)) + return { + [Err]: new Error(`Missing entry point ${flow.StartAction}`), + }; + + return { [Ok]: undefined }; + } + + private static async validate( + v: Input + ): Promise> { + const flow = await new Promise(ok => + all([v]).apply(([flow]) => ok(flow!)) + ); + + return ContactFlow.validateEntryPointSet(flow); + } +} diff --git a/ts/pulumi/zemn.me/index.ts b/ts/pulumi/zemn.me/index.ts index 0720a3ec0d..82309efbd7 100644 --- a/ts/pulumi/zemn.me/index.ts +++ b/ts/pulumi/zemn.me/index.ts @@ -3,6 +3,7 @@ import * as Pulumi from '@pulumi/pulumi'; import { mergeTags, tagTrue } from '#root/ts/pulumi/lib/tags.js'; import Website from '#root/ts/pulumi/lib/website.js'; +import { Voice } from '#root/ts/pulumi/zemn.me/voice/voice.js'; // @@ -33,6 +34,8 @@ export class Component extends Pulumi.ComponentResource { { parent: this } ); + new Voice(`${name}_voice`, { tags }, { parent: this }); + this.site = new Website( `${name}_zemn_me`, { diff --git a/ts/pulumi/zemn.me/voice/voice.ts b/ts/pulumi/zemn.me/voice/voice.ts new file mode 100644 index 0000000000..54aff1cfd8 --- /dev/null +++ b/ts/pulumi/zemn.me/voice/voice.ts @@ -0,0 +1,118 @@ +import * as aws from '@pulumi/aws'; +import * as Pulumi from '@pulumi/pulumi'; +import { RandomPet } from '@pulumi/random'; + +import { + ContactFlow, + ContactFlowAction, + ContactFlowLanguage, +} from '#root/ts/pulumi/lib/contact_flow.js'; +import { mergeTags, tagTrue } from '#root/ts/pulumi/lib/tags.js'; + +export interface Args { + tags?: Pulumi.Input>>; +} + +export class Voice extends Pulumi.ComponentResource { + phoneNumber: Pulumi.Output; + constructor( + name: string, + args: Args, + opts?: Pulumi.ComponentResourceOptions + ) { + super('ts:pulumi:voice', name, args, opts); + const tag = name; + const tags = mergeTags(args.tags, tagTrue(tag)); + + /* + new CostAllocationTag( + `${name}_cost_tag`, + { + status: 'Active', + tagKey: tag, + }, + { parent: this } + ); + */ + + const connectInstance = new aws.connect.Instance( + `${name}_connect_instance`, + { + inboundCallsEnabled: true, + outboundCallsEnabled: true, + identityManagementType: 'CONNECT_MANAGED', + instanceAlias: `${name}_connect_instance`.replaceAll( + /[^a-z]/g, + '' + ), + }, + { parent: this } + ); + + const disconnectAction = new RandomPet( + `${name}_disconnect_flow_id`, + {}, + { parent: this } + ).id.apply( + id => + ({ + Identifier: id, + Type: 'DisconnectParticipant', + Parameters: {}, + }) satisfies ContactFlowAction + ); + + const action = Pulumi.all([ + new RandomPet(`${name}_flow_id`, {}, { parent: this }).id, + disconnectAction, + ]).apply( + ([Identifier, disconnectAction]) => + ({ + Identifier, + Type: 'MessageParticipant', + Parameters: { + Text: 'Hello, world!', + }, + Transitions: { + NextAction: disconnectAction.Identifier, + }, + }) satisfies ContactFlowAction + ); + + const flow: Pulumi.Input = Pulumi.all([ + action, + disconnectAction, + ]).apply( + ([action, disconnectAction]) => + ({ + Version: '2019-10-30', + StartAction: action!.Identifier, + Actions: [action!, disconnectAction], + }) satisfies ContactFlowLanguage + ); + + new ContactFlow( + `${name}_contact_flow`, + { + instanceId: connectInstance.id, + name: 'Hello world flow', + type: 'CONTACT_FLOW', + content: flow, + }, + { parent: this } + ); + + const phone = new aws.connect.PhoneNumber( + `${name}_phone_number`, + { + countryCode: 'US', + type: 'DID', + targetArn: connectInstance.arn, + tags, + }, + { parent: this } + ); + + this.phoneNumber = phone.phoneNumber; + } +}