1import * as dialogflow from "@distilled.cloud/gcp/dialogflow_v3";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 encodeOwnershipLine,
12 listAgents,
13 listFlows,
14 listTransitionRouteGroups,
15 ownedByAlchemy,
16 ownershipLabels,
17 ownershipText,
18 parseOwnership,
19 parseResourceName,
20 sameJson,
21 sameText,
22 updateMaskOf,
23 retryQuota,
24} from "./internal.ts";
25
26export type TransitionRoute = {
27 /** Intent that triggers this route. */
28 intent?: string;
29 /** Condition expression. */
30 condition?: string;
31 /** Target flow resource name. */
32 targetFlow?: string;
33 /** Target page resource name. */
34 targetPage?: string;
35 /** Description of the route. */
36 description?: string;
37 /** Fulfillment run when the route matches. */
38 triggerFulfillment?: {
39 messages?: Array<{
40 text?: { text?: string[]; allowPlaybackInterruption?: boolean };
41 }>;
42 tag?: string;
43 };
44};
45
46export type AgentsFlowsTransitionRouteGroupProps = {
47 /**
48 * Parent flow resource name
49 * `projects/{project}/locations/{location}/agents/{agent}/flows/{flow}`.
50 * Immutable — changing it replaces the route group.
51 */
52 flow: string;
53 /**
54 * Transition route group id. Server-assigned on create. Immutable —
55 * changing it replaces the route group.
56 */
57 transitionRouteGroupId?: string;
58 /**
59 * Human-readable name, unique within the flow. Route groups have no
60 * labels or description field, so Alchemy stamps ownership into this
61 * field for `list` / nuke.
62 */
63 displayName?: string;
64 /** Transition routes in the group. */
65 transitionRoutes?: TransitionRoute[];
66 /** Language code of the route group. */
67 languageCode?: string;
68};
69
70export type AgentsFlowsTransitionRouteGroup = Resource<
71 "GCP.Dialogflow.AgentsFlowsTransitionRouteGroup",
72 AgentsFlowsTransitionRouteGroupProps,
73 {
74 /** Full resource name. */
75 name: string;
76 /** Route group id (last path segment). */
77 transitionRouteGroupId: string;
78 /** Parent flow resource name. */
79 flow: string;
80 /** Project id. */
81 project: string;
82 /** Location id. */
83 location: string;
84 /** User display name with the Alchemy ownership prefix stripped. */
85 displayName: string | undefined;
86 /** Transition routes. */
87 transitionRoutes: TransitionRoute[];
88 },
89 never,
90 Providers
91>;
92
93/**
94 * A Dialogflow CX transition route group under a flow.
95 *
96 * Route groups have no labels field, so Alchemy stamps ownership into
97 * `displayName` for `list` / nuke. Parent flow and id are immutable.
98 * Display name and routes update in place.
99 *
100 * ### Creating a Route Group
101 * **Example:** Fallback routes
102 * ```typescript
103 * const group = yield* GCP.Dialogflow.AgentsFlowsTransitionRouteGroup(
104 * "Fallback",
105 * {
106 * flow: flow.name,
107 * displayName: "fallback",
108 * transitionRoutes: [
109 * {
110 * condition: "true",
111 * triggerFulfillment: {
112 * messages: [{ text: { text: ["I did not get that."] } }],
113 * },
114 * },
115 * ],
116 * },
117 * );
118 * ```
119 *
120 * @resource
121 * @category Dialogflow
122 */
123export const AgentsFlowsTransitionRouteGroup =
124 Resource<AgentsFlowsTransitionRouteGroup>(
125 "GCP.Dialogflow.AgentsFlowsTransitionRouteGroup",
126 );
127
128export class AgentsFlowsTransitionRouteGroupNotResolved extends Data.TaggedError(
129 "GCP.Dialogflow.AgentsFlowsTransitionRouteGroupNotResolved",
130)<{
131 name: string;
132}> {}
133
134const routesOf = (
135 list:
136 | readonly dialogflow.GoogleCloudDialogflowCxV3TransitionRoute[]
137 | undefined,
138): TransitionRoute[] =>
139 (list ?? []).map((route) => ({
140 intent: route.intent,
141 condition: route.condition,
142 targetFlow: route.targetFlow,
143 targetPage: route.targetPage,
144 description: route.description,
145 triggerFulfillment: route.triggerFulfillment
146 ? {
147 messages: route.triggerFulfillment.messages?.map((message) => ({
148 text: message.text
149 ? {
150 text: [...(message.text.text ?? [])],
151 allowPlaybackInterruption:
152 message.text.allowPlaybackInterruption,
153 }
154 : undefined,
155 })),
156 tag: route.triggerFulfillment.tag,
157 }
158 : undefined,
159 }));
160
161const toAttrs = (
162 group: dialogflow.GoogleCloudDialogflowCxV3TransitionRouteGroup,
163 project: string,
164) => {
165 const name = group.name ?? "";
166 const parsed = parseResourceName(name, "transitionRouteGroups");
167 return {
168 name,
169 transitionRouteGroupId: parsed.id,
170 flow: parsed.flow || parsed.parent,
171 project: parsed.project || project,
172 location: parsed.location,
173 displayName: parseOwnership(group.displayName).text,
174 transitionRoutes: routesOf(group.transitionRoutes),
175 };
176};
177
178const getByName = (name: string) =>
179 name.length === 0
180 ? Effect.succeed(undefined)
181 : dialogflow
182 .getProjectsLocationsAgentsFlowsTransitionRouteGroups({ name })
183 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
184
185const findOwned = (id: string, flow: string, hinted?: string) =>
186 Effect.gen(function* () {
187 if (hinted !== undefined && hinted.length > 0) {
188 const existing = yield* getByName(hinted);
189 if (existing !== undefined) return existing;
190 }
191 const groups = yield* listTransitionRouteGroups(flow);
192 for (const group of groups) {
193 if (yield* ownedByAlchemy(id, ownershipText(group))) return group;
194 }
195 return undefined as
196 | dialogflow.GoogleCloudDialogflowCxV3TransitionRouteGroup
197 | undefined;
198 });
199
200const listOwned = (project: string) =>
201 Effect.gen(function* () {
202 const agents = yield* listAgents(project);
203 const flows = (yield* Effect.forEach(
204 agents,
205 (agent) => (agent.name ? listFlows(agent.name) : Effect.succeed([])),
206 { concurrency: 4 },
207 )).flat();
208 const groups = (yield* Effect.forEach(
209 flows,
210 (flow) =>
211 flow.name ? listTransitionRouteGroups(flow.name) : Effect.succeed([]),
212 { concurrency: 4 },
213 )).flat();
214 return groups
215 .filter(
216 (group) =>
217 parseOwnership(group.displayName).labels["alchemy-id"] !== undefined,
218 )
219 .map((group) => toAttrs(group, project));
220 });
221
222export const AgentsFlowsTransitionRouteGroupProvider = () =>
223 Provider.succeed(AgentsFlowsTransitionRouteGroup, {
224 stables: ["name", "transitionRouteGroupId", "flow", "project", "location"],
225
226 diff: Effect.fn(function* ({ news, olds, output }) {
227 if (!isResolved(news)) return undefined;
228 const previousFlow = olds?.flow ?? output?.flow;
229 const previousId =
230 olds?.transitionRouteGroupId ?? output?.transitionRouteGroupId;
231 if (
232 (previousFlow !== undefined && news.flow !== previousFlow) ||
233 (previousId !== undefined &&
234 news.transitionRouteGroupId !== undefined &&
235 news.transitionRouteGroupId !== previousId)
236 ) {
237 return {
238 action: "replace" as const,
239 deleteFirst:
240 previousFlow === news.flow &&
241 previousId !== undefined &&
242 news.transitionRouteGroupId === previousId,
243 };
244 }
245 return undefined;
246 }),
247
248 read: Effect.fn(function* ({ id, olds, output }) {
249 const env = yield* GcpEnvironment.current;
250 const flow = olds?.flow ?? output?.flow;
251 const existing =
252 output?.name !== undefined
253 ? yield* getByName(output.name)
254 : flow !== undefined
255 ? yield* findOwned(id, flow)
256 : undefined;
257 if (existing === undefined) return undefined;
258 const attrs = toAttrs(existing, env.project);
259 return (yield* ownedByAlchemy(id, ownershipText(existing)))
260 ? attrs
261 : Unowned(attrs);
262 }),
263
264 list: () =>
265 Effect.gen(function* () {
266 const env = yield* GcpEnvironment.current;
267 return yield* listOwned(env.project);
268 }),
269
270 reconcile: Effect.fn(function* ({ id, news, output }) {
271 const env = yield* GcpEnvironment.current;
272 const flow = news.flow;
273 const ownership = yield* ownershipLabels(id);
274 const displayName = encodeOwnershipLine(
275 ownership,
276 news.displayName ?? "routes",
277 );
278 const transitionRoutes = news.transitionRoutes;
279 const body: dialogflow.GoogleCloudDialogflowCxV3TransitionRouteGroup = {
280 displayName,
281 transitionRoutes,
282 };
283
284 let current = yield* findOwned(id, flow, output?.name);
285
286 if (current === undefined) {
287 const created = yield* dialogflow
288 .createProjectsLocationsAgentsFlowsTransitionRouteGroups({
289 parent: flow,
290 languageCode: news.languageCode,
291 body,
292 })
293 .pipe(
294 Effect.catchTag("Conflict", () =>
295 findOwned(id, flow, output?.name),
296 ),
297 );
298 current = created ?? undefined;
299 }
300
301 if (current === undefined) {
302 return yield* new AgentsFlowsTransitionRouteGroupNotResolved({
303 name:
304 output?.name ??
305 `${flow}/transitionRouteGroups/${news.transitionRouteGroupId ?? "unknown"}`,
306 });
307 }
308
309 const currentName = current.name ?? output?.name ?? "";
310 const displayChanged = !sameText(current.displayName, displayName);
311 const routesChanged = !sameJson(
312 routesOf(current.transitionRoutes),
313 routesOf(transitionRoutes),
314 );
315
316 if (displayChanged || routesChanged) {
317 current =
318 yield* dialogflow.patchProjectsLocationsAgentsFlowsTransitionRouteGroups(
319 {
320 name: currentName,
321 languageCode: news.languageCode,
322 updateMask: updateMaskOf(
323 displayChanged ? "display_name" : undefined,
324 routesChanged ? "transition_routes" : undefined,
325 ),
326 body: { ...body, name: currentName },
327 },
328 );
329 }
330
331 return toAttrs(current, env.project);
332 }, retryQuota),
333
334 delete: Effect.fn(function* ({ output }) {
335 yield* dialogflow
336 .deleteProjectsLocationsAgentsFlowsTransitionRouteGroups({
337 name: output.name,
338 force: true,
339 })
340 .pipe(Effect.catchTag("NotFound", () => Effect.void));
341 }, retryQuota),
342 });