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
14export interface SlackChannelConfigurationProps {
15 /**
16 * Name of the configuration (1-128 characters, `A-Za-z0-9-_`). Forms the
17 * trailing segment of the chat configuration ARN.
18 *
19 * Changing the name replaces the configuration.
20 * @default ${app}-${stage}-${id}
21 */
22 configurationName?: string;
23 /**
24 * ID of the Slack workspace (team) authorized with AWS Chatbot, e.g.
25 * `T012ABCDEFG`. The workspace must first be onboarded via the AWS Chatbot
26 * console OAuth flow — this cannot be automated.
27 *
28 * Changing the workspace replaces the configuration.
29 */
30 slackTeamId: string;
31 /**
32 * ID of the Slack channel, e.g. `C012AB3CD`. In Slack, copy it from the
33 * channel details or the channel URL.
34 */
35 slackChannelId: string;
36 /**
37 * Name of the Slack channel. Informational only.
38 */
39 slackChannelName?: string;
40 /**
41 * ARN of the IAM role that defines the permissions for running commands
42 * from this channel (the channel role). Must trust
43 * `chatbot.amazonaws.com`.
44 */
45 iamRoleArn: string;
46 /**
47 * ARNs of the SNS topics that deliver notifications to this channel.
48 */
49 snsTopicArns?: string[];
50 /**
51 * Logging level for the configuration: `ERROR`, `INFO`, or `NONE`.
52 * @default NONE
53 */
54 loggingLevel?: "ERROR" | "INFO" | "NONE";
55 /**
56 * ARNs of the IAM managed policies applied as channel guardrails. The AWS
57 * managed `AdministratorAccess` policy is applied by default if this is
58 * not set.
59 */
60 guardrailPolicyArns?: string[];
61 /**
62 * Whether channel members must have their AWS user identities authorized
63 * before running commands.
64 * @default false
65 */
66 userAuthorizationRequired?: boolean;
67 /**
68 * Tags to apply to the configuration. Merged with internal Alchemy tags.
69 */
70 tags?: Record<string, string>;
71}
72
73export interface SlackChannelConfiguration extends Resource<
74 "AWS.Chatbot.SlackChannelConfiguration",
75 SlackChannelConfigurationProps,
76 {
77 /**
78 * Name of the channel configuration.
79 */
80 configurationName: string;
81 /**
82 * The ARN of the channel configuration.
83 */
84 chatConfigurationArn: string;
85 /**
86 * The Slack workspace (team) ID.
87 */
88 slackTeamId: string;
89 /**
90 * The Slack channel ID.
91 */
92 slackChannelId: string;
93 /**
94 * Name of the Slack workspace.
95 */
96 slackTeamName: string;
97 /**
98 * Current state of the configuration (e.g. `ENABLED`).
99 */
100 state: string | undefined;
101 },
102 never,
103 Providers
104> {}
105
106/**
107 * An AWS Chatbot (Amazon Q Developer in chat applications) Slack channel
108 * configuration that delivers SNS notifications to a Slack channel and lets
109 * channel members run read-only or scoped AWS commands.
110 *
111 * The Slack workspace must be onboarded to AWS Chatbot beforehand via the
112 * console OAuth flow (Chatbot console -> Configure new client -> Slack) —
113 * workspace authorization cannot be automated.
114 *
115 * ### Creating Slack Channel Configurations
116 * **Example:** Notify a Slack channel from an SNS topic
117 * ```typescript
118 * import * as Chatbot from "alchemy/AWS/Chatbot";
119 * import { Role } from "alchemy/AWS/IAM/Role";
120 * import * as SNS from "alchemy/AWS/SNS";
121 *
122 * const topic = yield* SNS.Topic("Alerts", {});
123 * const role = yield* Role("ChatbotRole", {
124 * assumeRolePolicyDocument: {
125 * Version: "2012-10-17",
126 * Statement: [
127 * {
128 * Effect: "Allow",
129 * Principal: { Service: "chatbot.amazonaws.com" },
130 * Action: ["sts:AssumeRole"],
131 * },
132 * ],
133 * },
134 * managedPolicyArns: ["arn:aws:iam::aws:policy/ReadOnlyAccess"],
135 * });
136 *
137 * const config = yield* Chatbot.SlackChannelConfiguration("Alerts", {
138 * slackTeamId: "T012ABCDEFG",
139 * slackChannelId: "C012AB3CD",
140 * iamRoleArn: role.roleArn,
141 * snsTopicArns: [topic.topicArn],
142 * loggingLevel: "ERROR",
143 * });
144 * ```
145 *
146 * @resource
147 */
148export const SlackChannelConfiguration = Resource<SlackChannelConfiguration>(
149 "AWS.Chatbot.SlackChannelConfiguration",
150);
151
152export const SlackChannelConfigurationProvider = () =>
153 Provider.effect(
154 SlackChannelConfiguration,
155 Effect.gen(function* () {
156 const createConfigurationName = Effect.fn(function* (
157 id: string,
158 props: Pick<SlackChannelConfigurationProps, "configurationName">,
159 ) {
160 return (
161 props.configurationName ??
162 (yield* createPhysicalName({ id, maxLength: 128 }))
163 );
164 });
165
166 const configurationArn = Effect.fn(function* (configurationName: string) {
167 const { accountId } = yield* AWSEnvironment.current;
168 // Chatbot chat-configuration ARNs are global (no region component).
169 return `arn:aws:chatbot::${accountId}:chat-configuration/slack-channel/${configurationName}`;
170 });
171
172 // describeSlackChannelConfigurations is a filter — an unknown ARN
173 // yields an empty list rather than a not-found error.
174 const observeConfiguration = (arn: string) =>
175 chatbot
176 .describeSlackChannelConfigurations({ ChatConfigurationArn: arn })
177 .pipe(Effect.map((r) => r.SlackChannelConfigurations?.[0]));
178
179 const observedTags = (arn: string) =>
180 chatbot.listTagsForResource({ ResourceARN: arn }).pipe(
181 Effect.map((r) => fromChatbotTags(r.Tags)),
182 Effect.catchTag("ResourceNotFoundException", () =>
183 Effect.succeed({} as Record<string, string>),
184 ),
185 );
186
187 const toAttributes = (
188 configurationName: string,
189 live: chatbot.SlackChannelConfiguration,
190 ) => ({
191 configurationName,
192 chatConfigurationArn: live.ChatConfigurationArn,
193 slackTeamId: live.SlackTeamId,
194 slackChannelId: live.SlackChannelId,
195 slackTeamName: live.SlackTeamName,
196 state: live.State,
197 });
198
199 return SlackChannelConfiguration.Provider.of({
200 stables: ["configurationName", "chatConfigurationArn", "slackTeamId"],
201 list: () =>
202 Effect.gen(function* () {
203 const configurations =
204 yield* chatbot.describeSlackChannelConfigurations
205 .items({})
206 .pipe(Stream.runCollect);
207 return Array.from(configurations).map((config) => {
208 const arn = config.ChatConfigurationArn;
209 return toAttributes(arn.slice(arn.lastIndexOf("/") + 1), config);
210 });
211 }),
212 read: Effect.fn(function* ({ id, olds, output }) {
213 const configurationName =
214 output?.configurationName ??
215 (yield* createConfigurationName(id, olds ?? {}));
216 const arn =
217 output?.chatConfigurationArn ??
218 (yield* configurationArn(configurationName));
219 const found = yield* observeConfiguration(arn);
220 if (found === undefined) return undefined;
221 const attrs = toAttributes(configurationName, found);
222 const tags = yield* observedTags(found.ChatConfigurationArn);
223 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
224 }),
225 diff: Effect.fn(function* ({ id, news, olds }) {
226 if (!isResolved(news)) return undefined;
227 const oldName = yield* createConfigurationName(id, olds ?? {});
228 const newName = yield* createConfigurationName(id, news ?? {});
229 if (oldName !== newName || olds?.slackTeamId !== news.slackTeamId) {
230 return { action: "replace" } as const;
231 }
232 // fall through: engine default update logic for mutable fields
233 }),
234 reconcile: Effect.fn(function* ({ id, news, output, session }) {
235 const configurationName =
236 output?.configurationName ??
237 (yield* createConfigurationName(id, news));
238 const arn =
239 output?.chatConfigurationArn ??
240 (yield* configurationArn(configurationName));
241 const internalTags = yield* createInternalTags(id);
242 const desiredTags = { ...news.tags, ...internalTags };
243 const desiredTopics = [...(news.snsTopicArns ?? [])].sort();
244 const desiredGuardrails = news.guardrailPolicyArns
245 ? [...news.guardrailPolicyArns].sort()
246 : undefined;
247
248 // 1. OBSERVE — cloud state is authoritative.
249 let live = yield* observeConfiguration(arn);
250
251 // 2. ENSURE — create when missing; a concurrent create surfaces as
252 // the typed ConflictException, which we treat as a race and
253 // re-observe.
254 if (live === undefined) {
255 live = yield* chatbot
256 .createSlackChannelConfiguration({
257 SlackTeamId: news.slackTeamId,
258 SlackChannelId: news.slackChannelId,
259 SlackChannelName: news.slackChannelName,
260 ConfigurationName: configurationName,
261 IamRoleArn: news.iamRoleArn,
262 SnsTopicArns: news.snsTopicArns,
263 LoggingLevel: news.loggingLevel,
264 GuardrailPolicyArns: news.guardrailPolicyArns,
265 UserAuthorizationRequired: news.userAuthorizationRequired,
266 Tags: toChatbotTags(desiredTags),
267 })
268 .pipe(
269 Effect.map((r) => r.ChannelConfiguration),
270 Effect.catchTag("ConflictException", () =>
271 observeConfiguration(arn),
272 ),
273 );
274 }
275
276 // 3. SYNC — diff the OBSERVED mutable aspects against the desired
277 // state; update only on drift.
278 const inSync =
279 live !== undefined &&
280 live.SlackChannelId === news.slackChannelId &&
281 live.IamRoleArn === news.iamRoleArn &&
282 JSON.stringify([...live.SnsTopicArns].sort()) ===
283 JSON.stringify(desiredTopics) &&
284 (live.LoggingLevel ?? "NONE") === (news.loggingLevel ?? "NONE") &&
285 JSON.stringify(
286 live.GuardrailPolicyArns
287 ? [...live.GuardrailPolicyArns].sort()
288 : undefined,
289 ) === JSON.stringify(desiredGuardrails) &&
290 (live.UserAuthorizationRequired ?? false) ===
291 (news.userAuthorizationRequired ?? false);
292 if (!inSync) {
293 live = yield* chatbot
294 .updateSlackChannelConfiguration({
295 ChatConfigurationArn: arn,
296 SlackChannelId: news.slackChannelId,
297 SlackChannelName: news.slackChannelName,
298 IamRoleArn: news.iamRoleArn,
299 SnsTopicArns: news.snsTopicArns,
300 LoggingLevel: news.loggingLevel,
301 GuardrailPolicyArns: news.guardrailPolicyArns,
302 UserAuthorizationRequired: news.userAuthorizationRequired,
303 })
304 .pipe(Effect.map((r) => r.ChannelConfiguration));
305 }
306
307 // 3b. SYNC TAGS — diff against OBSERVED cloud tags so adoption
308 // converges (create-time Tags only apply on first create).
309 const currentTags = yield* observedTags(arn);
310 const { upsert, removed } = diffTags(currentTags, desiredTags);
311 if (upsert.length > 0) {
312 yield* chatbot.tagResource({
313 ResourceARN: arn,
314 Tags: upsert.map(({ Key, Value }) => ({
315 TagKey: Key,
316 TagValue: Value,
317 })),
318 });
319 }
320 if (removed.length > 0) {
321 yield* chatbot.untagResource({
322 ResourceARN: arn,
323 TagKeys: removed,
324 });
325 }
326
327 yield* session.note(configurationName);
328 return live !== undefined
329 ? toAttributes(configurationName, live)
330 : {
331 configurationName,
332 chatConfigurationArn: arn,
333 slackTeamId: news.slackTeamId,
334 slackChannelId: news.slackChannelId,
335 slackTeamName: "",
336 state: undefined,
337 };
338 }),
339 delete: Effect.fn(function* ({ output }) {
340 yield* chatbot
341 .deleteSlackChannelConfiguration({
342 ChatConfigurationArn: output.chatConfigurationArn,
343 })
344 .pipe(
345 // Idempotent delete — a missing configuration is not an error.
346 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
347 );
348 }),
349 });
350 }),
351 );