1import * as lexm from "@distilled.cloud/aws/lex-models-v2";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9import { retryWhileConflict, toLexName } from "./internal.ts";
10
11export interface IntentProps {
12 /**
13 * ID of the bot the intent belongs to. Changing it replaces the intent.
14 */
15 botId: string;
16 /**
17 * Locale the intent lives under, e.g. `en_US`. Changing it replaces the
18 * intent. Pass the `localeId` attribute of a `BotLocale` so the intent
19 * depends on the locale.
20 */
21 localeId: string;
22 /**
23 * Name of the intent. Mutable — renames update the intent in place
24 * (identity is the generated intent ID).
25 * @default ${app}-${id}-${stage}-${suffix}
26 */
27 intentName?: string;
28 /**
29 * Description of the intent.
30 */
31 description?: string;
32 /**
33 * Sample utterances that invoke the intent, e.g. `"I want to order a
34 * pizza"`. Utterances may reference slots with `{slotName}`.
35 */
36 sampleUtterances?: string[];
37 /**
38 * Signature of a built-in intent to base this intent on, e.g.
39 * `AMAZON.HelpIntent`.
40 */
41 parentIntentSignature?: string;
42 /**
43 * Invoke the alias's Lambda code hook on every dialog turn of this intent
44 * (slot elicitation, validation). Attach the function itself with
45 * `LexV2.onCodeHook`.
46 * @default false
47 */
48 dialogCodeHook?: boolean;
49 /**
50 * Invoke the alias's Lambda code hook to fulfill the intent once all
51 * required slots are filled. Attach the function itself with
52 * `LexV2.onCodeHook`.
53 * @default false
54 */
55 fulfillmentCodeHook?: boolean;
56}
57
58export interface Intent extends Resource<
59 "AWS.LexV2.Intent",
60 IntentProps,
61 {
62 /** Unique identifier assigned to the intent. */
63 intentId: string;
64 /** Name of the intent. */
65 intentName: string;
66 /** ID of the bot the intent belongs to. */
67 botId: string;
68 /** Bot version the intent lives on — always `DRAFT`. */
69 botVersion: string;
70 /** Locale the intent lives under. */
71 localeId: string;
72 },
73 never,
74 Providers
75> {}
76
77/**
78 * An intent on the DRAFT locale of an Amazon Lex V2 bot — an action the user
79 * wants to perform, recognized from sample utterances.
80 *
81 * ### Creating Intents
82 * **Example:** Intent with Sample Utterances
83 * ```typescript
84 * import * as AWS from "alchemy/AWS";
85 *
86 * const greet = yield* AWS.LexV2.Intent("Greet", {
87 * botId: locale.botId,
88 * localeId: locale.localeId,
89 * sampleUtterances: ["hello", "hi", "good morning"],
90 * });
91 * ```
92 *
93 * **Example:** Built-in Parent Intent
94 * ```typescript
95 * const help = yield* AWS.LexV2.Intent("Help", {
96 * botId: locale.botId,
97 * localeId: locale.localeId,
98 * parentIntentSignature: "AMAZON.HelpIntent",
99 * });
100 * ```
101 *
102 * @resource
103 */
104export const Intent = Resource<Intent>("AWS.LexV2.Intent");
105
106const createIntentName = (
107 id: string,
108 props: { intentName?: string | undefined },
109) =>
110 Effect.gen(function* () {
111 if (props.intentName) return props.intentName;
112 return toLexName(yield* createPhysicalName({ id, maxLength: 100 }));
113 });
114
115const describeIntent = Effect.fn(function* (
116 botId: string,
117 localeId: string,
118 intentId: string,
119) {
120 return yield* lexm
121 .describeIntent({ botId, botVersion: "DRAFT", localeId, intentId })
122 .pipe(
123 Effect.catchTag("ResourceNotFoundException", () =>
124 Effect.succeed(undefined),
125 ),
126 );
127});
128
129/** Find an intent of the locale by exact name (used when state was lost). */
130const findIntentByName = Effect.fn(function* (
131 botId: string,
132 localeId: string,
133 intentName: string,
134) {
135 const pages = yield* lexm.listIntents
136 .pages({
137 botId,
138 botVersion: "DRAFT",
139 localeId,
140 filters: [{ name: "IntentName", values: [intentName], operator: "EQ" }],
141 })
142 .pipe(
143 Stream.runCollect,
144 Effect.catchTag("ResourceNotFoundException", () =>
145 Effect.succeed([] as lexm.ListIntentsResponse[]),
146 ),
147 );
148 const summary = Array.from(pages)
149 .flatMap((page) => page.intentSummaries ?? [])
150 .find((intent) => intent.intentName === intentName);
151 if (summary?.intentId === undefined) return undefined;
152 return yield* describeIntent(botId, localeId, summary.intentId);
153});
154
155const utterancesOf = (
156 intent: lexm.DescribeIntentResponse | lexm.CreateIntentResponse,
157): string[] => (intent.sampleUtterances ?? []).map((u) => u.utterance);
158
159const toUtterances = (
160 utterances: string[] | undefined,
161): lexm.SampleUtterance[] | undefined =>
162 utterances?.map((utterance) => ({ utterance }));
163
164const attributesOf = (
165 intent: lexm.DescribeIntentResponse,
166): Intent["Attributes"] => ({
167 intentId: intent.intentId!,
168 intentName: intent.intentName!,
169 botId: intent.botId!,
170 botVersion: "DRAFT",
171 localeId: intent.localeId!,
172});
173
174export const IntentProvider = () =>
175 Provider.effect(
176 Intent,
177 Effect.gen(function* () {
178 return {
179 stables: ["intentId", "botId", "botVersion", "localeId"],
180
181 // Sub-resource keyed entirely by its bot locale — nuke reaches it
182 // through the parent bot's deletion.
183 list: () => Effect.succeed([]),
184
185 read: Effect.fn(function* ({ id, olds, output }) {
186 const botId = output?.botId ?? olds?.botId;
187 const localeId = output?.localeId ?? olds?.localeId;
188 if (botId === undefined || localeId === undefined) return undefined;
189 const observed =
190 output?.intentId !== undefined
191 ? yield* describeIntent(botId, localeId, output.intentId)
192 : yield* findIntentByName(
193 botId,
194 localeId,
195 yield* createIntentName(id, olds ?? {}),
196 );
197 return observed === undefined ? undefined : attributesOf(observed);
198 }),
199
200 diff: Effect.fn(function* ({ news, olds }) {
201 if (!isResolved(news)) return undefined;
202 if (olds?.botId !== news.botId || olds?.localeId !== news.localeId) {
203 return { action: "replace" } as const;
204 }
205 }),
206
207 reconcile: Effect.fn(function* ({ id, news, output, session }) {
208 const intentName = yield* createIntentName(id, news);
209 const desiredUtterances = news.sampleUtterances ?? [];
210 const desiredDialogCodeHook =
211 news.dialogCodeHook !== undefined
212 ? { enabled: news.dialogCodeHook }
213 : undefined;
214 const desiredFulfillmentCodeHook =
215 news.fulfillmentCodeHook !== undefined
216 ? { enabled: news.fulfillmentCodeHook }
217 : undefined;
218
219 // 1. OBSERVE — output.intentId is only a cache; fall back to name.
220 let observed =
221 output?.intentId !== undefined
222 ? yield* describeIntent(
223 news.botId,
224 news.localeId,
225 output.intentId,
226 )
227 : undefined;
228 if (observed === undefined) {
229 observed = yield* findIntentByName(
230 news.botId,
231 news.localeId,
232 intentName,
233 );
234 }
235
236 // 2. ENSURE — create when missing.
237 if (observed === undefined) {
238 const created = yield* retryWhileConflict(
239 lexm.createIntent({
240 botId: news.botId,
241 botVersion: "DRAFT",
242 localeId: news.localeId,
243 intentName,
244 description: news.description,
245 sampleUtterances: toUtterances(news.sampleUtterances),
246 parentIntentSignature: news.parentIntentSignature,
247 dialogCodeHook: desiredDialogCodeHook,
248 fulfillmentCodeHook: desiredFulfillmentCodeHook,
249 }),
250 );
251 observed = yield* describeIntent(
252 news.botId,
253 news.localeId,
254 created.intentId!,
255 );
256 if (observed === undefined) {
257 return yield* Effect.fail(
258 new Error(`failed to read created Lex intent ${intentName}`),
259 );
260 }
261 } else if (
262 // 3. SYNC — UpdateIntent replaces the declared aspects; skip the
263 // call when nothing drifted.
264 observed.intentName !== intentName ||
265 (observed.description ?? undefined) !==
266 (news.description ?? undefined) ||
267 JSON.stringify([...utterancesOf(observed)].sort()) !==
268 JSON.stringify([...desiredUtterances].sort()) ||
269 (observed.dialogCodeHook?.enabled ?? false) !==
270 (news.dialogCodeHook ?? false) ||
271 (observed.fulfillmentCodeHook?.enabled ?? false) !==
272 (news.fulfillmentCodeHook ?? false)
273 ) {
274 yield* retryWhileConflict(
275 lexm.updateIntent({
276 botId: news.botId,
277 botVersion: "DRAFT",
278 localeId: news.localeId,
279 intentId: observed.intentId!,
280 intentName,
281 description: news.description,
282 sampleUtterances: toUtterances(news.sampleUtterances),
283 parentIntentSignature: news.parentIntentSignature,
284 dialogCodeHook: desiredDialogCodeHook,
285 fulfillmentCodeHook: desiredFulfillmentCodeHook,
286 }),
287 );
288 observed = yield* describeIntent(
289 news.botId,
290 news.localeId,
291 observed.intentId!,
292 );
293 if (observed === undefined) {
294 return yield* Effect.fail(
295 new Error(`failed to read updated Lex intent ${intentName}`),
296 );
297 }
298 }
299
300 yield* session.note(observed.intentId!);
301 return attributesOf(observed);
302 }),
303
304 delete: Effect.fn(function* ({ output }) {
305 // Lex reports a missing intent (or already-deleted parent) as
306 // PreconditionFailed.
307 yield* retryWhileConflict(
308 lexm.deleteIntent({
309 botId: output.botId,
310 botVersion: "DRAFT",
311 localeId: output.localeId,
312 intentId: output.intentId,
313 }),
314 ).pipe(
315 Effect.catchTag("PreconditionFailedException", () => Effect.void),
316 );
317 }),
318 };
319 }),
320 );