vendor/ibexa/connect/src/lib/Ai/EventSubscriber/ConnectOptionsFormSubscriber.php line 70

Open in your IDE?
  1. <?php
  2. /**
  3. * @copyright Copyright (C) Ibexa AS. All rights reserved.
  4. * @license For full copyright and license information view LICENSE file distributed with this source code.
  5. */
  6. declare(strict_types=1);
  7. namespace Ibexa\Connect\Ai\EventSubscriber;
  8. use Ibexa\Bundle\ConnectorAi\Form\Data\ActionConfiguration\ActionConfigurationCreateData;
  9. use Ibexa\Bundle\ConnectorAi\Form\Data\ActionConfiguration\ActionConfigurationUpdateData;
  10. use Ibexa\Connect\Ai\ActionHandler\AbstractActionHandler;
  11. use Ibexa\Connect\Resource\Scenario;
  12. use Ibexa\Contracts\Connect\Ai\ActionHandlerDataStructureAwareInterface;
  13. use Ibexa\Contracts\Connect\ConnectClientInterface;
  14. use Ibexa\Contracts\Connect\Resource\DataStructure\DataStructureCreateStruct;
  15. use Ibexa\Contracts\Connect\Resource\Hook\HookCreateStruct;
  16. use Ibexa\Contracts\Connect\Resource\Scenario\ScenarioCreateStruct;
  17. use Ibexa\Contracts\Connect\Response\Hook\CreateResponse;
  18. use Ibexa\Contracts\Connect\Response\Scenario\RetrieveResponse as ScenarioRetrieveResponse;
  19. use Ibexa\Contracts\Connect\Response\Template\BlueprintResponse;
  20. use Ibexa\Contracts\Connect\Response\Template\RetrieveResponse;
  21. use Ibexa\Contracts\Connect\Response\Template\RetrieveResponse as TemplateRetrieveResponse;
  22. use Ibexa\Contracts\ConnectorAi\ActionHandlerRegistryInterface;
  23. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  24. use Symfony\Component\Form\FormEvent;
  25. use Symfony\Component\Form\FormEvents;
  26. use Symfony\Component\Form\FormInterface;
  27. use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
  28. use Symfony\Component\Serializer\SerializerInterface;
  29. final class ConnectOptionsFormSubscriber implements EventSubscriberInterface
  30. {
  31. private ConnectClientInterface $connectClient;
  32. private SerializerInterface $serializer;
  33. private ActionHandlerRegistryInterface $actionHandlerRegistry;
  34. private ?int $teamId;
  35. public function __construct(
  36. ConnectClientInterface $connectClient,
  37. SerializerInterface $serializer,
  38. ActionHandlerRegistryInterface $actionHandlerRegistry,
  39. ?int $teamId = null
  40. ) {
  41. $this->connectClient = $connectClient;
  42. $this->serializer = $serializer;
  43. $this->actionHandlerRegistry = $actionHandlerRegistry;
  44. $this->teamId = $teamId;
  45. }
  46. public static function getSubscribedEvents(): array
  47. {
  48. return [
  49. FormEvents::SUBMIT => [
  50. ['handleConnectOptions', 10],
  51. ],
  52. ];
  53. }
  54. /**
  55. * @throws \Ibexa\Contracts\Connect\Exception\BadResponseException
  56. * @throws \Ibexa\Contracts\Connect\Exception\UnserializablePayload
  57. * @throws \Ibexa\Contracts\Connect\Exception\UnserializableResponse
  58. * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException
  59. */
  60. public function handleConnectOptions(FormEvent $event): void
  61. {
  62. $data = $event->getData();
  63. $parentForm = $event->getForm()->getParent();
  64. $parentData = $parentForm instanceof FormInterface ? $parentForm->getData() : null;
  65. if (
  66. $this->teamId === null
  67. || !is_array($data)
  68. || (
  69. !$parentData instanceof ActionConfigurationCreateData
  70. && !$parentData instanceof ActionConfigurationUpdateData
  71. )
  72. ) {
  73. return;
  74. }
  75. if (
  76. $data[AbstractActionHandler::SCENARIO_ID_OPTION] !== null
  77. && $data[AbstractActionHandler::TEMPLATE_ID_OPTION] !== null
  78. ) {
  79. throw new \RuntimeException('Scenario and template cannot be provided together.');
  80. }
  81. if (
  82. $data[AbstractActionHandler::SCENARIO_ID_OPTION] === null
  83. && $data[AbstractActionHandler::TEMPLATE_ID_OPTION] === null
  84. ) {
  85. return;
  86. }
  87. if ($data[AbstractActionHandler::SCENARIO_ID_OPTION] instanceof ScenarioRetrieveResponse) {
  88. $data = $this->handleScenario($data);
  89. } elseif ($data[AbstractActionHandler::TEMPLATE_ID_OPTION] instanceof TemplateRetrieveResponse) {
  90. $data = $this->handleTemplate($data, $parentData->getActionHandlerIdentifier());
  91. }
  92. $event->setData($data);
  93. }
  94. /**
  95. * @param array<string, mixed> $data
  96. *
  97. * @return array<string, mixed>
  98. */
  99. private function handleScenario(array $data): array
  100. {
  101. /** @var \Ibexa\Contracts\Connect\Response\Scenario\RetrieveResponse $scenario */
  102. $scenario = $data[AbstractActionHandler::SCENARIO_ID_OPTION];
  103. if ($scenario->getHookId() === null) {
  104. throw new \RuntimeException('Scenario\'s hook cannot be null.');
  105. }
  106. $hook = $this->connectClient
  107. ->hooks()
  108. ->details($scenario->getHookId());
  109. $data[AbstractActionHandler::WEBHOOK_URL_OPTION] = $hook->getUrl();
  110. $data['scenario_label'] = $scenario->getName();
  111. $data[AbstractActionHandler::SCENARIO_ID_OPTION] = $scenario->getId();
  112. return $data;
  113. }
  114. /**
  115. * @param array<string, mixed> $data
  116. *
  117. * @return array<string, mixed>
  118. *
  119. * @throws \Ibexa\Contracts\Connect\Exception\UnserializableResponse
  120. * @throws \Ibexa\Contracts\Connect\Exception\UnserializablePayload
  121. * @throws \Ibexa\Contracts\Connect\Exception\BadResponseException
  122. * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException
  123. */
  124. private function handleTemplate(
  125. array $data,
  126. ?string $actionHandlerIdentifier
  127. ): array {
  128. assert($this->teamId !== null);
  129. if ($actionHandlerIdentifier === null) {
  130. throw new \RuntimeException('Action handler cannot be null.');
  131. }
  132. $actionHandler = $this->actionHandlerRegistry->hasHandler($actionHandlerIdentifier)
  133. ? $this->actionHandlerRegistry->getHandler($actionHandlerIdentifier)
  134. : null;
  135. if (!$actionHandler instanceof ActionHandlerDataStructureAwareInterface) {
  136. throw new \RuntimeException(
  137. sprintf(
  138. 'Action handler must be an instance of "%s".',
  139. ActionHandlerDataStructureAwareInterface::class,
  140. ),
  141. );
  142. }
  143. /** @var \Ibexa\Contracts\Connect\Response\Template\RetrieveResponse $template */
  144. $template = $data[AbstractActionHandler::TEMPLATE_ID_OPTION];
  145. $blueprint = $this->connectClient
  146. ->templates()
  147. ->blueprint($template->getId(), true);
  148. $hook = $this->createHookForTemplate($template, $actionHandler);
  149. $innerBlueprint = $this->generateInnerScenarioBlueprint($template, $blueprint, $hook);
  150. $scenarioCreateResponse = $this->connectClient
  151. ->scenarios()
  152. ->create(
  153. new ScenarioCreateStruct(
  154. $this->teamId,
  155. $innerBlueprint,
  156. $this->serializer->serialize(
  157. $blueprint->getScheduling(),
  158. 'json',
  159. [AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS => true],
  160. ),
  161. ),
  162. );
  163. $this->connectClient->scenarios()->fillInCustomPropertiesData(
  164. $scenarioCreateResponse->getId(),
  165. [Scenario::AI_HANDLER_CUSTOM_PROPERTY => $actionHandlerIdentifier]
  166. );
  167. $data['template_label'] = $template->getName();
  168. $data['scenario_label'] = $scenarioCreateResponse->getName();
  169. $data[AbstractActionHandler::SCENARIO_ID_OPTION] = $scenarioCreateResponse->getId();
  170. $data[AbstractActionHandler::TEMPLATE_ID_OPTION] = $template->getId();
  171. $data[AbstractActionHandler::WEBHOOK_URL_OPTION] = $hook->getUrl();
  172. return $data;
  173. }
  174. private function generateInnerScenarioBlueprint(
  175. RetrieveResponse $template,
  176. BlueprintResponse $blueprint,
  177. CreateResponse $hook
  178. ): string {
  179. /** @var object{name?: string} $innerBlueprint */
  180. $innerBlueprint = $blueprint->getBlueprint();
  181. if (
  182. !is_object($innerBlueprint)
  183. || !property_exists($innerBlueprint, 'flow')
  184. || !is_array($innerBlueprint->flow)
  185. || !isset($innerBlueprint->flow[0])
  186. ) {
  187. throw new \RuntimeException('Blueprint provided for the template has an invalid format.');
  188. }
  189. $firstModule = $innerBlueprint->flow[0];
  190. assert(is_object($firstModule));
  191. assert(property_exists($firstModule, 'parameters'));
  192. assert(is_object($firstModule->parameters));
  193. $firstModule->parameters->hook = $hook->getId();
  194. $innerBlueprint->flow[0] = $firstModule;
  195. $innerBlueprint->name = $template->getName();
  196. return $this->serializer->serialize(
  197. $innerBlueprint,
  198. 'json',
  199. [AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS => true],
  200. );
  201. }
  202. /**
  203. * @throws \Ibexa\Contracts\Connect\Exception\UnserializableResponse
  204. * @throws \Ibexa\Contracts\Connect\Exception\UnserializablePayload
  205. * @throws \Ibexa\Contracts\Connect\Exception\BadResponseException
  206. */
  207. private function createHookForTemplate(
  208. TemplateRetrieveResponse $template,
  209. ActionHandlerDataStructureAwareInterface $actionHandler
  210. ): CreateResponse {
  211. assert($this->teamId !== null);
  212. $dataStructureCreateStruct = new DataStructureCreateStruct(
  213. sprintf('%s-udt-%s', $template->getName(), bin2hex(random_bytes(8))),
  214. $this->teamId,
  215. $actionHandler->getDataStructureSpec(),
  216. );
  217. $dataStructureId = $this->connectClient->dataStructures()
  218. ->create($dataStructureCreateStruct)
  219. ->getId();
  220. $webHookCreateStruct = new HookCreateStruct(
  221. sprintf('%s-hook', $template->getName()),
  222. $this->teamId,
  223. 'gateway-webhook',
  224. $dataStructureId,
  225. );
  226. return $this->connectClient->hooks()
  227. ->create($webHookCreateStruct);
  228. }
  229. }