<?php
/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);
namespace Ibexa\Connect\Ai\EventSubscriber;
use Ibexa\Bundle\ConnectorAi\Form\Data\ActionConfiguration\ActionConfigurationCreateData;
use Ibexa\Bundle\ConnectorAi\Form\Data\ActionConfiguration\ActionConfigurationUpdateData;
use Ibexa\Connect\Ai\ActionHandler\AbstractActionHandler;
use Ibexa\Connect\Resource\Scenario;
use Ibexa\Contracts\Connect\Ai\ActionHandlerDataStructureAwareInterface;
use Ibexa\Contracts\Connect\ConnectClientInterface;
use Ibexa\Contracts\Connect\Resource\DataStructure\DataStructureCreateStruct;
use Ibexa\Contracts\Connect\Resource\Hook\HookCreateStruct;
use Ibexa\Contracts\Connect\Resource\Scenario\ScenarioCreateStruct;
use Ibexa\Contracts\Connect\Response\Hook\CreateResponse;
use Ibexa\Contracts\Connect\Response\Scenario\RetrieveResponse as ScenarioRetrieveResponse;
use Ibexa\Contracts\Connect\Response\Template\BlueprintResponse;
use Ibexa\Contracts\Connect\Response\Template\RetrieveResponse;
use Ibexa\Contracts\Connect\Response\Template\RetrieveResponse as TemplateRetrieveResponse;
use Ibexa\Contracts\ConnectorAi\ActionHandlerRegistryInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
final class ConnectOptionsFormSubscriber implements EventSubscriberInterface
{
private ConnectClientInterface $connectClient;
private SerializerInterface $serializer;
private ActionHandlerRegistryInterface $actionHandlerRegistry;
private ?int $teamId;
public function __construct(
ConnectClientInterface $connectClient,
SerializerInterface $serializer,
ActionHandlerRegistryInterface $actionHandlerRegistry,
?int $teamId = null
) {
$this->connectClient = $connectClient;
$this->serializer = $serializer;
$this->actionHandlerRegistry = $actionHandlerRegistry;
$this->teamId = $teamId;
}
public static function getSubscribedEvents(): array
{
return [
FormEvents::SUBMIT => [
['handleConnectOptions', 10],
],
];
}
/**
* @throws \Ibexa\Contracts\Connect\Exception\BadResponseException
* @throws \Ibexa\Contracts\Connect\Exception\UnserializablePayload
* @throws \Ibexa\Contracts\Connect\Exception\UnserializableResponse
* @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException
*/
public function handleConnectOptions(FormEvent $event): void
{
$data = $event->getData();
$parentForm = $event->getForm()->getParent();
$parentData = $parentForm instanceof FormInterface ? $parentForm->getData() : null;
if (
$this->teamId === null
|| !is_array($data)
|| (
!$parentData instanceof ActionConfigurationCreateData
&& !$parentData instanceof ActionConfigurationUpdateData
)
) {
return;
}
if (
$data[AbstractActionHandler::SCENARIO_ID_OPTION] !== null
&& $data[AbstractActionHandler::TEMPLATE_ID_OPTION] !== null
) {
throw new \RuntimeException('Scenario and template cannot be provided together.');
}
if (
$data[AbstractActionHandler::SCENARIO_ID_OPTION] === null
&& $data[AbstractActionHandler::TEMPLATE_ID_OPTION] === null
) {
return;
}
if ($data[AbstractActionHandler::SCENARIO_ID_OPTION] instanceof ScenarioRetrieveResponse) {
$data = $this->handleScenario($data);
} elseif ($data[AbstractActionHandler::TEMPLATE_ID_OPTION] instanceof TemplateRetrieveResponse) {
$data = $this->handleTemplate($data, $parentData->getActionHandlerIdentifier());
}
$event->setData($data);
}
/**
* @param array<string, mixed> $data
*
* @return array<string, mixed>
*/
private function handleScenario(array $data): array
{
/** @var \Ibexa\Contracts\Connect\Response\Scenario\RetrieveResponse $scenario */
$scenario = $data[AbstractActionHandler::SCENARIO_ID_OPTION];
if ($scenario->getHookId() === null) {
throw new \RuntimeException('Scenario\'s hook cannot be null.');
}
$hook = $this->connectClient
->hooks()
->details($scenario->getHookId());
$data[AbstractActionHandler::WEBHOOK_URL_OPTION] = $hook->getUrl();
$data['scenario_label'] = $scenario->getName();
$data[AbstractActionHandler::SCENARIO_ID_OPTION] = $scenario->getId();
return $data;
}
/**
* @param array<string, mixed> $data
*
* @return array<string, mixed>
*
* @throws \Ibexa\Contracts\Connect\Exception\UnserializableResponse
* @throws \Ibexa\Contracts\Connect\Exception\UnserializablePayload
* @throws \Ibexa\Contracts\Connect\Exception\BadResponseException
* @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException
*/
private function handleTemplate(
array $data,
?string $actionHandlerIdentifier
): array {
assert($this->teamId !== null);
if ($actionHandlerIdentifier === null) {
throw new \RuntimeException('Action handler cannot be null.');
}
$actionHandler = $this->actionHandlerRegistry->hasHandler($actionHandlerIdentifier)
? $this->actionHandlerRegistry->getHandler($actionHandlerIdentifier)
: null;
if (!$actionHandler instanceof ActionHandlerDataStructureAwareInterface) {
throw new \RuntimeException(
sprintf(
'Action handler must be an instance of "%s".',
ActionHandlerDataStructureAwareInterface::class,
),
);
}
/** @var \Ibexa\Contracts\Connect\Response\Template\RetrieveResponse $template */
$template = $data[AbstractActionHandler::TEMPLATE_ID_OPTION];
$blueprint = $this->connectClient
->templates()
->blueprint($template->getId(), true);
$hook = $this->createHookForTemplate($template, $actionHandler);
$innerBlueprint = $this->generateInnerScenarioBlueprint($template, $blueprint, $hook);
$scenarioCreateResponse = $this->connectClient
->scenarios()
->create(
new ScenarioCreateStruct(
$this->teamId,
$innerBlueprint,
$this->serializer->serialize(
$blueprint->getScheduling(),
'json',
[AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS => true],
),
),
);
$this->connectClient->scenarios()->fillInCustomPropertiesData(
$scenarioCreateResponse->getId(),
[Scenario::AI_HANDLER_CUSTOM_PROPERTY => $actionHandlerIdentifier]
);
$data['template_label'] = $template->getName();
$data['scenario_label'] = $scenarioCreateResponse->getName();
$data[AbstractActionHandler::SCENARIO_ID_OPTION] = $scenarioCreateResponse->getId();
$data[AbstractActionHandler::TEMPLATE_ID_OPTION] = $template->getId();
$data[AbstractActionHandler::WEBHOOK_URL_OPTION] = $hook->getUrl();
return $data;
}
private function generateInnerScenarioBlueprint(
RetrieveResponse $template,
BlueprintResponse $blueprint,
CreateResponse $hook
): string {
/** @var object{name?: string} $innerBlueprint */
$innerBlueprint = $blueprint->getBlueprint();
if (
!is_object($innerBlueprint)
|| !property_exists($innerBlueprint, 'flow')
|| !is_array($innerBlueprint->flow)
|| !isset($innerBlueprint->flow[0])
) {
throw new \RuntimeException('Blueprint provided for the template has an invalid format.');
}
$firstModule = $innerBlueprint->flow[0];
assert(is_object($firstModule));
assert(property_exists($firstModule, 'parameters'));
assert(is_object($firstModule->parameters));
$firstModule->parameters->hook = $hook->getId();
$innerBlueprint->flow[0] = $firstModule;
$innerBlueprint->name = $template->getName();
return $this->serializer->serialize(
$innerBlueprint,
'json',
[AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS => true],
);
}
/**
* @throws \Ibexa\Contracts\Connect\Exception\UnserializableResponse
* @throws \Ibexa\Contracts\Connect\Exception\UnserializablePayload
* @throws \Ibexa\Contracts\Connect\Exception\BadResponseException
*/
private function createHookForTemplate(
TemplateRetrieveResponse $template,
ActionHandlerDataStructureAwareInterface $actionHandler
): CreateResponse {
assert($this->teamId !== null);
$dataStructureCreateStruct = new DataStructureCreateStruct(
sprintf('%s-udt-%s', $template->getName(), bin2hex(random_bytes(8))),
$this->teamId,
$actionHandler->getDataStructureSpec(),
);
$dataStructureId = $this->connectClient->dataStructures()
->create($dataStructureCreateStruct)
->getId();
$webHookCreateStruct = new HookCreateStruct(
sprintf('%s-hook', $template->getName()),
$this->teamId,
'gateway-webhook',
$dataStructureId,
);
return $this->connectClient->hooks()
->create($webHookCreateStruct);
}
}