1import * as Lambda from "@/AWS/Lambda";
2import * as PinpointSMSVoiceV2 from "@/AWS/PinpointSMSVoiceV2";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import path from "pathe";
9
10const main = path.resolve(import.meta.dirname, "phone-handler.ts");
11
12/**
13 * Destination the fixture sends to — the US simulator success number, so
14 * sends complete without a registered/verified destination and no real
15 * end user is ever contacted.
16 */
17export const SIMULATOR_DESTINATION = "+14254147755";
18
19export class SmsVoicePhoneTestFunction extends Lambda.Function<Lambda.Function>()(
20 "SmsVoicePhoneTestFunction",
21) {}
22
23export default SmsVoicePhoneTestFunction.make(
24 {
25 main,
26 functionUrl: true,
27 timeout: Duration.seconds(30),
28 },
29 Effect.gen(function* () {
30 // SIMULATOR numbers only exchange messages with simulator
31 // destinations and carry the smallest leasing cost.
32 const number = yield* PinpointSMSVoiceV2.PhoneNumber("BindingsNumber", {
33 isoCountryCode: "US",
34 messageType: "TRANSACTIONAL",
35 numberCapabilities: ["SMS"],
36 numberType: "SIMULATOR",
37 tags: { fixture: "smsvoice-bindings" },
38 });
39
40 const sendText = yield* PinpointSMSVoiceV2.SendTextMessage(number);
41 const sendVoice = yield* PinpointSMSVoiceV2.SendVoiceMessage(number);
42 const sendMedia = yield* PinpointSMSVoiceV2.SendMediaMessage(number);
43 const putKeyword = yield* PinpointSMSVoiceV2.PutKeyword(number);
44 const describeKeywords = yield* PinpointSMSVoiceV2.DescribeKeywords(number);
45 const deleteKeyword = yield* PinpointSMSVoiceV2.DeleteKeyword(number);
46
47 return {
48 fetch: Effect.gen(function* () {
49 const request = yield* HttpServerRequest;
50 const pathname = new URL(request.originalUrl).pathname;
51
52 // Cheap readiness route — no AWS call.
53 if (request.method === "GET" && pathname === "/ping") {
54 return yield* HttpServerResponse.json({ ok: true });
55 }
56
57 if (request.method === "POST" && pathname === "/send-text") {
58 const { MessageId } = yield* sendText({
59 DestinationPhoneNumber: SIMULATOR_DESTINATION,
60 MessageBody: "hello from alchemy",
61 MessageType: "TRANSACTIONAL",
62 });
63 return yield* HttpServerResponse.json({ messageId: MessageId });
64 }
65
66 if (request.method === "POST" && pathname === "/send-voice") {
67 // The SIMULATOR number is leased with SMS capability only —
68 // surface the typed tag so the test can assert IAM allowed the
69 // call regardless of the capability verdict.
70 const result = yield* sendVoice({
71 DestinationPhoneNumber: SIMULATOR_DESTINATION,
72 MessageBody: "hello from alchemy",
73 }).pipe(
74 Effect.map((r) => ({ ok: true as const, messageId: r.MessageId })),
75 Effect.catchTag(
76 [
77 "AccessDeniedException",
78 "ConflictException",
79 "ResourceNotFoundException",
80 "ServiceQuotaExceededException",
81 "ThrottlingException",
82 "ValidationException",
83 "InternalServerException",
84 ],
85 (e) =>
86 Effect.succeed({
87 ok: false as const,
88 tag: e._tag,
89 message: e.message,
90 }),
91 ),
92 );
93 return yield* HttpServerResponse.json(result);
94 }
95
96 if (request.method === "POST" && pathname === "/send-media") {
97 const result = yield* sendMedia({
98 DestinationPhoneNumber: SIMULATOR_DESTINATION,
99 MessageBody: "hello from alchemy (mms)",
100 }).pipe(
101 Effect.map((r) => ({ ok: true as const, messageId: r.MessageId })),
102 Effect.catchTag(
103 [
104 "AccessDeniedException",
105 "ConflictException",
106 "ResourceNotFoundException",
107 "ServiceQuotaExceededException",
108 "ThrottlingException",
109 "ValidationException",
110 "InternalServerException",
111 ],
112 (e) =>
113 Effect.succeed({
114 ok: false as const,
115 tag: e._tag,
116 message: e.message,
117 }),
118 ),
119 );
120 return yield* HttpServerResponse.json(result);
121 }
122
123 if (request.method === "POST" && pathname === "/keyword-put") {
124 const result = yield* putKeyword({
125 Keyword: "INFO",
126 KeywordMessage: "Visit https://alchemy.run for details.",
127 });
128 return yield* HttpServerResponse.json({
129 keyword: result.Keyword,
130 message: result.KeywordMessage,
131 });
132 }
133
134 if (request.method === "POST" && pathname === "/keyword-list") {
135 const result = yield* describeKeywords({});
136 return yield* HttpServerResponse.json({
137 keywords: (result.Keywords ?? []).map((k) => k.Keyword),
138 });
139 }
140
141 if (request.method === "POST" && pathname === "/keyword-delete") {
142 const result = yield* deleteKeyword({ Keyword: "INFO" });
143 return yield* HttpServerResponse.json({ deleted: result.Keyword });
144 }
145
146 return yield* HttpServerResponse.json(
147 { error: "Not found", method: request.method, pathname },
148 { status: 404 },
149 );
150 }).pipe(Effect.orDie),
151 };
152 }).pipe(
153 Effect.provide(
154 Layer.mergeAll(
155 PinpointSMSVoiceV2.SendTextMessageHttp,
156 PinpointSMSVoiceV2.SendVoiceMessageHttp,
157 PinpointSMSVoiceV2.SendMediaMessageHttp,
158 PinpointSMSVoiceV2.PutKeywordHttp,
159 PinpointSMSVoiceV2.DescribeKeywordsHttp,
160 PinpointSMSVoiceV2.DeleteKeywordHttp,
161 ),
162 ),
163 ),
164);