1import * as chatbot from "@distilled.cloud/aws/chatbot";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10
11/**
12 * Raised when a freshly created Chatbot association has not become visible
13 * to `listAssociations` within the bounded eventual-consistency window.
14 */
15export class AssociationNotVisible extends Data.TaggedError(
16 "AssociationNotVisible",
17)<{
18 readonly chatConfiguration: string;
19 readonly resource: string;
20}> {}
21
22export interface AssociationProps {
23 /**
24 * ARN of the Slack or Microsoft Teams channel configuration to associate
25 * the resource with (e.g.
26 * `arn:aws:chatbot::123456789012:chat-configuration/slack-channel/alerts`).
27 *
28 * Changing the configuration replaces the association.
29 */
30 chatConfiguration: string;
31 /**
32 * ARN of the resource to associate with the channel configuration — a
33 * Chatbot custom action ARN (e.g.
34 * `arn:aws:chatbot::123456789012:custom-action/describe-alarm`).
35 *
36 * Changing the resource replaces the association.
37 */
38 resource: string;
39}
40
41export interface Association extends Resource<
42 "AWS.Chatbot.Association",
43 AssociationProps,
44 {
45 /**
46 * ARN of the channel configuration the resource is associated with.
47 */
48 chatConfigurationArn: string;
49 /**
50 * ARN of the associated resource (the custom action).
51 */
52 resourceArn: string;
53 },
54 never,
55 Providers
56> {}
57
58/**
59 * An AWS Chatbot (Amazon Q Developer in chat applications) association that
60 * links a resource — a {@link CustomAction} — to a Slack or Microsoft Teams
61 * channel configuration so the action is available in that channel.
62 *
63 * ### Associating Custom Actions
64 * **Example:** Attach a custom action to a Slack channel configuration
65 * ```typescript
66 * import * as Chatbot from "alchemy/AWS/Chatbot";
67 *
68 * const action = yield* Chatbot.CustomAction("DescribeAlarm", {
69 * commandText: "aws cloudwatch describe-alarms --alarm-names $AlarmName",
70 * });
71 *
72 * const config = yield* Chatbot.SlackChannelConfiguration("Alerts", {
73 * slackTeamId: "T012ABCDEFG",
74 * slackChannelId: "C012AB3CD",
75 * iamRoleArn: role.roleArn,
76 * });
77 *
78 * const association = yield* Chatbot.Association("AlarmAction", {
79 * chatConfiguration: config.chatConfigurationArn,
80 * resource: action.customActionArn,
81 * });
82 * ```
83 *
84 * @resource
85 */
86export const Association = Resource<Association>("AWS.Chatbot.Association");
87
88export const AssociationProvider = () =>
89 Provider.effect(
90 Association,
91 Effect.gen(function* () {
92 // listAssociations is a filter over a single configuration; an unknown
93 // configuration ARN yields an empty list (verified live), while a
94 // malformed ARN surfaces the typed InvalidRequestException — both are
95 // treated as "no association".
96 const observeAssociation = Effect.fn(function* (
97 chatConfiguration: string,
98 resource: string,
99 ) {
100 return yield* chatbot.listAssociations
101 .items({ ChatConfiguration: chatConfiguration })
102 .pipe(
103 Stream.runCollect,
104 Effect.map((listings) =>
105 Array.from(listings).find(
106 (listing) => listing.Resource === resource,
107 ),
108 ),
109 Effect.catchTag("InvalidRequestException", () =>
110 Effect.succeed(undefined),
111 ),
112 );
113 });
114
115 const toAttributes = (chatConfiguration: string, resource: string) => ({
116 chatConfigurationArn: chatConfiguration,
117 resourceArn: resource,
118 });
119
120 return Association.Provider.of({
121 stables: ["chatConfigurationArn", "resourceArn"],
122 // The configuration/resource ARN pair IS the association's identity —
123 // changing either side replaces it. There is nothing to update.
124 diff: Effect.fn(function* ({ olds, news }) {
125 if (!isResolved(news)) return undefined;
126 if (
127 olds !== undefined &&
128 (olds.chatConfiguration !== news.chatConfiguration ||
129 olds.resource !== news.resource)
130 ) {
131 return { action: "replace" } as const;
132 }
133 }),
134 read: Effect.fn(function* ({ olds, output }) {
135 const chatConfiguration =
136 output?.chatConfigurationArn ?? olds?.chatConfiguration;
137 const resource = output?.resourceArn ?? olds?.resource;
138 if (chatConfiguration === undefined || resource === undefined) {
139 return undefined;
140 }
141 const found = yield* observeAssociation(chatConfiguration, resource);
142 if (found === undefined) return undefined;
143 // Associations cannot carry tags; existence of the exact
144 // configuration/resource pair is the ownership signal.
145 return toAttributes(chatConfiguration, resource);
146 }),
147 // Existence-only resource: observe → if missing, associate. There is
148 // no mutable aspect to sync.
149 reconcile: Effect.fn(function* ({ news, session }) {
150 // 1. OBSERVE — cloud state is authoritative.
151 const observed = yield* observeAssociation(
152 news.chatConfiguration,
153 news.resource,
154 );
155
156 // 2. ENSURE — associate when missing. The API is an idempotent
157 // upsert, so a concurrent associate converges naturally.
158 if (observed === undefined) {
159 yield* chatbot.associateToConfiguration({
160 Resource: news.resource,
161 ChatConfiguration: news.chatConfiguration,
162 });
163
164 // 3. RETURN — bounded wait until the association is visible.
165 yield* observeAssociation(
166 news.chatConfiguration,
167 news.resource,
168 ).pipe(
169 Effect.flatMap((listing) =>
170 listing !== undefined
171 ? Effect.void
172 : Effect.fail(
173 new AssociationNotVisible({
174 chatConfiguration: news.chatConfiguration,
175 resource: news.resource,
176 }),
177 ),
178 ),
179 Effect.retry({
180 while: (e): boolean => e._tag === "AssociationNotVisible",
181 schedule: Schedule.max([
182 Schedule.fixed("2 seconds"),
183 Schedule.recurs(8),
184 ]),
185 }),
186 );
187 }
188
189 yield* session.note(news.resource);
190 return toAttributes(news.chatConfiguration, news.resource);
191 }),
192 delete: Effect.fn(function* ({ output }) {
193 // Observe before delete — disassociating a pair that is already
194 // gone (or whose configuration was deleted) is not an error.
195 const observed = yield* observeAssociation(
196 output.chatConfigurationArn,
197 output.resourceArn,
198 );
199 if (observed === undefined) return;
200 yield* chatbot
201 .disassociateFromConfiguration({
202 Resource: output.resourceArn,
203 ChatConfiguration: output.chatConfigurationArn,
204 })
205 .pipe(
206 // Idempotent delete — a concurrently removed association or
207 // configuration is not an error. A configuration deleted out
208 // from under the association surfaces the typed
209 // ResourceNotFoundException ("Channel Arn ... does not
210 // exist!", patched in distilled).
211 Effect.catchTag(
212 ["InvalidRequestException", "ResourceNotFoundException"],
213 () => Effect.void,
214 ),
215 );
216 }),
217 list: () =>
218 Effect.gen(function* () {
219 const configurationArns: string[] = [];
220 const slack = yield* chatbot.describeSlackChannelConfigurations
221 .items({})
222 .pipe(Stream.runCollect);
223 for (const config of slack) {
224 configurationArns.push(config.ChatConfigurationArn);
225 }
226 const teams = yield* chatbot.listMicrosoftTeamsChannelConfigurations
227 .items({})
228 .pipe(Stream.runCollect);
229 for (const config of teams) {
230 configurationArns.push(config.ChatConfigurationArn);
231 }
232 const results: {
233 chatConfigurationArn: string;
234 resourceArn: string;
235 }[] = [];
236 for (const arn of configurationArns) {
237 const associations = yield* chatbot.listAssociations
238 .items({ ChatConfiguration: arn })
239 .pipe(Stream.runCollect);
240 for (const association of associations) {
241 results.push(toAttributes(arn, association.Resource));
242 }
243 }
244 return results;
245 }),
246 });
247 }),
248 );