1import * as IAM from "@/AWS/IAM";
2import * as Lambda from "@/AWS/Lambda";
3import * as LexV2 from "@/AWS/LexV2";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Redacted from "effect/Redacted";
7import * as Result from "effect/Result";
8import { HttpServerRequest } from "effect/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/http/HttpServerResponse";
10import path from "pathe";
11
12const main = path.resolve(import.meta.dirname, "handler.ts");
13
14export class LexTestFunction extends Lambda.Function<Lambda.Function>()(
15 "LexTestFunction",
16) {}
17
18const contentOf = (
19 content: string | Redacted.Redacted<string> | undefined,
20): string | undefined =>
21 content === undefined
22 ? undefined
23 : typeof content === "string"
24 ? content
25 : Redacted.value(content);
26
27/**
28 * Surface a typed failure as JSON. Only `AccessDeniedException` (fresh IAM
29 * policy propagation) maps to a retryable 502 — any other tag is terminal
30 * and returns 400 so the test fails fast with the tag visible.
31 */
32const respond = <A, E extends { readonly _tag: string }>(
33 self: Effect.Effect<A, E>,
34 onSuccess: (a: A) => Record<string, unknown>,
35) =>
36 Effect.gen(function* () {
37 const result = yield* Effect.result(self);
38 if (Result.isFailure(result)) {
39 return yield* HttpServerResponse.json(
40 {
41 error: result.failure._tag,
42 message: String(
43 (result.failure as { message?: string }).message ?? "",
44 ),
45 },
46 {
47 status: result.failure._tag === "AccessDeniedException" ? 502 : 400,
48 },
49 );
50 }
51 return yield* HttpServerResponse.json(onSuccess(result.success));
52 });
53
54export default LexTestFunction.make(
55 {
56 main,
57 functionUrl: true,
58 timeout: Duration.seconds(30),
59 },
60 Effect.gen(function* () {
61 // The whole Lex chain: role -> bot -> DRAFT locale -> intents -> built
62 // version -> alias. BotVersion builds the locale before snapshotting.
63 const role = yield* IAM.Role("LexBotRole", {
64 assumeRolePolicyDocument: {
65 Version: "2012-10-17",
66 Statement: [
67 {
68 Effect: "Allow",
69 Principal: { Service: "lexv2.amazonaws.com" },
70 Action: ["sts:AssumeRole"],
71 },
72 ],
73 },
74 });
75 const bot = yield* LexV2.Bot("E2EBot", {
76 roleArn: role.roleArn,
77 description: "alchemy lex recognize-text e2e bot",
78 });
79 const locale = yield* LexV2.BotLocale("En", {
80 botId: bot.botId,
81 localeId: "en_US",
82 });
83 const greet = yield* LexV2.Intent("Greet", {
84 botId: locale.botId,
85 localeId: locale.localeId,
86 intentName: "Greet",
87 sampleUtterances: ["hello", "hi", "hey there"],
88 });
89 // Slotless intent fulfilled by this function's code hook.
90 const order = yield* LexV2.Intent("Order", {
91 botId: greet.botId,
92 localeId: greet.localeId,
93 intentName: "OrderPizza",
94 sampleUtterances: ["order a pizza", "I want to order a pizza"],
95 fulfillmentCodeHook: true,
96 });
97 const version = yield* LexV2.BotVersion("V1", {
98 botId: order.botId,
99 localeIds: [order.localeId],
100 });
101 const alias = yield* LexV2.BotAlias("Live", {
102 botId: version.botId,
103 botVersion: version.botVersion,
104 });
105
106 const recognizeText = yield* LexV2.RecognizeText(alias);
107 const recognizeUtterance = yield* LexV2.RecognizeUtterance(alias);
108 const getSession = yield* LexV2.GetSession(alias);
109 const putSession = yield* LexV2.PutSession(alias);
110 const deleteSession = yield* LexV2.DeleteSession(alias);
111
112 // Fulfillment code hook: Lex invokes this same function while
113 // /recognize awaits the RecognizeText response.
114 yield* LexV2.onCodeHook(alias, { localeId: "en_US" }, (event) =>
115 Effect.succeed(
116 LexV2.fulfillIntent(event, {
117 message: `Order placed for ${event.sessionId}!`,
118 }),
119 ),
120 );
121
122 return {
123 fetch: Effect.gen(function* () {
124 const request = yield* HttpServerRequest;
125 const url = new URL(request.originalUrl);
126 const sessionId = url.searchParams.get("sessionId") ?? "missing";
127
128 if (request.method === "POST" && url.pathname === "/recognize") {
129 const body = (yield* request.json) as unknown as {
130 text: string;
131 sessionId: string;
132 };
133 return yield* respond(
134 recognizeText({
135 localeId: "en_US",
136 sessionId: body.sessionId,
137 text: body.text,
138 }),
139 (reply) => ({
140 intent: reply.sessionState?.intent?.name ?? null,
141 state: reply.sessionState?.intent?.state ?? null,
142 messages: (reply.messages ?? []).map((message) =>
143 contentOf(message.content),
144 ),
145 interpretations: (reply.interpretations ?? []).map(
146 (interpretation) => interpretation.intent?.name,
147 ),
148 }),
149 );
150 }
151
152 if (request.method === "POST" && url.pathname === "/utterance") {
153 const body = (yield* request.json) as unknown as {
154 text: string;
155 sessionId: string;
156 };
157 return yield* respond(
158 recognizeUtterance({
159 localeId: "en_US",
160 sessionId: body.sessionId,
161 requestContentType: "text/plain; charset=utf-8",
162 // Default is speech — the test locale has no voice configured.
163 responseContentType: "text/plain; charset=utf-8",
164 inputStream: new TextEncoder().encode(body.text),
165 }),
166 (reply) => ({
167 contentType: reply.contentType ?? null,
168 sessionId: reply.sessionId ?? null,
169 }),
170 );
171 }
172
173 if (request.method === "POST" && url.pathname === "/session") {
174 const body = (yield* request.json) as unknown as {
175 sessionId: string;
176 attributes: Record<string, string>;
177 };
178 return yield* respond(
179 putSession({
180 localeId: "en_US",
181 sessionId: body.sessionId,
182 sessionState: {
183 dialogAction: { type: "ElicitIntent" },
184 sessionAttributes: body.attributes,
185 },
186 // ElicitIntent requires a message to relay to the user.
187 messages: [
188 { contentType: "PlainText", content: "How can I help?" },
189 ],
190 // Default is speech — the test locale has no voice configured.
191 responseContentType: "text/plain; charset=utf-8",
192 }),
193 (reply) => ({ sessionId: reply.sessionId ?? null }),
194 );
195 }
196
197 if (request.method === "GET" && url.pathname === "/session") {
198 return yield* respond(
199 getSession({ localeId: "en_US", sessionId }),
200 (reply) => ({
201 sessionId: reply.sessionId ?? null,
202 attributes: reply.sessionState?.sessionAttributes ?? {},
203 }),
204 );
205 }
206
207 if (request.method === "DELETE" && url.pathname === "/session") {
208 return yield* respond(
209 deleteSession({ localeId: "en_US", sessionId }),
210 (reply) => ({ sessionId: reply.sessionId ?? null }),
211 );
212 }
213
214 if (request.method === "GET" && url.pathname === "/health") {
215 return yield* HttpServerResponse.json({ ok: true });
216 }
217
218 return yield* HttpServerResponse.json(
219 {
220 error: "Not found",
221 method: request.method,
222 pathname: url.pathname,
223 },
224 { status: 404 },
225 );
226 }).pipe(Effect.orDie),
227 };
228 }).pipe(
229 Effect.provide([
230 LexV2.RecognizeTextHttp,
231 LexV2.RecognizeUtteranceHttp,
232 LexV2.GetSessionHttp,
233 LexV2.PutSessionHttp,
234 LexV2.DeleteSessionHttp,
235 LexV2.LambdaCodeHookEventSource,
236 ]),
237 ),
238);