1import * as lexm from "@distilled.cloud/aws/lex-models-v2";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Predicate from "effect/Predicate";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
11import { toWireSeconds } from "../../Util/Duration.ts";
12import { AWSEnvironment } from "../Environment.ts";
13import type { Providers } from "../Providers.ts";
14import {
15 readLexTags,
16 toLexName,
17 retryWhileConflict,
18 syncLexTags,
19 waitForBotSettled,
20} from "./internal.ts";
21
22export interface BotProps {
23 /**
24 * Name of the bot. Mutable — renames update the bot in place (identity is
25 * the generated bot ID).
26 * @default ${app}-${id}-${stage}-${suffix}
27 */
28 botName?: string;
29 /**
30 * ARN of an IAM role that Amazon Lex assumes to call AWS services on the
31 * bot's behalf (e.g. Polly for speech). The role must trust
32 * `lexv2.amazonaws.com`.
33 */
34 roleArn: string;
35 /**
36 * COPPA data-privacy declaration. Set `childDirected: true` when the bot
37 * is directed at children under 13.
38 * @default { childDirected: false }
39 */
40 dataPrivacy?: {
41 /** Whether the bot is directed at children under 13 (COPPA). */
42 childDirected: boolean;
43 };
44 /**
45 * How long Amazon Lex retains a conversation session after the last user
46 * input (60 seconds to 24 hours). Accepts any `Duration.Input` (e.g.
47 * `"10 minutes"`, `Duration.minutes(10)`; a bare number is milliseconds);
48 * the wire unit is whole seconds.
49 * @default 300 seconds
50 */
51 idleSessionTTL?: Duration.Input;
52 /**
53 * Description of the bot.
54 */
55 description?: string;
56 /**
57 * Tags to associate with the bot.
58 */
59 tags?: Record<string, string>;
60}
61
62export interface Bot extends Resource<
63 "AWS.LexV2.Bot",
64 BotProps,
65 {
66 /** Unique identifier assigned to the bot. */
67 botId: string;
68 /** Name of the bot. */
69 botName: string;
70 /** ARN of the bot. */
71 botArn: string;
72 /** Current status of the bot (e.g. `Available`). */
73 botStatus: string;
74 /** ARN of the IAM role the bot assumes. */
75 roleArn: string;
76 /** Tags currently associated with the bot. */
77 tags: Record<string, string>;
78 },
79 never,
80 Providers
81> {}
82
83/**
84 * An Amazon Lex V2 conversational bot. The bot is the container for locales,
85 * intents, and slot types; conversations run against an alias of a built
86 * version.
87 *
88 * ### Creating a Bot
89 * **Example:** Basic Bot
90 * ```typescript
91 * import * as AWS from "alchemy/AWS";
92 *
93 * const role = yield* AWS.IAM.Role("BotRole", {
94 * assumeRolePolicyDocument: {
95 * Version: "2012-10-17",
96 * Statement: [
97 * {
98 * Effect: "Allow",
99 * Principal: { Service: "lexv2.amazonaws.com" },
100 * Action: ["sts:AssumeRole"],
101 * },
102 * ],
103 * },
104 * });
105 *
106 * const bot = yield* AWS.LexV2.Bot("OrderBot", {
107 * roleArn: role.roleArn,
108 * });
109 * ```
110 *
111 * **Example:** Bot with Session and Privacy Settings
112 * ```typescript
113 * const bot = yield* AWS.LexV2.Bot("KidsBot", {
114 * roleArn: role.roleArn,
115 * dataPrivacy: { childDirected: true },
116 * idleSessionTTL: "10 minutes",
117 * description: "A bot for children",
118 * });
119 * ```
120 *
121 * ### Building the Conversation Graph
122 * **Example:** Locale, Intent, and Alias
123 * ```typescript
124 * const locale = yield* AWS.LexV2.BotLocale("En", {
125 * botId: bot.botId,
126 * localeId: "en_US",
127 * });
128 * const intent = yield* AWS.LexV2.Intent("Greet", {
129 * botId: locale.botId,
130 * localeId: locale.localeId,
131 * sampleUtterances: ["hello", "hi"],
132 * });
133 * const version = yield* AWS.LexV2.BotVersion("V1", {
134 * botId: intent.botId,
135 * localeIds: [intent.localeId],
136 * });
137 * const alias = yield* AWS.LexV2.BotAlias("Live", {
138 * botId: version.botId,
139 * botVersion: version.botVersion,
140 * });
141 * ```
142 *
143 * @resource
144 */
145export const Bot = Resource<Bot>("AWS.LexV2.Bot");
146
147const createBotName = (id: string, props: { botName?: string | undefined }) =>
148 Effect.gen(function* () {
149 if (props.botName) return props.botName;
150 return toLexName(yield* createPhysicalName({ id, maxLength: 100 }));
151 });
152
153const describeBot = Effect.fn(function* (botId: string) {
154 return yield* lexm
155 .describeBot({ botId })
156 .pipe(
157 Effect.catchTag("ResourceNotFoundException", () =>
158 Effect.succeed(undefined),
159 ),
160 );
161});
162
163/** Find a bot by exact name. The physical name embeds app/stage/id. */
164const findBotByName = Effect.fn(function* (botName: string) {
165 const pages = yield* lexm.listBots
166 .pages({
167 filters: [{ name: "BotName", values: [botName], operator: "EQ" }],
168 })
169 .pipe(Stream.runCollect);
170 const summary = Array.from(pages)
171 .flatMap((page) => page.botSummaries ?? [])
172 .find((bot) => bot.botName === botName);
173 if (summary?.botId === undefined) return undefined;
174 return yield* describeBot(summary.botId);
175});
176
177const botArnOf = Effect.fn(function* (botId: string) {
178 const { accountId, region } = yield* AWSEnvironment.current;
179 return `arn:aws:lex:${region}:${accountId}:bot/${botId}`;
180});
181
182const attributesOf = Effect.fn(function* (bot: lexm.DescribeBotResponse) {
183 const botArn = yield* botArnOf(bot.botId!);
184 return {
185 botId: bot.botId!,
186 botName: bot.botName!,
187 botArn,
188 botStatus: bot.botStatus!,
189 roleArn: bot.roleArn!,
190 tags: yield* readLexTags(botArn),
191 } satisfies Bot["Attributes"];
192});
193
194export const BotProvider = () =>
195 Provider.effect(
196 Bot,
197 Effect.gen(function* () {
198 return {
199 stables: ["botId", "botArn"],
200
201 list: () =>
202 Effect.gen(function* () {
203 const pages = yield* lexm.listBots
204 .pages({})
205 .pipe(Stream.runCollect);
206 const ids = Array.from(pages)
207 .flatMap((page) => page.botSummaries ?? [])
208 .map((bot) => bot.botId)
209 .filter((id): id is string => id !== undefined);
210 const hydrated = yield* Effect.forEach(
211 ids,
212 (id) =>
213 Effect.flatMap(describeBot(id), (bot) =>
214 bot === undefined
215 ? Effect.succeed(undefined)
216 : attributesOf(bot),
217 ),
218 { concurrency: 5 },
219 );
220 return hydrated.filter(Predicate.isNotUndefined);
221 }),
222
223 read: Effect.fn(function* ({ id, olds, output }) {
224 const observed =
225 output?.botId !== undefined
226 ? yield* describeBot(output.botId)
227 : yield* findBotByName(yield* createBotName(id, olds ?? {}));
228 if (observed === undefined) return undefined;
229 const attrs = yield* attributesOf(observed);
230 return (yield* hasAlchemyTags(id, attrs.tags))
231 ? attrs
232 : Unowned(attrs);
233 }),
234
235 // All bot props are mutable via UpdateBot — no replacement triggers.
236
237 reconcile: Effect.fn(function* ({ id, news, output, session }) {
238 const botName = yield* createBotName(id, news);
239 const internalTags = yield* createInternalTags(id);
240 const desiredTags = { ...internalTags, ...news.tags };
241 const desiredDataPrivacy = news.dataPrivacy ?? {
242 childDirected: false,
243 };
244 // Wire unit is whole seconds (idleSessionTTLInSeconds).
245 const desiredTtl = toWireSeconds(news.idleSessionTTL) ?? 300;
246
247 // 1. OBSERVE — output.botId is only a cache; fall back to name.
248 let observed =
249 output?.botId !== undefined
250 ? yield* describeBot(output.botId)
251 : undefined;
252 if (observed === undefined) {
253 observed = yield* findBotByName(botName);
254 }
255
256 // 2. ENSURE — create when missing; a Conflict is a concurrent
257 // create of the same name.
258 if (observed === undefined) {
259 const created = yield* lexm
260 .createBot({
261 botName,
262 roleArn: news.roleArn,
263 dataPrivacy: desiredDataPrivacy,
264 idleSessionTTLInSeconds: desiredTtl,
265 description: news.description,
266 botTags: desiredTags,
267 })
268 .pipe(
269 Effect.map((r) => r.botId),
270 Effect.catchTag("ConflictException", () =>
271 Effect.map(findBotByName(botName), (bot) => bot?.botId),
272 ),
273 );
274 if (created === undefined) {
275 return yield* Effect.fail(
276 new Error(`failed to create Lex bot ${botName}`),
277 );
278 }
279 observed = yield* waitForBotSettled(created);
280 }
281
282 const botId = observed.botId!;
283
284 // 3. SYNC — apply the delta when any declared prop drifted.
285 if (
286 observed.botName !== botName ||
287 observed.roleArn !== news.roleArn ||
288 (observed.description ?? undefined) !==
289 (news.description ?? undefined) ||
290 observed.idleSessionTTLInSeconds !== desiredTtl ||
291 (observed.dataPrivacy?.childDirected ?? false) !==
292 desiredDataPrivacy.childDirected
293 ) {
294 yield* retryWhileConflict(
295 lexm.updateBot({
296 botId,
297 botName,
298 roleArn: news.roleArn,
299 dataPrivacy: desiredDataPrivacy,
300 idleSessionTTLInSeconds: desiredTtl,
301 description: news.description,
302 }),
303 );
304 observed = yield* waitForBotSettled(botId);
305 }
306
307 // 3b. SYNC TAGS — diff against observed cloud tags.
308 const botArn = yield* botArnOf(botId);
309 const observedTags = yield* readLexTags(botArn);
310 yield* syncLexTags(botArn, observedTags, desiredTags);
311
312 yield* session.note(botId);
313 return yield* attributesOf(observed);
314 }),
315
316 delete: Effect.fn(function* ({ output }) {
317 // DeleteBot is asynchronous (status `Deleting`); Lex reports a
318 // missing bot as PreconditionFailed.
319 yield* retryWhileConflict(
320 lexm.deleteBot({
321 botId: output.botId,
322 skipResourceInUseCheck: true,
323 }),
324 ).pipe(
325 Effect.catchTag("PreconditionFailedException", () => Effect.void),
326 );
327 }),
328 };
329 }),
330 );