1import * as chatbot from "@distilled.cloud/aws/chatbot";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
10import { AWSEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import { fromChatbotTags, toChatbotTags } from "./internal.ts";
13
14/**
15 * A criteria block controlling when a custom action button is shown on a
16 * notification.
17 */
18export interface CustomActionAttachmentCriteria {
19 /**
20 * The operation to perform on the named variable: `HAS_VALUE` or `EQUALS`.
21 */
22 operator: "HAS_VALUE" | "EQUALS" | (string & {});
23 /**
24 * The name of the notification variable to operate on.
25 */
26 variableName: string;
27 /**
28 * The value to compare the variable against when the operator is `EQUALS`.
29 */
30 value?: string;
31}
32
33/**
34 * Defines when and how a custom action surfaces as a button on chat
35 * notifications.
36 */
37export interface CustomActionAttachment {
38 /**
39 * The type of notification the action button appears on, e.g.
40 * `CloudWatch`. Although the AWS API documentation marks this optional,
41 * the live API rejects attachments without it (`Invalid request body`).
42 */
43 notificationType: string;
44 /**
45 * The label of the button that appears on the notification.
46 */
47 buttonText?: string;
48 /**
49 * Conditions (over notification variables) that must be met for the button
50 * to appear.
51 */
52 criteria?: CustomActionAttachmentCriteria[];
53 /**
54 * Variable values bound into the action's command text.
55 */
56 variables?: Record<string, string>;
57}
58
59export interface CustomActionProps {
60 /**
61 * Name of the custom action (1-64 characters, `A-Za-z0-9-_`). Forms the
62 * trailing segment of the custom action ARN.
63 *
64 * Changing the name replaces the custom action.
65 * @default ${app}-${stage}-${id}
66 */
67 actionName?: string;
68 /**
69 * The CLI command text executed when the action is invoked, e.g.
70 * `aws lambda list-functions`. Variables from attachments may be
71 * interpolated with `$variable` syntax.
72 */
73 commandText: string;
74 /**
75 * An alias that lets chat users invoke the action as `@aws run <alias>`.
76 */
77 aliasName?: string;
78 /**
79 * Notification attachments that surface the action as a button on
80 * matching notifications.
81 */
82 attachments?: CustomActionAttachment[];
83 /**
84 * Tags to apply to the custom action. Merged with internal Alchemy tags.
85 */
86 tags?: Record<string, string>;
87}
88
89export interface CustomAction extends Resource<
90 "AWS.Chatbot.CustomAction",
91 CustomActionProps,
92 {
93 /**
94 * Name of the custom action.
95 */
96 actionName: string;
97 /**
98 * The ARN of the custom action.
99 */
100 customActionArn: string;
101 },
102 never,
103 Providers
104> {}
105
106/**
107 * An AWS Chatbot (Amazon Q Developer in chat applications) custom action —
108 * a reusable CLI command that chat users invoke by alias or as a button on
109 * notifications.
110 *
111 * Custom actions exist at the account level and do not require a chat
112 * workspace to be onboarded, though they only become usable once a Slack or
113 * Microsoft Teams channel configuration exists.
114 *
115 * ### Creating Custom Actions
116 * **Example:** List Lambda functions from chat
117 * ```typescript
118 * import * as Chatbot from "alchemy/AWS/Chatbot";
119 *
120 * const action = yield* Chatbot.CustomAction("ListFunctions", {
121 * commandText: "aws lambda list-functions",
122 * aliasName: "list-functions",
123 * });
124 * ```
125 *
126 * **Example:** Button on CloudWatch alarm notifications
127 * ```typescript
128 * const action = yield* Chatbot.CustomAction("DescribeAlarm", {
129 * commandText: "aws cloudwatch describe-alarms --alarm-names $AlarmName",
130 * attachments: [
131 * {
132 * notificationType: "CloudWatch",
133 * buttonText: "Describe alarm",
134 * criteria: [
135 * { operator: "HAS_VALUE", variableName: "AlarmName" },
136 * ],
137 * },
138 * ],
139 * });
140 * ```
141 *
142 * @resource
143 */
144export const CustomAction = Resource<CustomAction>("AWS.Chatbot.CustomAction");
145
146const toWireAttachment = (
147 attachment: CustomActionAttachment,
148): chatbot.CustomActionAttachment => ({
149 NotificationType: attachment.notificationType,
150 ButtonText: attachment.buttonText,
151 Criteria: attachment.criteria?.map((c) => ({
152 Operator: c.operator,
153 VariableName: c.variableName,
154 Value: c.value,
155 })),
156 Variables: attachment.variables,
157});
158
159export const CustomActionProvider = () =>
160 Provider.effect(
161 CustomAction,
162 Effect.gen(function* () {
163 const createActionName = Effect.fn(function* (
164 id: string,
165 props: Pick<CustomActionProps, "actionName">,
166 ) {
167 // Custom action names are limited to 64 characters of [A-Za-z0-9-_].
168 return (
169 props.actionName ?? (yield* createPhysicalName({ id, maxLength: 64 }))
170 );
171 });
172
173 const actionArn = Effect.fn(function* (actionName: string) {
174 const { accountId } = yield* AWSEnvironment.current;
175 // Chatbot custom action ARNs are global (no region component).
176 return `arn:aws:chatbot::${accountId}:custom-action/${actionName}`;
177 });
178
179 const observeAction = (arn: string) =>
180 chatbot.getCustomAction({ CustomActionArn: arn }).pipe(
181 Effect.map((r) => r.CustomAction),
182 Effect.catchTag("ResourceNotFoundException", () =>
183 Effect.succeed(undefined),
184 ),
185 );
186
187 const observedTags = (arn: string) =>
188 chatbot.listTagsForResource({ ResourceARN: arn }).pipe(
189 Effect.map((r) => fromChatbotTags(r.Tags)),
190 Effect.catchTag("ResourceNotFoundException", () =>
191 Effect.succeed({} as Record<string, string>),
192 ),
193 );
194
195 return CustomAction.Provider.of({
196 stables: ["actionName", "customActionArn"],
197 list: () =>
198 Effect.gen(function* () {
199 const arns = yield* chatbot.listCustomActions
200 .items({})
201 .pipe(Stream.runCollect);
202 return Array.from(arns).map((arn) => ({
203 actionName: arn.slice(arn.lastIndexOf("/") + 1),
204 customActionArn: arn,
205 }));
206 }),
207 read: Effect.fn(function* ({ id, olds, output }) {
208 const actionName =
209 output?.actionName ?? (yield* createActionName(id, olds ?? {}));
210 const arn = output?.customActionArn ?? (yield* actionArn(actionName));
211 const found = yield* observeAction(arn);
212 if (found === undefined) return undefined;
213 const attrs = { actionName, customActionArn: found.CustomActionArn };
214 const tags = yield* observedTags(found.CustomActionArn);
215 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
216 }),
217 diff: Effect.fn(function* ({ id, news, olds }) {
218 if (!isResolved(news)) return undefined;
219 const oldName = yield* createActionName(id, olds ?? {});
220 const newName = yield* createActionName(id, news ?? {});
221 if (oldName !== newName) {
222 return { action: "replace" } as const;
223 }
224 // fall through: engine default update logic for mutable fields
225 }),
226 reconcile: Effect.fn(function* ({ id, news, output, session }) {
227 const actionName =
228 output?.actionName ?? (yield* createActionName(id, news));
229 const arn = output?.customActionArn ?? (yield* actionArn(actionName));
230 const internalTags = yield* createInternalTags(id);
231 const desiredTags = { ...news.tags, ...internalTags };
232 const desiredDefinition = { CommandText: news.commandText };
233 const desiredAttachments = news.attachments?.map(toWireAttachment);
234
235 // 1. OBSERVE — cloud state is authoritative.
236 let live = yield* observeAction(arn);
237
238 // 2. ENSURE — create when missing; a concurrent create surfaces as
239 // the typed ConflictException, which we treat as a race and
240 // re-observe.
241 if (live === undefined) {
242 live = yield* chatbot
243 .createCustomAction({
244 ActionName: actionName,
245 Definition: desiredDefinition,
246 AliasName: news.aliasName,
247 Attachments: desiredAttachments,
248 Tags: toChatbotTags(desiredTags),
249 })
250 .pipe(
251 Effect.map((r) => r.CustomActionArn),
252 Effect.catchTag("ConflictException", () => Effect.succeed(arn)),
253 Effect.flatMap(observeAction),
254 );
255 }
256
257 // 3. SYNC — diff the OBSERVED definition, alias, and attachments
258 // against the desired state; update only on drift.
259 const inSync =
260 live !== undefined &&
261 live.Definition.CommandText === news.commandText &&
262 live.AliasName === news.aliasName &&
263 JSON.stringify(live.Attachments ?? []) ===
264 JSON.stringify(desiredAttachments ?? []);
265 if (!inSync) {
266 yield* chatbot.updateCustomAction({
267 CustomActionArn: arn,
268 Definition: desiredDefinition,
269 AliasName: news.aliasName,
270 Attachments: desiredAttachments,
271 });
272 }
273
274 // 3b. SYNC TAGS — diff against OBSERVED cloud tags so adoption
275 // converges (create-time Tags only apply on first create).
276 const currentTags = yield* observedTags(arn);
277 const { upsert, removed } = diffTags(currentTags, desiredTags);
278 if (upsert.length > 0) {
279 yield* chatbot.tagResource({
280 ResourceARN: arn,
281 Tags: upsert.map(({ Key, Value }) => ({
282 TagKey: Key,
283 TagValue: Value,
284 })),
285 });
286 }
287 if (removed.length > 0) {
288 yield* chatbot.untagResource({
289 ResourceARN: arn,
290 TagKeys: removed,
291 });
292 }
293
294 yield* session.note(actionName);
295 return { actionName, customActionArn: arn };
296 }),
297 delete: Effect.fn(function* ({ output }) {
298 yield* chatbot
299 .deleteCustomAction({ CustomActionArn: output.customActionArn })
300 .pipe(
301 // Idempotent delete — a missing action is not an error.
302 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
303 );
304 }),
305 });
306 }),
307 );