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