1import * as dialogflow from "@distilled.cloud/gcp/dialogflow_v3";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 collectionParent,
13 DEFAULT_LOCATION,
14 encodeOwnershipLine,
15 expandAgent,
16 fingerprint,
17 hasOwnershipMarker,
18 internalLabels,
19 lastSegment,
20 locationOf,
21 namedAgents,
22 normalizeLocation,
23 ownedByAlchemy,
24 parentOf,
25 parseOwnership,
26 projectOf,
27 sameText,
28 toResourceId,
29 updateMaskOf,
30 retryQuota,
31} from "./internal.ts";
32
33export type WebhookHttpMethod =
34 | "HTTP_METHOD_UNSPECIFIED"
35 | "POST"
36 | "GET"
37 | "HEAD"
38 | "PUT"
39 | "DELETE"
40 | "PATCH"
41 | "OPTIONS";
42
43export type WebhookType = "WEBHOOK_TYPE_UNSPECIFIED" | "STANDARD" | "FLEXIBLE";
44
45export type WebhookServiceAgentAuth =
46 | "SERVICE_AGENT_AUTH_UNSPECIFIED"
47 | "NONE"
48 | "ID_TOKEN"
49 | "ACCESS_TOKEN";
50
51export type WebhookOAuthConfig = {
52 /** OAuth token endpoint. */
53 tokenEndpoint?: string;
54 /** OAuth client id. */
55 clientId?: string;
56 /** OAuth client secret. Prefer `secretVersionForClientSecret`. */
57 clientSecret?: string;
58 /** Secret Manager version holding the client secret. */
59 secretVersionForClientSecret?: string;
60 /** OAuth scopes. */
61 scopes?: string[];
62};
63
64export type WebhookGenericWebService = {
65 /** HTTPS URI of the webhook. Required for generic web service. */
66 uri?: string;
67 /** HTTP method. Defaults to POST for STANDARD webhooks. */
68 httpMethod?: WebhookHttpMethod | (string & {});
69 /** Webhook type. */
70 webhookType?: WebhookType | (string & {});
71 /** Request headers. */
72 requestHeaders?: Record<string, string>;
73 /** Secret Manager versions used as request headers. */
74 secretVersionsForRequestHeaders?: Record<
75 string,
76 { secretVersion?: string } | undefined
77 >;
78 /** Allowed CA certificates (DER, base64). */
79 allowedCaCerts?: string[];
80 /** OAuth client-credentials config. */
81 oauthConfig?: WebhookOAuthConfig;
82 /** Auth token minted from the Dialogflow service agent. */
83 serviceAgentAuth?: WebhookServiceAgentAuth | (string & {});
84 /** Service account used to generate ID tokens. */
85 serviceAccountAuthConfig?: { serviceAccount?: string };
86 /** Custom request body (FLEXIBLE webhooks). */
87 requestBody?: string;
88 /** Map webhook response fields onto session parameters (FLEXIBLE). */
89 parameterMapping?: Record<string, string>;
90 /** Secret Manager version holding username:password. */
91 secretVersionForUsernamePassword?: string;
92};
93
94export type WebhookServiceDirectoryConfig = {
95 /** Service Directory service resource name. */
96 service?: string;
97 /** Generic web service settings used with Service Directory. */
98 genericWebService?: WebhookGenericWebService;
99};
100
101export type AgentsWebhookProps = {
102 /**
103 * Parent agent resource name
104 * `projects/{project}/locations/{location}/agents/{agent}` or a bare
105 * agent id (combined with `location`). Immutable — changing it
106 * replaces the webhook.
107 */
108 agent: string;
109 /**
110 * Webhook id (the `{webhook}` segment). Server-assigned on create.
111 * Immutable — changing it replaces the webhook.
112 */
113 webhookId?: string;
114 /**
115 * Location used when `agent` is a bare id.
116 * @default "global"
117 */
118 location?: string;
119 /**
120 * Human-readable name, unique within the agent. Webhooks have no
121 * labels field, so Alchemy stamps ownership into this field for
122 * `list` / nuke.
123 */
124 displayName?: string;
125 /**
126 * Webhook execution timeout (e.g. `"5s"`). Execution fails if
127 * Dialogflow does not receive a response before the timeout.
128 */
129 timeout?: string;
130 /**
131 * When true, the webhook is not called.
132 * @default false
133 */
134 disabled?: boolean;
135 /**
136 * Generic HTTPS webhook configuration. Mutually exclusive with
137 * `serviceDirectory`.
138 */
139 genericWebService?: WebhookGenericWebService;
140 /**
141 * Service Directory webhook configuration. Mutually exclusive with
142 * `genericWebService`.
143 */
144 serviceDirectory?: WebhookServiceDirectoryConfig;
145};
146
147export type AgentsWebhook = Resource<
148 "GCP.Dialogflow.AgentsWebhook",
149 AgentsWebhookProps,
150 {
151 /** Full resource name `.../agents/{agent}/webhooks/{webhook}`. */
152 name: string;
153 /** Webhook id (last path segment). */
154 webhookId: string;
155 /** Parent agent resource name. */
156 agent: string;
157 /** Project id. */
158 project: string;
159 /** Location id. */
160 location: string;
161 /** User display name with the Alchemy ownership prefix stripped. */
162 displayName: string | undefined;
163 /** Execution timeout. */
164 timeout: string | undefined;
165 /** Whether the webhook is disabled. */
166 disabled: boolean;
167 /** Generic HTTPS webhook configuration. */
168 genericWebService: WebhookGenericWebService | undefined;
169 /** Service Directory webhook configuration. */
170 serviceDirectory: WebhookServiceDirectoryConfig | undefined;
171 },
172 never,
173 Providers
174>;
175
176/**
177 * A Dialogflow CX webhook that hosts fulfillment business logic.
178 *
179 * Webhooks have no labels field, so Alchemy stamps ownership into
180 * `displayName` for `list` / nuke. Parent agent and id are immutable.
181 * Display name, timeout, disabled flag, and webhook config update in
182 * place.
183 *
184 * ### Creating a Webhook
185 * **Example:** Generic HTTPS webhook
186 * ```typescript
187 * const webhook = yield* GCP.Dialogflow.AgentsWebhook("Fulfillment", {
188 * agent: agent.name,
189 * displayName: "orders",
190 * genericWebService: { uri: "https://example.com/dialogflow" },
191 * });
192 * ```
193 *
194 * ### Updating a Webhook
195 * Change props on the same logical id; the engine keeps the physical id.
196 *
197 * **Example:** Disable and retarget
198 * ```typescript
199 * const webhook = yield* GCP.Dialogflow.AgentsWebhook("Fulfillment", {
200 * agent: agent.name,
201 * displayName: "orders-v2",
202 * disabled: true,
203 * genericWebService: { uri: "https://example.com/dialogflow-v2" },
204 * });
205 * ```
206 *
207 * @resource
208 * @category Dialogflow
209 */
210export const AgentsWebhook = Resource<AgentsWebhook>(
211 "GCP.Dialogflow.AgentsWebhook",
212);
213
214export class AgentsWebhookNotResolved extends Data.TaggedError(
215 "GCP.Dialogflow.AgentsWebhookNotResolved",
216)<{
217 name: string;
218}> {}
219
220const resourceName = (agent: string, webhookId: string) =>
221 `${agent}/webhooks/${webhookId}`;
222
223const stringMapOf = (
224 value: Record<string, string | undefined> | undefined,
225): Record<string, string> | undefined => {
226 if (value === undefined) return undefined;
227 return Object.fromEntries(
228 Object.entries(value).filter(
229 (entry): entry is [string, string] => entry[1] !== undefined,
230 ),
231 );
232};
233
234const genericOf = (
235 service:
236 | dialogflow.GoogleCloudDialogflowCxV3WebhookGenericWebService
237 | undefined,
238): WebhookGenericWebService | undefined => {
239 if (service === undefined) return undefined;
240 return {
241 uri: service.uri,
242 httpMethod: service.httpMethod,
243 webhookType: service.webhookType,
244 requestHeaders: stringMapOf(service.requestHeaders),
245 secretVersionsForRequestHeaders: service.secretVersionsForRequestHeaders,
246 allowedCaCerts: service.allowedCaCerts
247 ? [...service.allowedCaCerts]
248 : undefined,
249 oauthConfig: service.oauthConfig
250 ? {
251 tokenEndpoint: service.oauthConfig.tokenEndpoint,
252 clientId: service.oauthConfig.clientId,
253 clientSecret: service.oauthConfig.clientSecret,
254 secretVersionForClientSecret:
255 service.oauthConfig.secretVersionForClientSecret,
256 scopes: service.oauthConfig.scopes
257 ? [...service.oauthConfig.scopes]
258 : undefined,
259 }
260 : undefined,
261 serviceAgentAuth: service.serviceAgentAuth,
262 serviceAccountAuthConfig: service.serviceAccountAuthConfig
263 ? { serviceAccount: service.serviceAccountAuthConfig.serviceAccount }
264 : undefined,
265 requestBody: service.requestBody,
266 parameterMapping: stringMapOf(service.parameterMapping),
267 secretVersionForUsernamePassword: service.secretVersionForUsernamePassword,
268 };
269};
270
271const directoryOf = (
272 config:
273 | dialogflow.GoogleCloudDialogflowCxV3WebhookServiceDirectoryConfig
274 | undefined,
275): WebhookServiceDirectoryConfig | undefined => {
276 if (config === undefined) return undefined;
277 return {
278 service: config.service,
279 genericWebService: genericOf(config.genericWebService),
280 };
281};
282
283const toAttrs = (
284 webhook: dialogflow.GoogleCloudDialogflowCxV3Webhook,
285 project: string,
286 agentHint?: string,
287) => {
288 const name = webhook.name ?? "";
289 return {
290 name,
291 webhookId: lastSegment(name),
292 agent: name.includes("/webhooks/")
293 ? collectionParent(name, "agents")
294 : (agentHint ?? parentOf(name)),
295 project: projectOf(name) || project,
296 location: locationOf(name),
297 displayName: parseOwnership(webhook.displayName).text,
298 timeout: webhook.timeout,
299 disabled: webhook.disabled === true,
300 genericWebService: genericOf(webhook.genericWebService),
301 serviceDirectory: directoryOf(webhook.serviceDirectory),
302 };
303};
304
305const getByName = (name: string) =>
306 name.length === 0
307 ? Effect.succeed(undefined)
308 : dialogflow
309 .getProjectsLocationsAgentsWebhooks({ name })
310 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
311
312const listAt = (parent: string, project: string) =>
313 dialogflow.listProjectsLocationsAgentsWebhooks
314 .pages({ parent, pageSize: 100 })
315 .pipe(
316 Stream.flatMap((page) => Stream.fromIterable(page.webhooks ?? [])),
317 Stream.filter((webhook) => hasOwnershipMarker(webhook.displayName)),
318 Stream.map((webhook) => toAttrs(webhook, project, parent)),
319 Stream.runCollect,
320 Effect.map((chunk) => Array.from(chunk)),
321 Effect.catchTag("NotFound", () => Effect.succeed([])),
322 );
323
324const findByDisplayName = (parent: string, displayName: string) =>
325 dialogflow.listProjectsLocationsAgentsWebhooks
326 .pages({ parent, pageSize: 100 })
327 .pipe(
328 Stream.flatMap((page) => Stream.fromIterable(page.webhooks ?? [])),
329 Stream.filter((webhook) => webhook.displayName === displayName),
330 Stream.runHead,
331 Effect.map((option) =>
332 option._tag === "Some" ? option.value : undefined,
333 ),
334 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
335 );
336
337export const AgentsWebhookProvider = () =>
338 Provider.succeed(AgentsWebhook, {
339 stables: ["name", "webhookId", "agent", "project", "location"],
340
341 diff: Effect.fn(function* ({ news, olds, output }) {
342 if (!isResolved(news)) return undefined;
343 const previousAgent = olds?.agent ?? output?.agent;
344 if (previousAgent !== undefined && news.agent !== previousAgent) {
345 return { action: "replace" as const, deleteFirst: false };
346 }
347 const previousId = olds?.webhookId ?? output?.webhookId;
348 if (
349 previousId !== undefined &&
350 news.webhookId !== undefined &&
351 news.webhookId !== previousId
352 ) {
353 return { action: "replace" as const, deleteFirst: false };
354 }
355 const previousLocation = olds?.location ?? output?.location;
356 if (
357 previousLocation !== undefined &&
358 news.location !== undefined &&
359 normalizeLocation(previousLocation) !== normalizeLocation(news.location)
360 ) {
361 return { action: "replace" as const, deleteFirst: false };
362 }
363 return undefined;
364 }),
365
366 read: Effect.fn(function* ({ id, olds, output }) {
367 const env = yield* GcpEnvironment.current;
368 const location = normalizeLocation(olds?.location ?? output?.location);
369 const agent = olds?.agent
370 ? expandAgent(olds.agent, env.project, location)
371 : output?.agent;
372 const webhookId = yield* toResourceId(
373 id,
374 olds?.webhookId,
375 output?.webhookId,
376 );
377 const name =
378 output?.name ??
379 (agent !== undefined ? resourceName(agent, webhookId) : "");
380 let existing = yield* getByName(name);
381 if (existing === undefined && agent !== undefined) {
382 const ownership = yield* internalLabels(id);
383 existing = yield* findByDisplayName(
384 agent,
385 encodeOwnershipLine(ownership, olds?.displayName),
386 );
387 }
388 if (existing === undefined) return undefined;
389 const attrs = toAttrs(existing, env.project, agent);
390 return (yield* ownedByAlchemy(id, existing.displayName))
391 ? attrs
392 : Unowned(attrs);
393 }),
394
395 list: () =>
396 Effect.gen(function* () {
397 const env = yield* GcpEnvironment.current;
398 const agents = yield* namedAgents(env.project);
399 const pages = yield* Effect.forEach(
400 agents,
401 (agent) => listAt(agent.name, env.project),
402 { concurrency: 4 },
403 );
404 return pages.flat();
405 }),
406
407 reconcile: Effect.fn(function* ({ id, news, output }) {
408 const env = yield* GcpEnvironment.current;
409 const location = normalizeLocation(
410 news.location ?? output?.location ?? DEFAULT_LOCATION,
411 );
412 const agent = expandAgent(news.agent, env.project, location);
413 const webhookId = yield* toResourceId(
414 id,
415 news.webhookId,
416 output?.webhookId,
417 );
418 const name = output?.name ?? resourceName(agent, webhookId);
419 const ownership = yield* internalLabels(id);
420 const displayName = encodeOwnershipLine(ownership, news.displayName);
421 const disabled = news.disabled === true;
422 const body: dialogflow.GoogleCloudDialogflowCxV3Webhook = {
423 displayName,
424 timeout: news.timeout,
425 disabled,
426 genericWebService: news.genericWebService,
427 serviceDirectory: news.serviceDirectory,
428 };
429
430 let current = yield* getByName(output?.name ?? name);
431 if (current === undefined) {
432 current = yield* findByDisplayName(agent, displayName);
433 }
434
435 if (current === undefined) {
436 const created = yield* dialogflow
437 .createProjectsLocationsAgentsWebhooks({
438 parent: agent,
439 body,
440 })
441 .pipe(
442 Effect.catchTag("Conflict", () =>
443 findByDisplayName(agent, displayName),
444 ),
445 );
446 current = created ?? undefined;
447 }
448
449 if (current === undefined) {
450 return yield* new AgentsWebhookNotResolved({ name });
451 }
452
453 const currentName = current.name ?? name;
454 const displayChanged = !sameText(current.displayName, displayName);
455 const timeoutChanged = !sameText(current.timeout, news.timeout);
456 const disabledChanged = (current.disabled === true) !== disabled;
457 const genericChanged =
458 fingerprint(genericOf(current.genericWebService)) !==
459 fingerprint(news.genericWebService);
460 const directoryChanged =
461 fingerprint(directoryOf(current.serviceDirectory)) !==
462 fingerprint(news.serviceDirectory);
463
464 if (
465 displayChanged ||
466 timeoutChanged ||
467 disabledChanged ||
468 genericChanged ||
469 directoryChanged
470 ) {
471 current = yield* dialogflow.patchProjectsLocationsAgentsWebhooks({
472 name: currentName,
473 updateMask: updateMaskOf(
474 displayChanged ? "display_name" : undefined,
475 timeoutChanged ? "timeout" : undefined,
476 disabledChanged ? "disabled" : undefined,
477 genericChanged ? "generic_web_service" : undefined,
478 directoryChanged ? "service_directory" : undefined,
479 ),
480 body: { ...body, name: currentName },
481 });
482 }
483
484 return toAttrs(current, env.project, agent);
485 }, retryQuota),
486
487 delete: Effect.fn(function* ({ output }) {
488 yield* dialogflow
489 .deleteProjectsLocationsAgentsWebhooks({
490 name: output.name,
491 force: true,
492 })
493 .pipe(Effect.catchTag("NotFound", () => Effect.void));
494 }, retryQuota),
495 });