feat: serve chatbot content records at /api/content/records

This commit is contained in:
fromme committed 2026-10-08 09:36:06 +02:00
1 parent 81724c818b
commit 57b09624f4
3 files changed
+665

No files matched your search

@@ -8,6 +8,7 @@ use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* The chatbot content feed's backchannel endpoints under /api/content/.
@@ -23,6 +24,8 @@ class ContentFeedApi
// often it retries. Edits reach the chatbot within about 25 minutes.
private const MANIFEST_CACHE_LIFETIME = 600;
private const MAX_RECORDS_PER_REQUEST = 100;
private const JSON_FLAGS = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
/**
@@ -40,6 +43,11 @@ class ContentFeedApi
*/
private $manifestBuilder;
/**
* @var RecordSerializer
*/
private $recordSerializer;
/**
* @var FrontendInterface
*/
@@ -49,11 +57,13 @@ class ContentFeedApi
ApiKeyAuthenticator $authenticator,
ContentFeedConfiguration $configuration,
ManifestBuilder $manifestBuilder,
RecordSerializer $recordSerializer,
CacheManager $cacheManager
) {
$this->authenticator = $authenticator;
$this->configuration = $configuration;
$this->manifestBuilder = $manifestBuilder;
$this->recordSerializer = $recordSerializer;
$this->cache = $cacheManager->getCache('ep_products_cache');
}
@@ -74,9 +84,47 @@ class ContentFeedApi
return new JsonResponse($this->getManifest(), 200, [], self::JSON_FLAGS);
}
if ('records' === $path) {
return $this->handleRecordsRequest($request);
}
return $this->error('Not found', 404);
}
/**
* Uids that are not (or no longer) eligible are left out rather than answered with an error:
* the manifest may have changed between the consumer's two calls, and it reads a requested
* but absent uid as deleted.
*/
private function handleRecordsRequest(ServerRequestInterface $request): ResponseInterface
{
$type = (string) ($request->getQueryParams()['type'] ?? '');
if (!isset(RecordFinder::TYPES[$type])) {
return $this->error('Unknown type, expected one of: ' . implode(', ', array_keys(RecordFinder::TYPES)), 400);
}
$uids = array_values(array_unique(array_filter(
GeneralUtility::intExplode(',', (string) ($request->getQueryParams()['uids'] ?? ''), true),
function (int $uid): bool {
return $uid > 0;
}
)));
if ([] === $uids) {
return $this->error('No uids given', 400);
}
if (count($uids) > self::MAX_RECORDS_PER_REQUEST) {
return $this->error(sprintf('At most %d uids per request', self::MAX_RECORDS_PER_REQUEST), 400);
}
return new JsonResponse([
'type' => $type,
'items' => $this->recordSerializer->serialize($type, $uids),
], 200, [], self::JSON_FLAGS);
}
private function getManifest(): array
{
$manifest = $this->cache->get(self::MANIFEST_CACHE_IDENTIFIER);
@@ -0,0 +1,513 @@
<?php
namespace EP\EpProducts\ContentFeed;
use EP\EpProducts\Utility\HotelIconUtility;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
/**
* Builds GET /api/content/records: the editorial content of products, hotels, destinations,
* concepts and blog articles. Filter attributes and text only - no dates, prices or images.
* Dates and prices are the consumer's own data; images play no part in a text chatbot.
*
* Rich-text fields go out as stored HTML with `t3://` links resolved; the consumer converts
* them to Markdown together with the rendered pages, so there is one HTML-to-text step.
*
* See .ddev/plans/CHATBOT-MCP-01-TYPO3-CONTENT-FEED.md §5.
*/
class RecordSerializer
{
/**
* @var RecordFinder
*/
private $recordFinder;
/**
* @var UrlResolver
*/
private $urlResolver;
/**
* @var RteLinkResolver
*/
private $rteLinkResolver;
/**
* @var ConnectionPool
*/
private $connectionPool;
public function __construct(
RecordFinder $recordFinder,
UrlResolver $urlResolver,
RteLinkResolver $rteLinkResolver,
ConnectionPool $connectionPool
) {
$this->recordFinder = $recordFinder;
$this->urlResolver = $urlResolver;
$this->rteLinkResolver = $rteLinkResolver;
$this->connectionPool = $connectionPool;
}
/**
* @param int[] $uids
* @return array[] one item per eligible uid; ineligible uids are left out
*/
public function serialize(string $type, array $uids): array
{
$rows = $this->recordFinder->find($type, ['*'], $uids);
if ([] === $rows) {
return [];
}
switch ($type) {
case 'product':
return $this->serializeProducts($rows);
case 'hotel':
return $this->serializeHotels($rows);
case 'news':
return $this->serializeNews($rows);
default:
return array_values(array_map(function (array $row) use ($type): array {
return $this->serializeDestination($type, $row);
}, $rows));
}
}
private function serializeProducts(array $rows): array
{
$uids = array_keys($rows);
$hotels = $this->findMmUids('tx_epproducts_product_hotel_mm', 'uid_local', 'uid_foreign', $uids);
$regions = $this->findMmUids('tx_epproducts_product_region_mm', 'uid_local', 'uid_foreign', $uids);
$faqs = $this->findFaqs($uids);
$faqTimestamps = $this->recordFinder->findFaqTimestamps();
$infoboxes = $this->findInfoboxTexts(array_column($rows, 'non_bookable_info'));
$items = [];
foreach ($rows as $uid => $row) {
$season = trim((string) $row['season']);
$items[] = $this->withLink($row, max((int) $row['tstamp'], $faqTimestamps[$uid] ?? 0)) + [
'code' => $this->text($row['code']),
'busProId' => (int) $row['bus_pro_id'] ?: null,
'name' => $this->text($row['name']),
'subline' => $this->text($row['subline']),
'headline' => $this->text($row['headline']),
'season' => '' === $season ? 'w' : $season,
'daytrip' => (bool) $row['daytrip'],
'bookable' => (bool) $row['bookable'],
'searchable' => (bool) $row['searchable'],
'skipassIncluded' => (bool) $row['skipass_included'],
'conceptUid' => (int) $row['concept'] ?: null,
'countryUid' => (int) $row['country'] ?: null,
'regionUid' => (int) $row['region'] ?: null,
'cityUid' => (int) $row['city'] ?: null,
'additionalRegionUids' => $regions[$uid] ?? [],
'hotelUids' => $hotels[$uid] ?? [],
'keywords' => $this->text($row['keywords']),
'text' => [
'teaser' => $this->html($row['teaser']),
'teaserLong' => $this->html($row['teaser_long']),
'feature' => $this->text($row['feature']),
'conceptDescription' => $this->html($row['concept_description']),
'boardDescription' => $this->html($row['board_description']),
'includedServices' => $this->html($row['included_services']),
'additionalServices' => $this->html($row['additional_services']),
'programmeDescription' => $this->html($row['programme_description']),
'skiPassDescription' => $this->html($row['ski_pass_description']),
'nonBookableInfo' => $infoboxes[(int) $row['non_bookable_info']] ?? null,
],
'faqs' => $faqs[$uid] ?? [],
];
}
return $items;
}
private function serializeHotels(array $rows): array
{
$uids = array_keys($rows);
$aliases = $this->findMatchcodes($uids);
$matchcodeTimestamps = $this->recordFinder->findMatchcodeTimestamps();
$products = $this->findMmUids('tx_epproducts_product_hotel_mm', 'uid_foreign', 'uid_local', $uids, 'sorting_foreign');
$eligibleProducts = $this->recordFinder->find('product', ['uid'], array_values(array_unique(array_merge([], ...array_values($products)))));
$items = [];
foreach ($rows as $uid => $row) {
$code = trim((string) $row['code']);
$codes = array_values(array_unique(array_filter(array_merge([$code], $aliases[$uid] ?? []), 'strlen')));
$items[] = $this->withLink($row, max((int) $row['tstamp'], $matchcodeTimestamps[$uid] ?? 0)) + [
'code' => '' === $code ? null : $code,
'codes' => $codes,
'name' => $this->text($row['name']),
'shortName' => $this->text($row['short_name']),
'headline' => $this->text($row['headline']),
'type' => (int) $row['type'],
'category' => (int) $row['category'],
'new' => (bool) $row['new'],
'earlybird' => (bool) $row['earlybird'],
'countryUid' => (int) $row['country'] ?: null,
'regionUid' => (int) $row['region'] ?: null,
'cityUid' => (int) $row['city'] ?: null,
'productUids' => array_values(array_filter($products[$uid] ?? [], function (int $productUid) use ($eligibleProducts): bool {
return isset($eligibleProducts[$productUid]);
})),
'address' => $this->text($row['address']),
'latitude' => $this->text($row['latitude']),
'longitude' => $this->text($row['longitude']),
'facts' => [
'bedsCount' => (int) $row['beds_count'] ?: null,
'parkingCount' => (int) $row['parking_count'] ?: null,
'altitude' => $this->text($row['altitude']),
'distanceBus' => $this->text($row['distance_bus']),
'distanceLift' => $this->text($row['distance_lift']),
'shower' => $this->text($row['shower']),
'partyroom' => (bool) $row['partyroom'],
'gamesroom' => (bool) $row['gamesroom'],
'sauna' => (bool) $row['sauna'],
'wifi' => (bool) $row['wifi'],
],
'icons' => HotelIconUtility::getIcons($row),
'keywords' => $this->text($row['keywords']),
'text' => [
'teaser' => $this->html($row['teaser']),
'description' => $this->html($row['description']),
'features' => $this->html($row['features']),
'roomTypes' => $this->html($row['room_types']),
'additionalInformation' => $this->html($row['additional_information']),
],
];
}
return $items;
}
private function serializeDestination(string $type, array $row): array
{
$item = $this->withLink($row, (int) $row['tstamp']) + [
'name' => $this->text($row['name']),
'headline' => $this->text($row['headline']),
];
switch ($type) {
case 'region':
return $item + [
'season' => $this->text($row['season']),
'keywords' => $this->text($row['keywords']),
'countryUid' => (int) $row['country'] ?: null,
'facts' => [
'length' => $this->text($row['length']),
'height' => $this->text($row['height']),
'lifts' => $this->text($row['lifts']),
],
'webcam' => $this->findWebcamUrl((int) $row['webcam']),
'text' => [
'teaser' => $this->html($row['teaser']),
'description' => $this->html($row['description']),
'feature' => $this->text($row['feature']),
'skiArea' => $this->html($row['ski_area']),
'skiAreaExtended' => $this->html($row['ski_area_extended']),
'skiPass' => $this->html($row['ski_pass']),
'news' => $this->html($row['news']),
],
];
case 'country':
return $item + [
'code' => $this->text($row['code']),
'season' => $this->text($row['season']),
'keywords' => $this->text($row['keywords']),
'text' => [
'teaser' => $this->html($row['teaser']),
'description' => $this->html($row['description']),
'descriptionExtended' => $this->html($row['description_extended']),
],
];
case 'city':
return $item + [
'season' => $this->text($row['season']),
'keywords' => $this->text($row['keywords']),
'countryUid' => (int) $row['country'] ?: null,
'regionUid' => (int) $row['region'] ?: null,
'text' => [
'teaser' => $this->html($row['teaser']),
'description' => $this->html($row['description']),
'descriptionExtended' => $this->html($row['description_extended']),
'party' => $this->html($row['party']),
'leisure' => $this->html($row['leisure']),
],
];
default: // concept
return $item + [
'code' => $this->text($row['code']),
'text' => [
'teaser' => $this->html($row['teaser']),
'description' => $this->html($row['description']),
'descriptionExtended' => $this->html($row['description_extended']),
'popupText' => $this->html($row['popup_text']),
],
];
}
}
private function serializeNews(array $rows): array
{
$uids = array_keys($rows);
$tags = $this->findNewsTags($uids);
$categories = $this->findNewsCategories($uids);
$items = [];
foreach ($rows as $uid => $row) {
$link = $this->urlResolver->getNewsLink($uid, (int) $row['pid']);
if (null === $link) {
continue;
}
$items[] = [
'uid' => $uid,
'url' => $link['url'],
'site' => $link['site'],
'modified' => date('c', (int) $row['tstamp']),
'title' => $this->text($row['title']),
'teaser' => $this->html($row['teaser']),
'bodytext' => $this->html($row['bodytext']),
'datetime' => (int) $row['datetime'] > 0 ? date('c', (int) $row['datetime']) : null,
'tags' => $tags[$uid] ?? [],
'categories' => $categories[$uid] ?? [],
];
}
return $items;
}
/**
* The identity and link fields every record carries, with the same values as the manifest.
*/
private function withLink(array $row, int $modified): array
{
$link = $this->urlResolver->getRecordLink((string) $row['detail_page']);
return [
'uid' => (int) $row['uid'],
'url' => $link['url'],
'site' => $link['site'],
'modified' => date('c', $modified),
];
}
/**
* @param int[] $uids
* @return array<int, int[]> related uids by $keyField, in $sortField order
*/
private function findMmUids(string $table, string $keyField, string $valueField, array $uids, string $sortField = 'sorting'): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
$rows = $queryBuilder
->select($keyField, $valueField)
->from($table)
->where($queryBuilder->expr()->in($keyField, $queryBuilder->createNamedParameter($uids, Connection::PARAM_INT_ARRAY)))
->orderBy($keyField)
->addOrderBy($sortField)
->execute()
->fetchAllAssociative()
;
$related = [];
foreach ($rows as $row) {
$related[(int) $row[$keyField]][] = (int) $row[$valueField];
}
return $related;
}
/**
* @param int[] $productUids
* @return array<int, array[]> FAQs by product uid, in the order the editor arranged them
*/
private function findFaqs(array $productUids): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_faq');
$rows = $queryBuilder
->select('mm.uid_local AS product', 'faq.question', 'faq.answer', 'faq.category')
->from('tx_epproducts_domain_model_faq', 'faq')
->join('faq', 'tx_epproducts_product_faq_mm', 'mm', 'mm.uid_foreign = faq.uid')
->where($queryBuilder->expr()->in('mm.uid_local', $queryBuilder->createNamedParameter($productUids, Connection::PARAM_INT_ARRAY)))
->orderBy('mm.uid_local')
->addOrderBy('mm.sorting')
->execute()
->fetchAllAssociative()
;
$faqs = [];
foreach ($rows as $row) {
$faqs[(int) $row['product']][] = [
'question' => $this->text($row['question']),
'answer' => $this->html($row['answer']),
'category' => $this->text($row['category']),
];
}
return $faqs;
}
/**
* Matchcode aliases by hotel uid. With the hotel's own code they are every code BusPro may use
* for it - the same set DateImportService::getHotelMappings() resolves.
*
* @param int[] $hotelUids
* @return array<int, string[]>
*/
private function findMatchcodes(array $hotelUids): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_matchcode');
$rows = $queryBuilder
->select('entity', 'code')
->from('tx_epproducts_domain_model_matchcode')
->where(
$queryBuilder->expr()->eq('entity_type', $queryBuilder->createNamedParameter('hotel')),
$queryBuilder->expr()->in('entity', $queryBuilder->createNamedParameter($hotelUids, Connection::PARAM_INT_ARRAY))
)
->orderBy('code')
->execute()
->fetchAllAssociative()
;
$codes = [];
foreach ($rows as $row) {
$codes[(int) $row['entity']][] = trim((string) $row['code']);
}
return $codes;
}
/**
* @param array $infoboxUids
* @return array<int, string|null>
*/
private function findInfoboxTexts(array $infoboxUids): array
{
$infoboxUids = array_values(array_filter(array_map('intval', $infoboxUids)));
if ([] === $infoboxUids) {
return [];
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_infobox');
$rows = $queryBuilder
->select('uid', 'text')
->from('tx_epproducts_domain_model_infobox')
->where($queryBuilder->expr()->in('uid', $queryBuilder->createNamedParameter(array_unique($infoboxUids), Connection::PARAM_INT_ARRAY)))
->execute()
->fetchAllAssociative()
;
$texts = [];
foreach ($rows as $row) {
$texts[(int) $row['uid']] = $this->html($row['text']);
}
return $texts;
}
private function findWebcamUrl(int $webcamUid): ?string
{
if (0 === $webcamUid) {
return null;
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_webcam');
$url = $queryBuilder
->select('url')
->from('tx_epproducts_domain_model_webcam')
->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($webcamUid, \PDO::PARAM_INT)))
->execute()
->fetchOne()
;
return false === $url ? null : $this->text($url);
}
/**
* @param int[] $newsUids
* @return array<int, string[]>
*/
private function findNewsTags(array $newsUids): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_news_domain_model_tag');
$rows = $queryBuilder
->select('mm.uid_local AS news', 'tag.title')
->from('tx_news_domain_model_tag', 'tag')
->join('tag', 'tx_news_domain_model_news_tag_mm', 'mm', 'mm.uid_foreign = tag.uid')
->where($queryBuilder->expr()->in('mm.uid_local', $queryBuilder->createNamedParameter($newsUids, Connection::PARAM_INT_ARRAY)))
->orderBy('mm.sorting')
->execute()
->fetchAllAssociative()
;
$tags = [];
foreach ($rows as $row) {
$tags[(int) $row['news']][] = trim((string) $row['title']);
}
return $tags;
}
/**
* @param int[] $newsUids
* @return array<int, string[]>
*/
private function findNewsCategories(array $newsUids): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_category');
$rows = $queryBuilder
->select('mm.uid_foreign AS news', 'category.title')
->from('sys_category', 'category')
->join('category', 'sys_category_record_mm', 'mm', 'mm.uid_local = category.uid')
->where(
$queryBuilder->expr()->eq('mm.tablenames', $queryBuilder->createNamedParameter('tx_news_domain_model_news')),
$queryBuilder->expr()->eq('mm.fieldname', $queryBuilder->createNamedParameter('categories')),
$queryBuilder->expr()->in('mm.uid_foreign', $queryBuilder->createNamedParameter($newsUids, Connection::PARAM_INT_ARRAY))
)
->orderBy('mm.sorting_foreign')
->execute()
->fetchAllAssociative()
;
$categories = [];
foreach ($rows as $row) {
$categories[(int) $row['news']][] = trim((string) $row['title']);
}
return $categories;
}
/**
* Rich text as stored, with internal links made absolute.
*/
private function html($value): ?string
{
$value = trim((string) $value);
return '' === $value ? null : $this->rteLinkResolver->resolve($value);
}
/**
* Plain text with whitespace collapsed. Product names carry literal line breaks the website
* splits on for layout ("Apartments \nVal Thorens"); the chatbot needs them as one line.
*/
private function text($value): ?string
{
$value = trim((string) preg_replace('/\s+/u', ' ', (string) $value));
return '' === $value ? null : $value;
}
}
@@ -0,0 +1,104 @@
<?php
namespace EP\EpProducts\ContentFeed;
use TYPO3\CMS\Core\LinkHandling\LinkService;
use TYPO3\CMS\Core\Resource\File;
/**
* Rewrites `t3://` links in stored RTE HTML to absolute URLs.
*
* parseFunc_RTE would do this, but it needs a TSFE and the API middleware runs before one exists.
* Only links are touched; the rest of the HTML is passed on as stored. A link that cannot be
* resolved to something public (a page outside the feed's sites, a deleted page) loses its href
* and keeps its text.
*/
class RteLinkResolver
{
/**
* @var LinkService
*/
private $linkService;
/**
* @var UrlResolver
*/
private $urlResolver;
public function __construct(LinkService $linkService, UrlResolver $urlResolver)
{
$this->linkService = $linkService;
$this->urlResolver = $urlResolver;
}
public function resolve(string $html): string
{
if (false === strpos($html, 't3://')) {
return $html;
}
return (string) preg_replace_callback(
'/\shref=(["\'])(t3:\/\/[^"\']*)\1/i',
function (array $matches): string {
$url = $this->resolveLink(html_entity_decode($matches[2], ENT_QUOTES | ENT_HTML5));
return null === $url ? '' : ' href="' . htmlspecialchars($url) . '"';
},
$html
);
}
private function resolveLink(string $link): ?string
{
try {
$data = $this->linkService->resolve($link);
} catch (\Throwable $e) {
return null;
}
if (LinkService::TYPE_PAGE === ($data['type'] ?? null)) {
$pageUid = $this->urlResolver->parsePageReference('t3://page?uid=' . ($data['pageuid'] ?? ''));
$target = null === $pageUid ? null : $this->urlResolver->resolveLinkTarget($pageUid);
$url = null === $target ? null : $this->urlResolver->getPageUrl($target);
if (null !== $url && '' !== (string) ($data['fragment'] ?? '')) {
$url .= '#' . $data['fragment'];
}
return $url;
}
if (LinkService::TYPE_FILE === ($data['type'] ?? null) && ($data['file'] ?? null) instanceof File) {
return $this->absolute((string) $data['file']->getPublicUrl());
}
if (LinkService::TYPE_URL === ($data['type'] ?? null)) {
return (string) $data['url'];
}
return null;
}
/**
* File URLs come back relative to the document root; the consumer has no page to resolve
* them against for record text, so make them absolute against the main site.
*/
private function absolute(string $url): ?string
{
if ('' === $url) {
return null;
}
if (1 === preg_match('/^https?:\/\//', $url)) {
return $url;
}
$sites = $this->urlResolver->getIncludedSites();
if ([] === $sites) {
return null;
}
return rtrim((string) $sites[0]->getBase(), '/') . '/' . ltrim($url, '/');
}
}