diff --git a/.env.dist b/.env.dist index f490d0c18..8bfb512bb 100644 --- a/.env.dist +++ b/.env.dist @@ -28,6 +28,9 @@ TWITTER_OAUTH_ACCESS_TOKEN_SECRET= TWITTER_CONSUMER_KEY= TWITTER_CONSUMER_SECRET= +BLUESKY_API_IDENTIFIER= +BLUESKY_API_APP_PASSWORD= + MAILCHIMP_API_KEY=xxx-yyyy MAILCHIMP_MEMBERS_LIST= MAILCHIMP_SUBSCRIBERS_LIST= diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 998918612..b1e61b481 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -9,6 +9,7 @@ __DIR__ . '/sources', __DIR__ . '/tests', ]) + ->exclude('cache/templates') ; return (new PhpCsFixer\Config()) diff --git a/app/config/services.yml b/app/config/services.yml index 76d3e376b..fc3099ed7 100644 --- a/app/config/services.yml +++ b/app/config/services.yml @@ -9,6 +9,8 @@ parameters: app.badge_dir: "%kernel.project_dir%/htdocs/uploads/badges" app.members_logo_dir: "%kernel.project_dir%/htdocs/uploads/members_logo" app.general_meetings_dir: "%kernel.project_dir%/htdocs/uploads/general_meetings_reports" + bluesky.api.identifier: "%bluesky_api_identifier%" + bluesky.api.app_password: "%bluesky_api_app_password%" services: # service_name: @@ -17,6 +19,10 @@ services: _defaults: public: true + _instanceof: + AppBundle\SocialNetwork\Transport: + tags: [ 'app.social_network.transport' ] + Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler: public: false arguments: @@ -282,6 +288,11 @@ services: factory: ["@ting", get] arguments: [AppBundle\Association\Model\Repository\CompanyMemberRepository] + AppBundle\Event\Model\Repository\PlanningRepository: + class: AppBundle\Event\Model\Repository\PlanningRepository + factory: [ "@ting", get ] + arguments: [ AppBundle\Event\Model\Repository\PlanningRepository ] + AppBundle\Security\LegacyAuthenticator: autowire: true @@ -740,3 +751,28 @@ services: arguments: $httpClient: '@app.meetup.http_client' $antennesCollection: '@AppBundle\Antennes\AntennesCollection' + + app.bluesky.http_client: + class: GuzzleHttp\Client + arguments: + $config: + base_uri: https://bsky.social/ + + AppBundle\SocialNetwork\Bluesky\BlueskyTransport: + arguments: + $client: '@app.bluesky.http_client' + $apiIdentifier: '%bluesky.api.identifier%' + $apiAppPassword: '%bluesky.api.app_password%' + + AppBundle\VideoNotifier\HistoryRepository: + arguments: + $connection: '@Doctrine\DBAL\Connection' + + AppBundle\VideoNotifier\Engine: + arguments: + $transports: !tagged_iterator app.social_network.transport + $planningRepository: '@AppBundle\Event\Model\Repository\PlanningRepository' + $talkRepository: '@AppBundle\Event\Model\Repository\TalkRepository' + $eventRepository: '@AppBundle\Event\Model\Repository\EventRepository' + $speakerRepository: '@AppBundle\Event\Model\Repository\SpeakerRepository' + $historyRepository: '@AppBundle\VideoNotifier\HistoryRepository' diff --git a/composer.json b/composer.json index 6248a3f46..4d8c28f8a 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,7 @@ "knpuniversity/oauth2-client-bundle": "^1.4", "league/iso3166": "^4.0", "league/oauth2-github": "^0.2.1", + "myclabs/php-enum": "^1.8", "nojimage/twitter-text-php": "1.1.*", "pacely/mailchimp-apiv3": "^1.0", "pear/pear": "^1.10", @@ -40,6 +41,8 @@ "setasign/fpdf": "^1.8", "smarty/smarty": "^5.4", "symfony/monolog-bundle": "*", + "symfony/polyfill-php80": "^1.31", + "symfony/string": "^5.4", "symfony/symfony": "^4.4", "twig/extensions": "^1.4", "willdurand/geocoder": "^3.3", diff --git a/composer.lock b/composer.lock index 04eef154f..1b6c1f414 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "562d751636c429107d15424f0f988736", + "content-hash": "ba2de68cb4c28611eea26e2d8011b039", "packages": [ { "name": "algolia/algoliasearch-client-php", @@ -5220,6 +5220,84 @@ ], "time": "2024-09-09T11:45:10+00:00" }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, { "name": "symfony/polyfill-intl-icu", "version": "v1.31.0", @@ -5704,6 +5782,92 @@ ], "time": "2024-09-09T11:45:10+00:00" }, + { + "name": "symfony/string", + "version": "v5.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "136ca7d72f72b599f2631aca474a4f8e26719799" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/136ca7d72f72b599f2631aca474a4f8e26719799", + "reference": "136ca7d72f72b599f2631aca474a4f8e26719799", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php80": "~1.15" + }, + "conflict": { + "symfony/translation-contracts": ">=3.0" + }, + "require-dev": { + "symfony/error-handler": "^4.4|^5.0|^6.0", + "symfony/http-client": "^4.4|^5.0|^6.0", + "symfony/translation-contracts": "^1.1|^2", + "symfony/var-exporter": "^4.4|^5.0|^6.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v5.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-11-10T20:33:58+00:00" + }, { "name": "symfony/symfony", "version": "v4.4.51", diff --git a/db/migrations/20250228163339_create_video_notifier_history_table.php b/db/migrations/20250228163339_create_video_notifier_history_table.php new file mode 100644 index 000000000..43111a2ec --- /dev/null +++ b/db/migrations/20250228163339_create_video_notifier_history_table.php @@ -0,0 +1,28 @@ +table('video_notifier_history') + ->addColumn('talk_id', 'integer') + ->addColumn('status_id_bluesky', 'string', [ + 'limit' => 30, + 'null' => true, + ]) + ->addColumn('status_id_mastodon', 'string', [ + 'limit' => 30, + 'null' => true, + ]) + ->addColumn('created_at', 'timestamp', [ + 'default' => 'CURRENT_TIMESTAMP', + 'update' => '' + ]) + ->create(); + } +} diff --git a/sources/AppBundle/Command/VideoNotifierCommand.php b/sources/AppBundle/Command/VideoNotifierCommand.php new file mode 100644 index 000000000..fa899c646 --- /dev/null +++ b/sources/AppBundle/Command/VideoNotifierCommand.php @@ -0,0 +1,36 @@ +engine = $engine; + } + + protected function configure(): void + { + $this + ->setName('plop') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->engine->run(); + + return 0; + } +} diff --git a/sources/AppBundle/Event/Model/Repository/PlanningRepository.php b/sources/AppBundle/Event/Model/Repository/PlanningRepository.php index 6217b5fb8..a5cc2a6e6 100644 --- a/sources/AppBundle/Event/Model/Repository/PlanningRepository.php +++ b/sources/AppBundle/Event/Model/Repository/PlanningRepository.php @@ -13,6 +13,9 @@ use CCMBenchmark\Ting\Repository\Repository; use CCMBenchmark\Ting\Serializer\SerializerFactoryInterface; +/** + * @extends Repository + */ class PlanningRepository extends Repository implements MetadataInitializer { /** diff --git a/sources/AppBundle/Event/Model/Repository/SpeakerRepository.php b/sources/AppBundle/Event/Model/Repository/SpeakerRepository.php index 0e7b2ee6f..46faa47f1 100644 --- a/sources/AppBundle/Event/Model/Repository/SpeakerRepository.php +++ b/sources/AppBundle/Event/Model/Repository/SpeakerRepository.php @@ -28,7 +28,7 @@ class SpeakerRepository extends Repository implements MetadataInitializer public function getSpeakersByTalk(Talk $talk) { $query = $this->getPreparedQuery('SELECT c.conferencier_id, c.id_forum, c.civilite, c.nom, c.prenom, c.email,c.societe, - c.biographie, c.twitter, c.user_github, c.photo + c.biographie, c.twitter, c.user_github, c.photo, c.bluesky, c.mastodon FROM afup_conferenciers c LEFT JOIN afup_conferenciers_sessions cs ON cs.conferencier_id = c.conferencier_id WHERE cs.session_id = :talkId diff --git a/sources/AppBundle/SocialNetwork/Bluesky/BlueskyPolyfill.php b/sources/AppBundle/SocialNetwork/Bluesky/BlueskyPolyfill.php new file mode 100644 index 000000000..a78d7f844 --- /dev/null +++ b/sources/AppBundle/SocialNetwork/Bluesky/BlueskyPolyfill.php @@ -0,0 +1,101 @@ +client = $client; + } + + public function parseFacets(string $input): array + { + $facets = []; + $text = new ByteString($input); + + // regex based on: https://bluesky.com/specs/handle#handle-identifier-syntax + $regex = '#[$|\W](@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)#'; + foreach ($this->getMatchAndPosition($text, $regex) as $match) { + try { + $response = $this->client->request('GET', '/xrpc/com.atproto.identity.resolveHandle', [ + 'query' => [ + 'handle' => ltrim($match['match'], '@'), + ], + ]); + } catch (GuzzleException $e) { + continue; + } + + if ($response->getStatusCode() !== 200) { + continue; + } + + $data = json_decode($response->getBody()->getContents(), true); + + $did = $data['did'] ?? null; + if (null === $did) { + continue; + } + + $facets[] = [ + 'index' => [ + 'byteStart' => $match['start'], + 'byteEnd' => $match['end'], + ], + 'features' => [ + [ + '$type' => 'app.bsky.richtext.facet#mention', + 'did' => $did, + ], + ], + ]; + } + + return $facets; + } + + private function getMatchAndPosition(AbstractString $text, string $regex): array + { + $output = []; + $handled = []; + $matches = $text->match($regex, \PREG_PATTERN_ORDER); + if ([] === $matches) { + return $output; + } + + $length = $text->length(); + foreach ($matches[1] as $match) { + if (isset($handled[$match])) { + continue; + } + $handled[$match] = true; + $end = -1; + while (null !== $start = $text->indexOf($match, min($length, $end + 1))) { + $output[] = [ + 'start' => $start, + 'end' => $end = $start + (new ByteString($match))->length(), + 'match' => $match, + ]; + } + } + + return $output; + } +} diff --git a/sources/AppBundle/SocialNetwork/Bluesky/BlueskyTransport.php b/sources/AppBundle/SocialNetwork/Bluesky/BlueskyTransport.php new file mode 100644 index 000000000..e09a465a2 --- /dev/null +++ b/sources/AppBundle/SocialNetwork/Bluesky/BlueskyTransport.php @@ -0,0 +1,166 @@ +client = $client; + $this->apiIdentifier = $apiIdentifier; + $this->apiAppPassword = $apiAppPassword; + } + + public function socialNetwork(): SocialNetwork + { + return SocialNetwork::Bluesky(); + } + + public function send(Status $status): ?StatusId + { + $record = [ + '$type' => 'app.bsky.feed.post', + 'text' => $status->text, + 'createdAt' => date('Y-m-d\\TH:i:s.u\\Z'), + ]; + + if ($status->embed !== null) { + $record['embed'] = $this->buildEmbed($status->embed); + } + + $facets = (new BlueskyPolyfill($this->client))->parseFacets($status->text); + + if ($facets !== []) { + $record['facets'] = $facets; + } + + $response = $this->client->request('POST', '/xrpc/com.atproto.repo.createRecord', [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $this->session()->accessJwt, + ], + 'json' => [ + 'collection' => 'app.bsky.feed.post', + 'repo' => $this->session()->did, + 'record' => $record, + ], + ]); + + $uri = json_decode($response->getBody()->getContents(), true)['uri'] ?? null; + + if ($uri !== null) { + return new StatusId($this->extractPostId($uri)); + } + + return null; + } + + private function session(): Session + { + if ($this->session === null) { + $response = $this->client->request('POST', '/xrpc/com.atproto.server.createSession', [ + 'json' => [ + 'identifier' => $this->apiIdentifier, + 'password' => $this->apiAppPassword, + ], + ]); + + $this->session = (new MapperBuilder()) + ->allowSuperfluousKeys() + ->mapper() + ->map(Session::class, Source::json($response->getBody()->getContents())); + } + + return $this->session; + } + + // Un embed est ce qui permet d'avoir un cadre sous le texte du status avec un titre, une description et + // éventuellement une image. + private function buildEmbed(Embed $embedData): array + { + $embed = [ + '$type' => 'app.bsky.embed.external', + 'external' => [ + 'uri' => $embedData->url, + 'title' => $embedData->title, + 'description' => $embedData->abstract, + ], + ]; + + if ($embedData->imageUrl !== null) { + $thumbnail = $this->buildThumbnail($embedData->imageUrl); + + if ($thumbnail !== null) { + $embed['external']['thumb'] = $thumbnail; + } + } + + return $embed; + } + + /** + * Cette fonction tente d'uploader une image sur Bluesky pour ajouter au status. + * S'il y a la moindre erreur, on ne bloque pas, on génère juste un status sans image. + */ + private function buildThumbnail(string $thumbnailUrl): ?array + { + $downloadResponse = $this->client->request('GET', $thumbnailUrl); + + if ($downloadResponse->getStatusCode() !== 200) { + return null; + } + + $thumbnailBlob = $downloadResponse->getBody()->getContents(); + + $uploadResponse = $this->client->request('POST', '/xrpc/com.atproto.repo.uploadBlob', [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $this->session()->accessJwt, + 'Content-Type' => 'image/webp', + 'Content-Length' => mb_strlen($thumbnailBlob), + ], + 'body' => $thumbnailBlob, + ]); + + if ($uploadResponse->getStatusCode() !== 200) { + return null; + } + + $data = json_decode($uploadResponse->getBody()->getContents(), true); + + // La clé blob contient un tableau dans le format attendu pour l'ajout du thumbnail + // dans le status donc pas besoin de le parser, on peut directement faire passe-plat. + return $data['blob'] ?? null; + } + + private function extractPostId(string $uri): ?string + { + if (!preg_match('#^at://([^/]+)/([^/]+)/([^/]+)$#', $uri, $matches)) { + return null; + } + + if (count($matches) !== 4) { + return null; + } + + if (!is_string($matches[3])) { + return null; + } + + return $matches[3]; + } +} diff --git a/sources/AppBundle/SocialNetwork/Bluesky/Session.php b/sources/AppBundle/SocialNetwork/Bluesky/Session.php new file mode 100644 index 000000000..c3932ec2b --- /dev/null +++ b/sources/AppBundle/SocialNetwork/Bluesky/Session.php @@ -0,0 +1,20 @@ +did = $did; + $this->accessJwt = $accessJwt; + } +} diff --git a/sources/AppBundle/SocialNetwork/Embed.php b/sources/AppBundle/SocialNetwork/Embed.php new file mode 100644 index 000000000..13da30401 --- /dev/null +++ b/sources/AppBundle/SocialNetwork/Embed.php @@ -0,0 +1,24 @@ +url = $url; + $this->title = $title; + $this->abstract = $abstract; + $this->imageUrl = $imageUrl; + } +} diff --git a/sources/AppBundle/SocialNetwork/SocialNetwork.php b/sources/AppBundle/SocialNetwork/SocialNetwork.php new file mode 100644 index 000000000..18615e3a2 --- /dev/null +++ b/sources/AppBundle/SocialNetwork/SocialNetwork.php @@ -0,0 +1,60 @@ + + * @method static SocialNetwork Bluesky() + * @method static SocialNetwork Mastodon() + */ +final class SocialNetwork extends Enum +{ + private const Bluesky = 'bluesky'; + private const Mastodon = 'mastodon'; + + public function statusMaxLength(): int + { + switch ($this->value) { + case self::Bluesky: + return 300; + case self::Mastodon: + return 500; + default: + return 280; + } + } + + public function speakerHandle(Speaker $speaker): ?string + { + if ($this->value === self::Bluesky) { + $handle = $speaker->getBluesky(); + } else { + $handle = $speaker->getMastodon(); + } + + if ($handle === null) { + return null; + } + + if (str_starts_with($handle, '@')) { + return $handle; + } + + return '@' . $handle; + } + + public function setStatusId(HistoryEntry $historyEntry, StatusId $statusId): void + { + if ($this->value === self::Bluesky) { + $historyEntry->setStatusIdBluesky($statusId->value); + } + + $historyEntry->setStatusIdMastodon($statusId->value); + } +} diff --git a/sources/AppBundle/SocialNetwork/Status.php b/sources/AppBundle/SocialNetwork/Status.php new file mode 100644 index 000000000..6f3c241b3 --- /dev/null +++ b/sources/AppBundle/SocialNetwork/Status.php @@ -0,0 +1,20 @@ +text = $text; + $this->embed = $embed; + } +} diff --git a/sources/AppBundle/SocialNetwork/StatusId.php b/sources/AppBundle/SocialNetwork/StatusId.php new file mode 100644 index 000000000..f0cb86d30 --- /dev/null +++ b/sources/AppBundle/SocialNetwork/StatusId.php @@ -0,0 +1,18 @@ +value = $value; + } +} diff --git a/sources/AppBundle/SocialNetwork/Transport.php b/sources/AppBundle/SocialNetwork/Transport.php new file mode 100644 index 000000000..b4bda0cbd --- /dev/null +++ b/sources/AppBundle/SocialNetwork/Transport.php @@ -0,0 +1,15 @@ + */ + private iterable $transports; + + /** + * @param iterable $transports + */ + public function __construct( + iterable $transports, + PlanningRepository $planningRepository, + TalkRepository $talkRepository, + EventRepository $eventRepository, + SpeakerRepository $speakerRepository, + HistoryRepository $historyRepository + ) { + $this->transports = $transports; + $this->planningRepository = $planningRepository; + $this->talkRepository = $talkRepository; + $this->eventRepository = $eventRepository; + $this->speakerRepository = $speakerRepository; + $this->historyRepository = $historyRepository; + } + + public function run(): void + { + $talk = $this->pickRandomTalk(); + + if ($talk === null) { + return; // todo log ? + } + + $speakers = $this->speakerRepository->getSpeakersByTalk($talk); + + $historyEntry = new HistoryEntry($talk->getId()); + + foreach ($this->transports as $transport) { + $statusGenerator = new StatusGenerator($transport->socialNetwork()); + + $status = $statusGenerator->generate($talk, iterator_to_array($speakers)); + + $statusId = $transport->send($status); + + if ($statusId !== null) { + $transport->socialNetwork()->setStatusId($historyEntry, $statusId); + } + } + + $this->historyRepository->insert($historyEntry); + } + + private function pickRandomTalk(): ?Talk + { + // todo add a way to pick an event (like for twitter) + $minimumEventDate = new \DateTime('-20 years'); // todo + + /** @var CollectionInterface&iterable $plannings */ + $plannings = $this->planningRepository->getAll(); + + $talks = []; + + /** @var Planning $planning */ + foreach ($plannings as $planning) { + /** @var Talk|null $talk */ + $talk = $this->talkRepository->get($planning->getTalkId()); + + if (null === $talk + || !$talk->isDisplayedOnHistory() + || !$talk->hasYoutubeId() + || !in_array($talk->getType(), self::VALID_TALK_TYPES, true) + ) { + continue; + } + + /** @var Event|null $event */ + $event = $this->eventRepository->get($planning->getEventId()); + + if (null === $event || $event->startsBefore($minimumEventDate)) { + continue; + } + + $talks[] = $talk; + } + + $talksWithLessPosts = $this->findLessPostedTalks($talks); + + if ($talksWithLessPosts === []) { + return null; + } + + return $talksWithLessPosts[array_rand($talksWithLessPosts)]; + } + + /** + * @param array $talks + * @return array + */ + private function findLessPostedTalks(array $talks): array + { + $quantities = $this->historyRepository->getNumberOfStatusesPerTalk($talks); + $maxCount = 0; + $minCount = PHP_INT_MAX; + + foreach ($talks as $talk) { + $count = $quantities[$talk->getId()] ?? 0; + + $maxCount = max($maxCount, $count); + $minCount = max($minCount, $count); + } + + if ($minCount === $maxCount) { + return $talks; + } + + return array_filter($talks, fn (Talk $talk) => ($quantities[$talk->getId()] ?? 0) !== $maxCount); + } +} diff --git a/sources/AppBundle/VideoNotifier/HistoryEntry.php b/sources/AppBundle/VideoNotifier/HistoryEntry.php new file mode 100644 index 000000000..b38cd70dd --- /dev/null +++ b/sources/AppBundle/VideoNotifier/HistoryEntry.php @@ -0,0 +1,42 @@ +talkId = $talkId; + } + + public function setStatusIdBluesky(?string $statusIdBluesky): void + { + $this->statusIdBluesky = $statusIdBluesky; + } + + public function setStatusIdMastodon(?string $statusIdMastodon): void + { + $this->statusIdMastodon = $statusIdMastodon; + } + + public function getTalkId(): int + { + return $this->talkId; + } + + public function getStatusIdBluesky(): ?string + { + return $this->statusIdBluesky; + } + + public function getStatusIdMastodon(): ?string + { + return $this->statusIdMastodon; + } +} diff --git a/sources/AppBundle/VideoNotifier/HistoryRepository.php b/sources/AppBundle/VideoNotifier/HistoryRepository.php new file mode 100644 index 000000000..c5333e641 --- /dev/null +++ b/sources/AppBundle/VideoNotifier/HistoryRepository.php @@ -0,0 +1,60 @@ +connection = $connection; + } + + public function insert(HistoryEntry $entry): void + { + $this->connection->createQueryBuilder() + ->insert('video_notifier_history') + ->values([ + 'talk_id' => '?', + 'status_id_bluesky' => '?', + 'status_id_mastodon' => '?', + ]) + ->setParameters([ + $entry->getTalkId(), + $entry->getStatusIdBluesky(), + $entry->getStatusIdMastodon(), + ]) + ->execute(); + } + + /** + * @param array $talks + * @return array + */ + public function getNumberOfStatusesPerTalk(array $talks): array + { + $rows = ($qb = $this->connection->createQueryBuilder()) + ->from('video_notifier_history', 'h') + ->select('h.talk_id', 'COUNT(id) AS quantity') + ->where( + $qb->expr()->in('h.talk_id', array_map(fn (Talk $talk) => $talk->getId(), $talks)) + ) + ->groupBy('h.talk_id') + ->execute() + ->fetchAllAssociative(); + + $map = []; + + foreach ($rows as $row) { + $map[$row['talk_id']] = $row['quantity']; + } + + return $map; + } +} diff --git a/sources/AppBundle/VideoNotifier/StatusGenerator.php b/sources/AppBundle/VideoNotifier/StatusGenerator.php new file mode 100644 index 000000000..71e545253 --- /dev/null +++ b/sources/AppBundle/VideoNotifier/StatusGenerator.php @@ -0,0 +1,130 @@ +socialNetwork = $socialNetwork; + } + + /** + * Cette fonction génère un statut pour un réseau social (Bluesky ou Mastodon par exemple) à partir d'un talk. + * + * La fonction tente plusieurs formats de statut jusqu'à en trouver un qui ne dépasse pas la longueur max + * du réseau social en cours. + */ + public function generate(Talk $talk, array $speakers): Status + { + if ($speakers === []) { + throw new \InvalidArgumentException('no speaker provided'); + } + + $mentionsText = $this->buildMentionsText($speakers); + + $text = sprintf( + "« %s » La conférence de %s à revoir sur le site de l'AFUP", + $talk->getTitle(), + $mentionsText, + ); + + // Si c'est trop long, on remplace "La conférence de" par "Par" + if (mb_strlen($text) > $this->socialNetwork->statusMaxLength()) { + $text = sprintf( + "« %s » Par %s à revoir sur le site de l'AFUP", + $talk->getTitle(), + $mentionsText, + ); + } + + // Si c'est encore trop long, on enlève "le site" + if (mb_strlen($text) > $this->socialNetwork->statusMaxLength()) { + $text = sprintf( + "« %s » Par %s à revoir sur l'AFUP", + $talk->getTitle(), + $mentionsText, + ); + } + + // Remplacement des espaces multiples par un seul + $text = preg_replace('/\s+/', ' ', $text); + + if (($length = mb_strlen($text)) > $this->socialNetwork->statusMaxLength()) { + throw new \LengthException(sprintf( + "Taille du status %s (%d/%d) incorrecte : %s", + $this->socialNetwork->getValue(), + $length, + $this->socialNetwork->statusMaxLength(), + $text, + )); + } + + return new Status( + $text, + new Embed( + 'https://afup.org/talks/' . $talk->getId() . '-' . $talk->getSlug(), + $talk->getTitle(), + strip_tags(html_entity_decode($talk->getAbstract())), + $this->buildThumbnailUrl($talk), + ), + ); + } + + /** + * Prend une liste de speakers et les retourne séparés par des virgules et le mot "et". + * + * La fonction s'adapte à la quantité de speakers : + * + * - ['Foo', 'Bar'] devient "Foo, Bar" + * - ['Foo', 'Bar', 'Fiz'] devient "Foo, Bar et Fiz" + * + * @param array $speakers + */ + private function buildMentionsText(array $speakers): string + { + $mentions = array_map( + fn (Speaker $speaker) => $this->socialNetwork->speakerHandle($speaker) ?? $speaker->getLabel(), + $speakers, + ); + + $count = count($mentions); + + if ($count === 0) { + return ''; + } + + if ($count === 1) { + return $mentions[0]; + } + + if ($count === 2) { + return $mentions[0] . ' et ' . $mentions[1]; + } + + $lastMention = array_pop($mentions); + + return implode(', ', $mentions) . ' et ' . $lastMention; + } + + private function buildThumbnailUrl(Talk $talk): ?string + { + $youTubeId = $talk->getYoutubeId(); + + if ($youTubeId === null) { + return null; + } + + return sprintf('https://i.ytimg.com/vi_webp/%s/maxresdefault.webp', $youTubeId); + } +}