diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ed4a741..ec3962d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- [#206](https://github.com/os2display/display-api-service/pull/206) + - Added support for Notified (Instagram) feed as replacement for SparkleIOFeedType. + - Deprecated SparkleIOFeedType. (getsparkle.io has shut down) + ## [2.0.4] - 2024-04-25 - [#204](https://github.com/os2display/display-api-service/pull/204) diff --git a/fixtures/feed.yaml b/fixtures/feed.yaml index e8d2f68a..763e1e93 100644 --- a/fixtures/feed.yaml +++ b/fixtures/feed.yaml @@ -7,3 +7,12 @@ App\Entity\Tenant\Feed: createdAt (unique): '' modifiedAt: '' id: '' + feed_abc_notified: + feedSource: '@feed_source_abc_notified' + slide: '@slide_abc_notified' + tenant: '@tenant_abc' + createdAt (unique): '' + modifiedAt: '' + id: '' + configuration: + feeds: [12345] diff --git a/fixtures/feed_source.yaml b/fixtures/feed_source.yaml index 67e125ac..541b851e 100644 --- a/fixtures/feed_source.yaml +++ b/fixtures/feed_source.yaml @@ -3,7 +3,7 @@ App\Entity\Tenant\FeedSource: feed (template): description: feedType: "App\\Feed\\RssFeedType" - secrets: [] + secrets: [ ] supportedFeedOutputType: 'rss' createdAt (unique): '' modifiedAt: '' @@ -14,3 +14,10 @@ App\Entity\Tenant\FeedSource: feed_source_xyz_2 (extends feed): title: 'feed_source_xyz_2' tenant: '@tenant_xyz' + feed_source_abc_notified (extends feed): + title: 'feed_source_abc_notified' + feedType: "App\\Feed\\RssFeedType" + secrets: + token: '1234567890' + supportedFeedOutputType: 'instagram' + tenant: '@tenant_abc' diff --git a/fixtures/slide.yaml b/fixtures/slide.yaml index cec3800d..32cb958f 100644 --- a/fixtures/slide.yaml +++ b/fixtures/slide.yaml @@ -13,12 +13,12 @@ App\Entity\Tenant\Slide: theme: '@theme_abc_1' feed: '@feed_abc_1' tenant: '@tenant_abc' - media: [ '@media_abc_*', '@media_abc_*', '@media_abc_*'] + media: [ '@media_abc_*', '@media_abc_*', '@media_abc_*' ] slide_abc_{2..60} (extends slide): title: 'slide_abc_' theme: '@theme_abc_*' tenant: '@tenant_abc' - media: [ '@media_abc_*', '@media_abc_*', '@media_abc_*'] + media: [ '@media_abc_*', '@media_abc_*', '@media_abc_*' ] slide_def_shared_to_abc (extends slide): title: 'slide_def_shared_to_abc' theme: '@theme_def' @@ -28,3 +28,9 @@ App\Entity\Tenant\Slide: title: 'slide_xyz_' theme: '@theme_xyz' tenant: '@tenant_xyz' + slide_abc_notified (extends slide): + title: 'slide_abc_notified' + template: '@template_notified' + content: + maxEntries: 6 + tenant: '@tenant_abc' diff --git a/fixtures/template.yaml b/fixtures/template.yaml index d477fa0b..f35c4801 100644 --- a/fixtures/template.yaml +++ b/fixtures/template.yaml @@ -7,3 +7,10 @@ App\Entity\Template: createdAt (unique): '' modifiedAt: '' id: '' + template_notified: + title: 'template_notified' + description: A template with different that serves notified data + resources: + createdAt (unique): '' + modifiedAt: '' + id: '' diff --git a/src/Feed/NotifiedFeedType.php b/src/Feed/NotifiedFeedType.php new file mode 100644 index 00000000..bfa52e5a --- /dev/null +++ b/src/Feed/NotifiedFeedType.php @@ -0,0 +1,236 @@ +getFeedSource()?->getSecrets(); + if (!isset($secrets['token'])) { + return []; + } + + $configuration = $feed->getConfiguration(); + if (!isset($configuration['feeds']) || 0 === count($configuration['feeds'])) { + return []; + } + + $slide = $feed->getSlide(); + $slideContent = $slide?->getContent(); + + $pageSize = $slideContent['maxEntries'] ?? 10; + + $token = $secrets['token']; + + $data = $this->getMentions($token, $pageSize, $configuration['feeds']); + + return array_map(fn (array $item) => $this->getFeedItemObject($item), $data); + } catch (\Throwable $throwable) { + $this->logger->error('{code}: {message}', [ + 'code' => $throwable->getCode(), + 'message' => $throwable->getMessage(), + ]); + } + + return []; + } + + /** + * {@inheritDoc} + */ + public function getAdminFormOptions(FeedSource $feedSource): array + { + $endpoint = $this->feedService->getFeedSourceConfigUrl($feedSource, 'feeds'); + + // @TODO: Translation. + return [ + [ + 'key' => 'notified-selector', + 'input' => 'multiselect-from-endpoint', + 'endpoint' => $endpoint, + 'name' => 'feeds', + 'label' => 'Vælg feed', + 'helpText' => 'Her vælger du hvilket feed der skal hentes indgange fra.', + 'formGroupClasses' => 'col-md-6 mb-3', + ], + ]; + } + + /** + * {@inheritDoc} + */ + public function getConfigOptions(Request $request, FeedSource $feedSource, string $name): ?array + { + try { + if ('feeds' === $name) { + $secrets = $feedSource->getSecrets(); + + if (!isset($secrets['token'])) { + return []; + } + + $token = $secrets['token']; + + $data = $this->getSearchProfiles($token); + + return array_map(fn (array $item) => [ + 'id' => Ulid::generate(), + 'title' => $item['name'] ?? '', + 'value' => $item['id'] ?? '', + ], $data); + } + } catch (\Throwable $throwable) { + $this->logger->error('{code}: {message}', [ + 'code' => $throwable->getCode(), + 'message' => $throwable->getMessage(), + ]); + } + + return null; + } + + public function getMentions(string $token, int $pageSize = 10, array $searchProfileIds = []): array + { + $body = [ + 'pageSize' => $pageSize, + 'page' => 1, + 'searchProfileIds' => $searchProfileIds, + ]; + + $res = $this->client->request( + 'POST', + self::BASE_URL.'/api/listen/mentions', + [ + 'timeout' => self::REQUEST_TIMEOUT, + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'Notified-Custom-Token' => $token, + ], + 'body' => json_encode($body), + ] + ); + + return $res->toArray(); + } + + public function getSearchProfiles(string $token): array + { + $response = $this->client->request( + 'GET', + self::BASE_URL.'/api/listen/searchprofiles', + [ + 'timeout' => self::REQUEST_TIMEOUT, + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'Notified-Custom-Token' => $token, + ], + ] + ); + + return $response->toArray(); + } + + /** + * {@inheritDoc} + */ + public function getRequiredSecrets(): array + { + return ['token']; + } + + /** + * {@inheritDoc} + */ + public function getRequiredConfiguration(): array + { + return ['feeds']; + } + + /** + * {@inheritDoc} + */ + public function getSupportedFeedOutputType(): string + { + return self::SUPPORTED_FEED_TYPE; + } + + /** + * Parse feed item into object. + */ + private function getFeedItemObject(array $item): array + { + $description = $item['description'] ?? null; + + return [ + 'text' => $description, + 'textMarkup' => null !== $description ? $this->wrapTags($description) : null, + 'mediaUrl' => $item['mediaUrl'] ?? null, + // Video is not supported by the Notified Listen API. + 'videoUrl' => null, + 'username' => $item['sourceName'] ?? null, + 'createdTime' => $item['published'] ?? null, + ]; + } + + private function wrapTags(string $input): string + { + $text = trim($input); + + // Strip unicode zero-width-space. + $text = str_replace("\xE2\x80\x8B", '', $text); + + // Collects trailing tags one by one. + $trailingTags = []; + $pattern = "/\s*#(?[^\s#]+)\n?$/u"; + while (preg_match($pattern, (string) $text, $matches)) { + // We're getting tags in reverse order. + array_unshift($trailingTags, $matches['tag']); + $text = preg_replace($pattern, '', (string) $text); + } + + // Wrap sections in p tags. + $text = preg_replace("/(.+)\n?/u", '

\1

', (string) $text); + + // Wrap inline tags. + $pattern = '/(#(?[^\s#]+))/'; + + return implode('', [ + '
', + preg_replace($pattern, '\1', (string) $text), + '
', + '
', + implode(' ', + array_map(fn ($tag) => '#'.$tag.'', $trailingTags) + ), + '
', + ]); + } +} diff --git a/src/Feed/SparkleIOFeedType.php b/src/Feed/SparkleIOFeedType.php index fc40c4ed..ab52be27 100644 --- a/src/Feed/SparkleIOFeedType.php +++ b/src/Feed/SparkleIOFeedType.php @@ -19,6 +19,7 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +/** @deprecated The SparkleIO service is discontinued. */ class SparkleIOFeedType implements FeedTypeInterface { final public const SUPPORTED_FEED_TYPE = 'instagram'; diff --git a/tests/Api/FeedSourceTest.php b/tests/Api/FeedSourceTest.php index 50457efc..f0e1a000 100644 --- a/tests/Api/FeedSourceTest.php +++ b/tests/Api/FeedSourceTest.php @@ -20,14 +20,14 @@ public function testGetCollection(): void '@context' => '/contexts/FeedSource', '@id' => '/v2/feed-sources', '@type' => 'hydra:Collection', - 'hydra:totalItems' => 1, + 'hydra:totalItems' => 2, 'hydra:view' => [ '@id' => '/v2/feed-sources?itemsPerPage=10', '@type' => 'hydra:PartialCollectionView', ], ]); - $this->assertCount(1, $response->toArray()['hydra:member']); + $this->assertCount(2, $response->toArray()['hydra:member']); } public function testGetItem(): void diff --git a/tests/Api/SlidesTest.php b/tests/Api/SlidesTest.php index d1996a8f..9534461a 100644 --- a/tests/Api/SlidesTest.php +++ b/tests/Api/SlidesTest.php @@ -22,12 +22,12 @@ public function testGetCollection(): void '@context' => '/contexts/Slide', '@id' => '/v2/slides', '@type' => 'hydra:Collection', - 'hydra:totalItems' => 60, + 'hydra:totalItems' => 61, 'hydra:view' => [ '@id' => '/v2/slides?itemsPerPage=10&page=1', '@type' => 'hydra:PartialCollectionView', 'hydra:first' => '/v2/slides?itemsPerPage=10&page=1', - 'hydra:last' => '/v2/slides?itemsPerPage=10&page=6', + 'hydra:last' => '/v2/slides?itemsPerPage=10&page=7', ], ]); diff --git a/tests/Api/TemplatesTest.php b/tests/Api/TemplatesTest.php index 693fbcf1..a1318c06 100644 --- a/tests/Api/TemplatesTest.php +++ b/tests/Api/TemplatesTest.php @@ -19,14 +19,14 @@ public function testGetCollection(): void '@context' => '/contexts/Template', '@id' => '/v2/templates', '@type' => 'hydra:Collection', - 'hydra:totalItems' => 1, + 'hydra:totalItems' => 2, 'hydra:view' => [ '@id' => '/v2/templates?itemsPerPage=5', '@type' => 'hydra:PartialCollectionView', ], ]); - $this->assertCount(1, $response->toArray()['hydra:member']); + $this->assertCount(2, $response->toArray()['hydra:member']); // @TODO: resources: Object value found, but an array is required. In JSON it's an object but in the entity // it's an key array? So this test will fail. diff --git a/tests/Feed/NotifiedFeedTypeData.php b/tests/Feed/NotifiedFeedTypeData.php new file mode 100644 index 00000000..a829c244 --- /dev/null +++ b/tests/Feed/NotifiedFeedTypeData.php @@ -0,0 +1,88 @@ + 12345, + 'name' => 'Test1', + 'accountId' => 12345, + 'categoryId' => 12345, + 'categoryName' => 'Test2', + 'queries' => [ + '("#tag")', + ], + 'selectedMediaTypes' => [ + 'instagram', + ], + ], + [ + 'id' => 12346, + 'name' => 'Test3', + 'accountId' => 12346, + 'categoryId' => 12346, + 'categoryName' => 'Test4', + 'queries' => [ + '("#tag")', + ], + 'selectedMediaTypes' => [ + 'instagram', + ], + ], + ]; + } + + public static function getData(): array + { + return [ + [ + 'externalId' => '1_1111111111111111111', + 'title' => null, + 'description' => "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n\nUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. #tag", + 'sourceName' => 'test', + 'sourceUrl' => 'https://example.com', + 'author' => null, + 'mediaUrl' => '/media/thumbnail_other.png', + 'url' => 'https://example.com', + 'searchProfileId' => 123456, + 'queryString' => '("#tag")', + 'languageCode' => 'da', + 'mediaType' => 'instagram', + 'published' => '2024-04-23T08:00:34', + 'sentiment' => 'positive', + 'commonMetadata' => [ + 'reach' => 0, + 'engagement' => 9, + 'authorImageUrl' => null, + ], + 'articleMetadata' => null, + 'blogMetadata' => null, + 'facebookMetadata' => null, + 'forumMetadata' => null, + 'instagramMetadata' => [ + 'likeCount' => 9, + 'commentCount' => 0, + 'geo' => '0|0', + ], + 'twitterMetadata' => null, + 'youtubeMetadata' => null, + 'rssMetadata' => null, + 'printMetadata' => null, + 'tikTokMetadata' => null, + 'linkedInMetadata' => null, + 'commonLocationData' => [ + 'country' => 'Denmark', + 'countryCode' => 'DK', + 'latitude' => 55.67576, + 'longitude' => 12.56902, + ], + ], + ]; + } +} diff --git a/tests/Feed/NotifiedFeedTypeTest.php b/tests/Feed/NotifiedFeedTypeTest.php new file mode 100644 index 00000000..1469e225 --- /dev/null +++ b/tests/Feed/NotifiedFeedTypeTest.php @@ -0,0 +1,66 @@ +get(FeedSourceRepository::class); + + $feedSource = $feedSourceRepository->findOneBy(['title' => 'feed_source_abc_notified']); + + $feedService = $container->get(FeedService::class); + $logger = $container->get(LoggerInterface::class); + + $httpClientMock = $this->createMock(HttpClientInterface::class); + + $response = $this->createMock(ResponseInterface::class); + $response->method('toArray')->willReturn(NotifiedFeedTypeData::getConfigData()); + + $httpClientMock->method('request')->willReturn($response); + + $notifiedFeedType = new NotifiedFeedType($feedService, $httpClientMock, $logger); + + $feeds = $notifiedFeedType->getConfigOptions(new Request(), $feedSource, 'feeds'); + + $this->assertEquals(12345, $feeds[0]['value']); + $this->assertEquals('Test1', $feeds[0]['title']); + $this->assertEquals(12346, $feeds[1]['value']); + $this->assertEquals('Test3', $feeds[1]['title']); + + $httpClientMock = $this->createMock(HttpClientInterface::class); + + $response = $this->createMock(ResponseInterface::class); + $response->method('toArray')->willReturn( + NotifiedFeedTypeData::getData() + ); + + $httpClientMock->method('request')->willReturn($response); + + $notifiedFeedType = new NotifiedFeedType($feedService, $httpClientMock, $logger); + + $slideRepository = $container->get(SlideRepository::class); + + $slide = $slideRepository->findOneBy(['title' => 'slide_abc_notified']); + + $feed = $slide->getFeed(); + + $data = $notifiedFeedType->getData($feed); + + $this->assertCount(1, $data); + } +} diff --git a/tests/Utils/FeedServiceTest.php b/tests/Utils/FeedServiceTest.php index ad40aaea..d0637a72 100644 --- a/tests/Utils/FeedServiceTest.php +++ b/tests/Utils/FeedServiceTest.php @@ -9,6 +9,7 @@ use App\Feed\EventDatabaseApiFeedType; use App\Feed\FeedTypeInterface; use App\Feed\KobaFeedType; +use App\Feed\NotifiedFeedType; use App\Feed\RssFeedType; use App\Feed\SparkleIOFeedType; use App\Service\FeedService; @@ -36,8 +37,9 @@ public function testGetFeedTypes(): void $feedTypes = $this->feedService->getFeedTypes(); $this->assertEquals(EventDatabaseApiFeedType::class, $feedTypes[0]); $this->assertEquals(KobaFeedType::class, $feedTypes[1]); - $this->assertEquals(RssFeedType::class, $feedTypes[2]); - $this->assertEquals(SparkleIOFeedType::class, $feedTypes[3]); + $this->assertEquals(NotifiedFeedType::class, $feedTypes[2]); + $this->assertEquals(RssFeedType::class, $feedTypes[3]); + $this->assertEquals(SparkleIOFeedType::class, $feedTypes[4]); } public function testGetFeedUrl(): void