├── .env ├── .env.test ├── .github └── workflows │ ├── deployment.yml │ └── pipeline.yml ├── .gitignore ├── .php-cs-fixer.dist.php ├── .php-version ├── LICENSE ├── README.md ├── bin ├── check ├── console └── recreate ├── chatbot-webhook.http ├── composer.json ├── composer.lock ├── config ├── bootstrap.php ├── bundles.php ├── packages │ ├── algolia_search.yaml │ ├── cache.yaml │ ├── dama_doctrine_test_bundle.yaml │ ├── debug.yaml │ ├── doctrine.yaml │ ├── doctrine_migrations.yaml │ ├── framework.yaml │ ├── http_discovery.yaml │ ├── monolog.yaml │ ├── notifier.yaml │ ├── routing.yaml │ ├── sentry.yaml │ ├── twig.yaml │ ├── uid.yaml │ └── web_profiler.yaml ├── preload.php ├── routes.yaml ├── routes │ ├── dev │ │ └── framework.yaml │ ├── framework.yaml │ └── web_profiler.yaml ├── secrets │ └── prod │ │ └── .gitignore ├── services.yaml └── settings │ └── algolia_search │ └── slots-settings.json ├── deploy.php ├── http-client.env.json ├── infection.json.dist ├── migrations ├── Version20221019182814.php ├── Version20221022110750.php ├── Version20221022213419.php ├── Version20221029202016.php └── Version20221103205957.php ├── phpstan.neon.dist ├── phpunit.xml.dist ├── public └── index.php ├── src ├── ChatBot │ ├── ChatBot.php │ ├── Replier │ │ ├── AttendanceReplier.php │ │ ├── CommandReplier.php │ │ ├── CountdownReplier.php │ │ ├── Day1Replier.php │ │ ├── Day2Replier.php │ │ ├── HelpReplier.php │ │ ├── NextReplier.php │ │ ├── NowReplier.php │ │ ├── RatingReplier.php │ │ ├── Renderer │ │ │ ├── DayRenderer.php │ │ │ └── SlotRenderer.php │ │ ├── ReplierInterface.php │ │ ├── SearchReplier.php │ │ ├── SlotReplier.php │ │ ├── StartReplier.php │ │ ├── TalkReplier.php │ │ └── TodayReplier.php │ ├── ReplyMachine.php │ └── Telegram │ │ ├── Client.php │ │ └── Data │ │ ├── CallbackQuery.php │ │ ├── Chat.php │ │ ├── Message.php │ │ ├── Update.php │ │ └── User.php ├── Command │ ├── CommandRegisterCommand.php │ ├── ConferenceAnalysisCommand.php │ ├── ScheduleCrawlerCommand.php │ └── WebhookRegisterCommand.php ├── Controller │ └── WebhookController.php ├── DataFixtures │ └── AppFixtures.php ├── Entity │ ├── Attendance.php │ ├── AttendeeRating.php │ ├── Event.php │ ├── Rating.php │ ├── Slot.php │ ├── Talk.php │ ├── TimeSpan.php │ └── Track.php ├── Kernel.php ├── Repository │ ├── SlotRepository.php │ └── TalkRepository.php ├── SymfonyCon │ ├── Analyzer.php │ ├── Crawler.php │ ├── Crawler │ │ ├── Client.php │ │ └── Parser.php │ ├── Schedule.php │ ├── Search.php │ ├── Timer.php │ └── TimerFactory.php └── Twig │ └── ShortExtension.php ├── symfony.lock ├── telegram-bot-api.http ├── templates ├── search.html.twig └── talk.html.twig └── tests ├── ChatBot ├── ChatBotTest.php ├── Replier │ ├── CountdownReplierTest.php │ ├── Day1ReplierTest.php │ ├── Day2ReplierTest.php │ ├── HelpReplierTest.php │ ├── NextReplierTest.php │ ├── NowReplierTest.php │ ├── SearchReplierTest.php │ ├── StartReplierTest.php │ └── TodayReplierTest.php ├── ReplyMachineTest.php └── Telegram │ ├── ClientTest.php │ └── Data │ └── UpdateTest.php ├── Command └── WebhookRegisterCommandTest.php ├── ConferenceFixtures.php ├── Controller ├── WebhookControllerTest.php └── fixtures │ └── start.json ├── Entity ├── AttendanceTest.php ├── AttendeeRatingTest.php ├── EventTest.php ├── SlotTest.php ├── TalkTest.php └── TimeSpanTest.php ├── Renderer.php ├── Repository └── SlotRepositoryTest.php ├── SchemaSetup.php ├── SymfonyCon ├── Crawler │ ├── ClientTest.php │ └── ParserTest.php ├── CrawlerTest.php ├── ScheduleTest.php ├── TimerFactoryTest.php ├── TimerTest.php └── fixtures │ ├── full-schedule.html │ ├── keynote.html │ ├── lunch.html │ ├── three-talks.html │ ├── two-rows.html │ └── two-talks.html ├── Templates.php ├── TestDatabase.php ├── UuidShortener.php └── bootstrap.php /.env: -------------------------------------------------------------------------------- 1 | # In all environments, the following files are loaded if they exist, 2 | # the latter taking precedence over the former: 3 | # 4 | # * .env contains default values for the environment variables needed by the app 5 | # * .env.local uncommitted file with local overrides 6 | # * .env.$APP_ENV committed environment-specific defaults 7 | # * .env.$APP_ENV.local uncommitted environment-specific overrides 8 | # 9 | # Real environment variables win over .env files. 10 | # 11 | # DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES. 12 | # https://symfony.com/doc/current/configuration/secrets.html 13 | # 14 | # Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). 15 | # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration 16 | 17 | ###> symfony/framework-bundle ### 18 | APP_ENV=dev 19 | APP_SECRET=3cd0411a790d63bf70aea7759b4371d9 20 | ###< symfony/framework-bundle ### 21 | 22 | ###> doctrine/doctrine-bundle ### 23 | # Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url 24 | # IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml 25 | # 26 | DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" 27 | # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" 28 | # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" 29 | # DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=15&charset=utf8" 30 | ###< doctrine/doctrine-bundle ### 31 | 32 | ###> symfony/telegram-notifier ### 33 | # TELEGRAM_DSN=telegram://TOKEN@default?channel=CHAT_ID 34 | ###< symfony/telegram-notifier ### 35 | ###> sentry/sentry-symfony ### 36 | SENTRY_DSN= 37 | ###< sentry/sentry-symfony ### 38 | 39 | ###> algolia/search-bundle ### 40 | # Create a free account on www.algolia.com 41 | # and get your credentials from the API Keys tab. 42 | ALGOLIA_APP_ID= 43 | ALGOLIA_API_KEY= 44 | ###< algolia/search-bundle ### 45 | 46 | TELEGRAM_TOKEN= 47 | WEBHOOK_BASE_URL= 48 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | # define your env variables for the test env here 2 | KERNEL_CLASS='App\Kernel' 3 | APP_SECRET='$ecretf0rt3st' 4 | SYMFONY_DEPRECATIONS_HELPER=999999 5 | PANTHER_APP_ENV=panther 6 | PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots 7 | -------------------------------------------------------------------------------- /.github/workflows/deployment.yml: -------------------------------------------------------------------------------- 1 | name: deployment 2 | on: 3 | push: 4 | branches: [main] 5 | concurrency: production_environment 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | 14 | - name: Setup PHP 15 | uses: shivammathur/setup-php@v2 16 | with: 17 | php-version: '8.2' 18 | 19 | - name: Deploy 20 | uses: deployphp/action@v1 21 | with: 22 | private-key: ${{ secrets.PRIVATE_KEY }} 23 | dep: deploy 24 | deployer-version: "v7.0.0-rc.8" 25 | -------------------------------------------------------------------------------- /.github/workflows/pipeline.yml: -------------------------------------------------------------------------------- 1 | name: Pipeline 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | build: 7 | name: Tests 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout Code 11 | uses: actions/checkout@v1 12 | with: 13 | fetch-depth: 1 14 | - name: Setup PHP 15 | uses: shivammathur/setup-php@v2 16 | with: 17 | php-version: '8.2' 18 | - name: Cache Vendors 19 | uses: actions/cache@v1 20 | id: cache-vendors 21 | with: 22 | path: vendor 23 | key: ${{ runner.os }}-vendor-${{ hashFiles('**/composer.lock')}} 24 | - name: Composer Validation 25 | run: composer validate --strict 26 | - name: Install PHP Dependencies 27 | run: composer install --no-scripts 28 | - name: Lint Yaml Files 29 | run: bin/console lint:yaml config --parse-tags 30 | - name: Code Style PHP 31 | run: vendor/bin/php-cs-fixer fix --dry-run 32 | - name: Tests 33 | run: vendor/bin/phpunit 34 | - name: Static Code Analysis 35 | run: vendor/bin/phpstan analyse 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ###> symfony/framework-bundle ### 2 | /.env.local 3 | /.env.local.php 4 | /.env.*.local 5 | /config/secrets/prod/prod.decrypt.private.php 6 | /public/bundles/ 7 | /var/ 8 | /vendor/ 9 | ###< symfony/framework-bundle ### 10 | ###> friendsofphp/php-cs-fixer ### 11 | /.php-cs-fixer.php 12 | /.php-cs-fixer.cache 13 | ###< friendsofphp/php-cs-fixer ### 14 | 15 | infection.log 16 | http-client.private.env.json 17 | 18 | ###> phpunit/phpunit ### 19 | /phpunit.xml 20 | .phpunit.result.cache 21 | .phpunit.cache 22 | ###< phpunit/phpunit ### 23 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in(__DIR__) 5 | ->exclude('var') 6 | ; 7 | 8 | return (new PhpCsFixer\Config()) 9 | ->setRules([ 10 | '@Symfony' => true, 11 | ]) 12 | ->setFinder($finder) 13 | ; 14 | -------------------------------------------------------------------------------- /.php-version: -------------------------------------------------------------------------------- 1 | 8.2 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Christopher Hertel 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | SymfonyCon Telegram Chatbot 2 | =========================== 3 | 4 | Telegram Chatbot for SymfonyCon Disneyland Paris 2022 5 | 6 | Find it on Telegram: [@SymfonyConBot](https://t.me/SymfonyConBot) 7 | 8 | Requirements 9 | ------------ 10 | 11 | - PHP 8.1 12 | - Doctrine compatible database engine 13 | - [Symfony CLI](https://symfony.com/doc/master/cloud/getting-started#installing-the-cli-tool) (or sth similar) 14 | - [Ngrok](https://ngrok.com/download) (or sth similar) 15 | - [Telegram Bot Token](https://core.telegram.org/bots#6-botfather) 16 | 17 | Setting up ngrok 18 | ---------------- 19 | 20 | The Telegram bot will communicate with our application to process messages and reply to them. This requires a websocket 21 | connection that is reachable from the outside world, i.e. the internet. This can be achieved by deploying the 22 | application, e.g. using [Symfony Cloud](https://symfony.com/cloud/), or exposing the local environment through an 23 | HTTPS tunnel service like [serveo](https://serveo.net/) or [ngrok](https://ngrok.com). 24 | 25 | 1. Download and install the ngrok client 26 | 1. Sign up for a private ngrok account on https://ngrok.com 27 | 1. Go to the Auth section in the dashboard and follow the instructions for setting the auth token 28 | 1. Run ngrok: `ngrok http https://localhost:8000` 29 | The URL `https://localhost:8000` refers to our local Symfony Server, so be careful to update the port info 30 | if necessary. 31 | 32 | When you run ngrok, you will see `Forwarding` addresses like `https://abcd5678.ngrok.io`. You will need this value for 33 | the `WEBHOOK_BASE_URL` environment variable. Alternatively you can copy `.env` as `.env.local` and set the value there. 34 | 35 | Setting up the Telegram bot 36 | --------------------------- 37 | 38 | You will need to set up a Telegram bot to interact with. You can do this through Telegram by sending a message 39 | to a bot [`@BotFather`](https://core.telegram.org/bots#6-botfather) and following the instructions. Alternatively 40 | you can use the CLI as described in the link above. 41 | 42 | Once you finished setting up the bot you will get a long message with an auth token that looks something like: 43 | `1234567890:ABCD7890efgh1234IJKL5678mnop11223-3`. 44 | 45 | This token is used for the environment variable `TELEGRAM_TOKEN`. 46 | 47 | Setting up the database 48 | ----------------------- 49 | 50 | Set up a database using a platform supported by Doctrine ORM, e.g. MySQL, PostgreSQL or SQLite3. Set the database DSN 51 | using the environment variable `DATABASE_URL` or by setting the value in our `.env.local`. You can find an example 52 | for an SQLite3 DSN in the `.env` file, which should be suitable for first development steps. 53 | 54 | Setup 55 | ----- 56 | 57 | To set up this demo we need to create a telegram bot and connect our local environment to it (see sections above). 58 | 59 | 1. Clone the repository and go into the project root: 60 | 61 | ```bash 62 | git clone git@github.com:chr-hertel/symfonycon-bot.git 63 | cd symfonycon-bot 64 | ``` 65 | 66 | 1. Install the dependencies 67 | 68 | ```bash 69 | composer install 70 | ``` 71 | 72 | 1. Set up ngrok and start the tunnel 73 | 74 | ```bash 75 | ngrok http https://localhost:8000 76 | ``` 77 | 78 | 1. Set up Telegram bot (only necessary on first run) 79 | 80 | 1. Set up the database and environment variables 81 | 82 | - `DATABASE_URL` - see `.env` for examples 83 | - `TELEGRAM_TOKEN` - token provided by BotFather (eg. `1234567890:ABCD7890efgh1234IJKL5678mnop11223-3`) 84 | - `WEBHOOK_BASE_URL` - ngrok base url (eg. `https://abcd5678.ngrok.io`) 85 | 86 | 1. Start the development server using the [Symfony CLI](https://symfony.com/doc/current/setup/symfony_server.html) 87 | 88 | ```bash 89 | symfony serve --detach 90 | ``` 91 | 92 | 1. Set up the entities 93 | 94 | ```bash 95 | bin/console doctrine:database:create 96 | bin/console doctrine:schema:create 97 | bin/console doctrine:fixtures:load # Schedule of 2019 98 | ``` 99 | 100 | 1. Load schedule of 2022 (optional) 101 | 102 | ```bash 103 | bin/console app:schedule:crawl 104 | ``` 105 | 106 | 1. Set up the webhook & menu (only necessary once) 107 | 108 | ```bash 109 | bin/console app:webhook:register 110 | bin/console app:command:register 111 | ``` 112 | 113 | 1. Set up search functionality 114 | 115 | To use the search in your local development environment, setup an 116 | [Algolia Account](https://www.algolia.com/) and configure following keys 117 | in your `.env.local`: 118 | 119 | ```dotenv 120 | ALGOLIA_APP_ID= 121 | ALGOLIA_API_KEY= 122 | ``` 123 | 124 | To set up the search index, run: 125 | 126 | ```bash 127 | bin/console search:settings:push 128 | bin/console search:import 129 | ``` 130 | 131 | Testing 132 | ------- 133 | 134 | **Relevant tools** 135 | 136 | * [PHP CS Fixer](https://cs.symfony.com/) 137 | 138 | ``` 139 | php vendor/bin/php-cs-fixer fix 140 | ``` 141 | 142 | * [PHPStan](https://phpstan.org/) 143 | 144 | ``` 145 | php vendor/bin/phpstan analyse 146 | ``` 147 | 148 | * [PHPUnit](https://phpunit.de/) 149 | 150 | ``` 151 | # without coverage 152 | php bin/phpunit 153 | 154 | # with coverage, but without functional tests 155 | XDEBUG_MODE=coverage php bin/phpunit --coverage-html=public/phpunit --exclude=functional 156 | ``` 157 | 158 | * [Infection](https://infection.github.io/) (not executed in pipeline) 159 | 160 | ``` 161 | php vendor/bin/infection 162 | ``` 163 | 164 | Pipeline 165 | -------- 166 | 167 | Pipeline is executed with GitHub Actions on Pull Requests. 168 | 169 | You can simulate locally with: 170 | 171 | ```bash 172 | bin/check 173 | ``` 174 | 175 | Deployment 176 | ---------- 177 | 178 | Deployment is executed with GitHub Action on push on `main` branch using [Deployer](https://deployer.org/). 179 | 180 | Development 181 | ----------- 182 | 183 | If you're using PhpStorm and you want to play around with the WebHook controller or 184 | Telegram Bot API, see HTTP files: 185 | 186 | * Create `http-client-private.env.json` file with your bot token & your chat id: 187 | 188 | ```json 189 | { 190 | "dev": { 191 | "bot-token": "bot:TOKEN", 192 | "chat-id": "123456789" 193 | } 194 | } 195 | ``` 196 | 197 | * Now you can use the example files, to interact with both applications: 198 | * Telegram Bot API: `telegram-bot-api.http` 199 | * ChatBot Webhook: `chatbot-webhook.http` 200 | -------------------------------------------------------------------------------- /bin/check: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | symfony composer validate --strict 4 | symfony composer check-platform-reqs 5 | 6 | symfony console lint:yaml config --parse-tags 7 | symfony console lint:container 8 | 9 | symfony php vendor/bin/php-cs-fixer fix --dry-run 10 | 11 | symfony php vendor/bin/phpstan analyse 12 | 13 | symfony php vendor/bin/phpunit 14 | 15 | symfony php vendor/bin/infection 16 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | =1.2) 9 | if (is_array($env = @include dirname(__DIR__).'/.env.local.php')) { 10 | foreach ($env as $k => $v) { 11 | $_ENV[$k] = $_ENV[$k] ?? (isset($_SERVER[$k]) && 0 !== strpos($k, 'HTTP_') ? $_SERVER[$k] : $v); 12 | } 13 | } elseif (!class_exists(Dotenv::class)) { 14 | throw new RuntimeException('Please run "composer require symfony/dotenv" to load the ".env" files configuring the application.'); 15 | } else { 16 | // load all the .env files 17 | (new Dotenv(false))->loadEnv(dirname(__DIR__).'/.env'); 18 | } 19 | 20 | $_SERVER += $_ENV; 21 | $_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null) ?: 'dev'; 22 | $_SERVER['APP_DEBUG'] = $_SERVER['APP_DEBUG'] ?? $_ENV['APP_DEBUG'] ?? 'prod' !== $_SERVER['APP_ENV']; 23 | $_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = (int) $_SERVER['APP_DEBUG'] || filter_var($_SERVER['APP_DEBUG'], FILTER_VALIDATE_BOOLEAN) ? '1' : '0'; 24 | -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 6 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], 7 | Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true], 8 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], 9 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 10 | Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['all' => true], 11 | Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], 12 | Sentry\SentryBundle\SentryBundle::class => ['prod' => true], 13 | Algolia\SearchBundle\AlgoliaSearchBundle::class => ['all' => true], 14 | Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], 15 | DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true], 16 | ]; 17 | -------------------------------------------------------------------------------- /config/packages/algolia_search.yaml: -------------------------------------------------------------------------------- 1 | # All available configuration can be found here: 2 | # https://www.algolia.com/doc/api-client/symfony/configuration/ 3 | algolia_search: 4 | nbResults: 5 5 | prefix: '%env(APP_ENV)%_' 6 | doctrineSubscribedEvents: [] 7 | indices: 8 | - name: talks 9 | class: App\Entity\Talk 10 | enable_serializer_groups: true 11 | -------------------------------------------------------------------------------- /config/packages/cache.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | cache: 3 | # Unique name of your app: used to compute stable namespaces for cache keys. 4 | #prefix_seed: your_vendor_name/app_name 5 | 6 | # The "app" cache stores to the filesystem by default. 7 | # The data in this cache should persist between deploys. 8 | # Other options include: 9 | 10 | # Redis 11 | #app: cache.adapter.redis 12 | #default_redis_provider: redis://localhost 13 | 14 | # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) 15 | #app: cache.adapter.apcu 16 | 17 | # Namespaced pools use the above "app" backend by default 18 | #pools: 19 | #my.dedicated.cache: null 20 | -------------------------------------------------------------------------------- /config/packages/dama_doctrine_test_bundle.yaml: -------------------------------------------------------------------------------- 1 | when@test: 2 | dama_doctrine_test: 3 | enable_static_connection: true 4 | enable_static_meta_data_cache: true 5 | enable_static_query_cache: true 6 | -------------------------------------------------------------------------------- /config/packages/debug.yaml: -------------------------------------------------------------------------------- 1 | when@dev: 2 | debug: 3 | # Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser. 4 | # See the "server:dump" command to start a new server. 5 | dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%" 6 | -------------------------------------------------------------------------------- /config/packages/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | dbal: 3 | url: '%env(resolve:DATABASE_URL)%' 4 | 5 | # IMPORTANT: You MUST configure your server version, 6 | # either here or in the DATABASE_URL env var (see .env file) 7 | #server_version: '15' 8 | orm: 9 | auto_generate_proxy_classes: true 10 | enable_lazy_ghost_objects: true 11 | naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware 12 | auto_mapping: true 13 | mappings: 14 | App: 15 | is_bundle: false 16 | dir: '%kernel.project_dir%/src/Entity' 17 | prefix: 'App\Entity' 18 | alias: App 19 | 20 | when@test: 21 | doctrine: 22 | dbal: 23 | # "TEST_TOKEN" is typically set by ParaTest 24 | dbname_suffix: '_test%env(default::TEST_TOKEN)%' 25 | 26 | when@prod: 27 | doctrine: 28 | orm: 29 | auto_generate_proxy_classes: false 30 | proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies' 31 | query_cache_driver: 32 | type: pool 33 | pool: doctrine.system_cache_pool 34 | result_cache_driver: 35 | type: pool 36 | pool: doctrine.result_cache_pool 37 | 38 | framework: 39 | cache: 40 | pools: 41 | doctrine.result_cache_pool: 42 | adapter: cache.app 43 | doctrine.system_cache_pool: 44 | adapter: cache.system 45 | -------------------------------------------------------------------------------- /config/packages/doctrine_migrations.yaml: -------------------------------------------------------------------------------- 1 | doctrine_migrations: 2 | migrations_paths: 3 | # namespace is arbitrary but should be different from App\Migrations 4 | # as migrations classes should NOT be autoloaded 5 | 'DoctrineMigrations': '%kernel.project_dir%/migrations' 6 | enable_profiler: '%kernel.debug%' 7 | -------------------------------------------------------------------------------- /config/packages/framework.yaml: -------------------------------------------------------------------------------- 1 | # see https://symfony.com/doc/current/reference/configuration/framework.html 2 | framework: 3 | secret: '%env(APP_SECRET)%' 4 | #csrf_protection: true 5 | http_method_override: false 6 | handle_all_throwables: true 7 | 8 | # Enables session support. Note that the session will ONLY be started if you read or write from it. 9 | # Remove or comment this section to explicitly disable session support. 10 | session: 11 | handler_id: null 12 | cookie_secure: auto 13 | cookie_samesite: lax 14 | storage_factory_id: session.storage.factory.native 15 | 16 | #esi: true 17 | #fragments: true 18 | php_errors: 19 | log: true 20 | 21 | serializer: 22 | name_converter: 'serializer.name_converter.camel_case_to_snake_case' 23 | 24 | when@test: 25 | framework: 26 | test: true 27 | session: 28 | storage_factory_id: session.storage.factory.mock_file 29 | -------------------------------------------------------------------------------- /config/packages/http_discovery.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory' 3 | Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory' 4 | Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory' 5 | Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory' 6 | Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory' 7 | Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory' 8 | 9 | http_discovery.psr17_factory: 10 | class: Http\Discovery\Psr17Factory 11 | -------------------------------------------------------------------------------- /config/packages/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | channels: 3 | - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists 4 | 5 | when@dev: 6 | monolog: 7 | handlers: 8 | main: 9 | type: stream 10 | path: "%kernel.logs_dir%/%kernel.environment%.log" 11 | level: debug 12 | channels: ["!event"] 13 | # uncomment to get logging in your browser 14 | # you may have to allow bigger header sizes in your Web server configuration 15 | #firephp: 16 | # type: firephp 17 | # level: info 18 | #chromephp: 19 | # type: chromephp 20 | # level: info 21 | console: 22 | type: console 23 | process_psr_3_messages: false 24 | channels: ["!event", "!doctrine", "!console"] 25 | 26 | when@test: 27 | monolog: 28 | handlers: 29 | main: 30 | type: fingers_crossed 31 | action_level: error 32 | handler: nested 33 | excluded_http_codes: [404, 405] 34 | channels: ["!event"] 35 | nested: 36 | type: stream 37 | path: "%kernel.logs_dir%/%kernel.environment%.log" 38 | level: debug 39 | 40 | when@prod: 41 | monolog: 42 | handlers: 43 | main: 44 | type: fingers_crossed 45 | action_level: error 46 | handler: nested 47 | excluded_http_codes: [404, 405] 48 | buffer_size: 50 # How many messages should be saved? Prevent memory leaks 49 | nested: 50 | type: stream 51 | path: php://stderr 52 | level: debug 53 | formatter: monolog.formatter.json 54 | console: 55 | type: console 56 | process_psr_3_messages: false 57 | channels: ["!event", "!doctrine"] 58 | deprecation: 59 | type: stream 60 | channels: [deprecation] 61 | path: php://stderr 62 | -------------------------------------------------------------------------------- /config/packages/notifier.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | notifier: 3 | chatter_transports: 4 | telegram: 'telegram://%env(TELEGRAM_TOKEN)%@default' 5 | channel_policy: 6 | # use chat/slack, chat/telegram, sms/twilio or sms/nexmo 7 | urgent: ['email'] 8 | high: ['email'] 9 | medium: ['email'] 10 | low: ['email'] 11 | admin_recipients: 12 | - { email: admin@example.com } 13 | 14 | when@test: 15 | framework: 16 | notifier: 17 | chatter_transports: 18 | telegram: 'null://dev' 19 | -------------------------------------------------------------------------------- /config/packages/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | utf8: true 4 | 5 | # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. 6 | # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands 7 | #default_uri: http://localhost 8 | 9 | when@prod: 10 | framework: 11 | router: 12 | strict_requirements: null 13 | -------------------------------------------------------------------------------- /config/packages/sentry.yaml: -------------------------------------------------------------------------------- 1 | when@prod: 2 | sentry: 3 | dsn: '%env(SENTRY_DSN)%' 4 | options: 5 | integrations: 6 | - 'Sentry\Integration\IgnoreErrorsIntegration' 7 | 8 | services: 9 | Sentry\Integration\IgnoreErrorsIntegration: 10 | $options: 11 | ignore_exceptions: 12 | - 'Symfony\Component\HttpKernel\Exception\NotFoundHttpException' # 404 13 | # If you are using Monolog, you also need this additional configuration to log the errors correctly: 14 | # https://docs.sentry.io/platforms/php/guides/symfony/#monolog-integration 15 | # register_error_listener: false 16 | # register_error_handler: false 17 | 18 | # monolog: 19 | # handlers: 20 | # sentry: 21 | # type: sentry 22 | # level: !php/const Monolog\Logger::ERROR 23 | # hub_id: Sentry\State\HubInterface 24 | -------------------------------------------------------------------------------- /config/packages/twig.yaml: -------------------------------------------------------------------------------- 1 | twig: 2 | default_path: '%kernel.project_dir%/templates' 3 | 4 | when@test: 5 | twig: 6 | strict_variables: true 7 | -------------------------------------------------------------------------------- /config/packages/uid.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | uid: 3 | default_uuid_version: 7 4 | time_based_uuid_version: 7 5 | -------------------------------------------------------------------------------- /config/packages/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | when@dev: 2 | web_profiler: 3 | toolbar: true 4 | intercept_redirects: false 5 | 6 | framework: 7 | profiler: 8 | only_exceptions: false 9 | collect_serializer_data: true 10 | 11 | when@test: 12 | web_profiler: 13 | toolbar: false 14 | intercept_redirects: false 15 | 16 | framework: 17 | profiler: { collect: false } 18 | -------------------------------------------------------------------------------- /config/preload.php: -------------------------------------------------------------------------------- 1 | ", 33 | "highlightPostTag": "<\/em>", 34 | "snippetEllipsisText": "", 35 | "alternativesAsExact": [ 36 | "ignorePlurals", 37 | "singleWordSynonym" 38 | ] 39 | } -------------------------------------------------------------------------------- /deploy.php: -------------------------------------------------------------------------------- 1 | set('remote_user', 'deployer') 19 | ->set('deploy_path', '/var/www/symfonycon-bot'); 20 | 21 | // Tasks 22 | task('build', function () { 23 | cd('{{release_path}}'); 24 | run('{{bin/console}} dotenv:dump {{console_options}}'); 25 | }); 26 | 27 | after('deploy:cache:clear', 'build'); 28 | after('deploy:cache:clear', 'database:migrate'); 29 | after('deploy:failed', 'deploy:unlock'); 30 | -------------------------------------------------------------------------------- /http-client.env.json: -------------------------------------------------------------------------------- 1 | { 2 | "dev": { 3 | "webhook-host": "https://localhost:8000", 4 | "bot-token": "override with http-client.private.env.json file", 5 | "chat-id": "override with http-client.private.env.json file" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /infection.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "directories": [ 4 | "src" 5 | ] 6 | }, 7 | "logs": { 8 | "text": "infection.log" 9 | }, 10 | "mutators": { 11 | "@default": true 12 | } 13 | } -------------------------------------------------------------------------------- /migrations/Version20221019182814.php: -------------------------------------------------------------------------------- 1 | abortIf( 20 | !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\SqlitePlatform, 21 | "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\SqlitePlatform'." 22 | ); 23 | 24 | $this->addSql('CREATE TABLE slot (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, title VARCHAR(255) NOT NULL COLLATE BINARY, start DATETIME NOT NULL --(DC2Type:datetime_immutable) 25 | , "end" DATETIME NOT NULL --(DC2Type:datetime_immutable) 26 | , speaker VARCHAR(255) DEFAULT NULL COLLATE BINARY, track VARCHAR(255) DEFAULT NULL COLLATE BINARY)'); 27 | } 28 | 29 | public function down(Schema $schema): void 30 | { 31 | $this->abortIf( 32 | !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\SqlitePlatform, 33 | "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\SqlitePlatform'." 34 | ); 35 | 36 | $this->addSql('DROP TABLE slot'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /migrations/Version20221022110750.php: -------------------------------------------------------------------------------- 1 | addSql('ALTER TABLE slot ADD COLUMN description CLOB DEFAULT NULL'); 24 | } 25 | 26 | public function down(Schema $schema): void 27 | { 28 | // this down() migration is auto-generated, please modify it to your needs 29 | $this->addSql('CREATE TEMPORARY TABLE __temp__slot AS SELECT id, title, start, "end", speaker, track FROM slot'); 30 | $this->addSql('DROP TABLE slot'); 31 | $this->addSql('CREATE TABLE slot (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, title VARCHAR(255) NOT NULL, start DATETIME NOT NULL --(DC2Type:datetime_immutable) 32 | , "end" DATETIME NOT NULL --(DC2Type:datetime_immutable) 33 | , speaker VARCHAR(255) DEFAULT NULL, track VARCHAR(255) DEFAULT NULL)'); 34 | $this->addSql('INSERT INTO slot (id, title, start, "end", speaker, track) SELECT id, title, start, "end", speaker, track FROM __temp__slot'); 35 | $this->addSql('DROP TABLE __temp__slot'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /migrations/Version20221022213419.php: -------------------------------------------------------------------------------- 1 | addSql('CREATE TEMPORARY TABLE __temp__slot AS SELECT id, title, start, "end", speaker, track, description FROM slot'); 24 | $this->addSql('DROP TABLE slot'); 25 | $this->addSql('CREATE TABLE slot (id BLOB NOT NULL --(DC2Type:uuid) 26 | , title VARCHAR(255) NOT NULL, start DATETIME NOT NULL --(DC2Type:datetime_immutable) 27 | , "end" DATETIME NOT NULL --(DC2Type:datetime_immutable) 28 | , speaker VARCHAR(255) DEFAULT NULL, track VARCHAR(255) DEFAULT NULL, description CLOB DEFAULT NULL, PRIMARY KEY(id))'); 29 | $this->addSql('INSERT INTO slot (id, title, start, "end", speaker, track, description) SELECT id, title, start, "end", speaker, track, description FROM __temp__slot'); 30 | $this->addSql('DROP TABLE __temp__slot'); 31 | } 32 | 33 | public function down(Schema $schema): void 34 | { 35 | // this down() migration is auto-generated, please modify it to your needs 36 | $this->addSql('CREATE TEMPORARY TABLE __temp__slot AS SELECT id, title, start, "end", speaker, track, description FROM slot'); 37 | $this->addSql('DROP TABLE slot'); 38 | $this->addSql('CREATE TABLE slot (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, title VARCHAR(255) NOT NULL, start DATETIME NOT NULL --(DC2Type:datetime_immutable) 39 | , "end" DATETIME NOT NULL --(DC2Type:datetime_immutable) 40 | , speaker VARCHAR(255) DEFAULT NULL, track VARCHAR(255) DEFAULT NULL, description CLOB DEFAULT NULL)'); 41 | $this->addSql('INSERT INTO slot (id, title, start, "end", speaker, track, description) SELECT id, title, start, "end", speaker, track, description FROM __temp__slot'); 42 | $this->addSql('DROP TABLE __temp__slot'); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /migrations/Version20221029202016.php: -------------------------------------------------------------------------------- 1 | addSql('CREATE TABLE event (id BLOB NOT NULL --(DC2Type:uuid) 24 | , slot_id BLOB DEFAULT NULL --(DC2Type:uuid) 25 | , title VARCHAR(255) NOT NULL, start DATETIME NOT NULL --(DC2Type:datetime_immutable) 26 | , "end" DATETIME NOT NULL --(DC2Type:datetime_immutable) 27 | , dtype VARCHAR(255) NOT NULL, speaker VARCHAR(255) DEFAULT NULL, description CLOB DEFAULT NULL, track VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id), CONSTRAINT FK_3BAE0AA759E5119C FOREIGN KEY (slot_id) REFERENCES slot (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); 28 | $this->addSql('CREATE INDEX IDX_3BAE0AA759E5119C ON event (slot_id)'); 29 | $this->addSql('CREATE TEMPORARY TABLE __temp__slot AS SELECT id, start, "end" FROM slot'); 30 | $this->addSql('DROP TABLE slot'); 31 | $this->addSql('CREATE TABLE slot (id BLOB NOT NULL --(DC2Type:uuid) 32 | , previous_id BLOB DEFAULT NULL --(DC2Type:uuid) 33 | , next_id BLOB DEFAULT NULL --(DC2Type:uuid) 34 | , start DATETIME NOT NULL --(DC2Type:datetime_immutable) 35 | , "end" DATETIME NOT NULL --(DC2Type:datetime_immutable) 36 | , PRIMARY KEY(id), CONSTRAINT FK_AC0E20672DE62210 FOREIGN KEY (previous_id) REFERENCES slot (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_AC0E2067AA23F6C8 FOREIGN KEY (next_id) REFERENCES slot (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); 37 | $this->addSql('INSERT INTO slot (id, start, "end") SELECT id, start, "end" FROM __temp__slot'); 38 | $this->addSql('DROP TABLE __temp__slot'); 39 | $this->addSql('CREATE UNIQUE INDEX UNIQ_AC0E20672DE62210 ON slot (previous_id)'); 40 | $this->addSql('CREATE UNIQUE INDEX UNIQ_AC0E2067AA23F6C8 ON slot (next_id)'); 41 | } 42 | 43 | public function down(Schema $schema): void 44 | { 45 | // this down() migration is auto-generated, please modify it to your needs 46 | $this->addSql('DROP TABLE event'); 47 | $this->addSql('CREATE TEMPORARY TABLE __temp__slot AS SELECT id, start, "end" FROM slot'); 48 | $this->addSql('DROP TABLE slot'); 49 | $this->addSql('CREATE TABLE slot (id BLOB NOT NULL --(DC2Type:uuid) 50 | , start DATETIME NOT NULL --(DC2Type:datetime_immutable) 51 | , "end" DATETIME NOT NULL --(DC2Type:datetime_immutable) 52 | , title VARCHAR(255) NOT NULL, speaker VARCHAR(255) DEFAULT NULL, track VARCHAR(255) DEFAULT NULL, description CLOB DEFAULT NULL, PRIMARY KEY(id))'); 53 | $this->addSql('INSERT INTO slot (id, start, "end") SELECT id, start, "end" FROM __temp__slot'); 54 | $this->addSql('DROP TABLE __temp__slot'); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /migrations/Version20221103205957.php: -------------------------------------------------------------------------------- 1 | addSql('CREATE TABLE attendance (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, talk_id BLOB DEFAULT NULL --(DC2Type:uuid) 24 | , attendee INTEGER NOT NULL, CONSTRAINT FK_6DE30D916F0601D5 FOREIGN KEY (talk_id) REFERENCES event (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); 25 | $this->addSql('CREATE INDEX IDX_6DE30D916F0601D5 ON attendance (talk_id)'); 26 | $this->addSql('CREATE UNIQUE INDEX UNIQ_6DE30D916F0601D51150D567 ON attendance (talk_id, attendee)'); 27 | $this->addSql('CREATE TABLE attendee_rating (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, talk_id BLOB DEFAULT NULL --(DC2Type:uuid) 28 | , attendee INTEGER NOT NULL, rating INTEGER NOT NULL, CONSTRAINT FK_FDEEC1676F0601D5 FOREIGN KEY (talk_id) REFERENCES event (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); 29 | $this->addSql('CREATE INDEX IDX_FDEEC1676F0601D5 ON attendee_rating (talk_id)'); 30 | $this->addSql('CREATE UNIQUE INDEX UNIQ_FDEEC1676F0601D51150D567 ON attendee_rating (talk_id, attendee)'); 31 | } 32 | 33 | public function down(Schema $schema): void 34 | { 35 | // this down() migration is auto-generated, please modify it to your needs 36 | $this->addSql('DROP TABLE attendance'); 37 | $this->addSql('DROP TABLE attendee_rating'); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/phpstan/phpstan-phpunit/extension.neon 3 | - vendor/phpstan/phpstan-phpunit/rules.neon 4 | 5 | parameters: 6 | level: 8 7 | paths: 8 | - src/ 9 | - tests/ 10 | ignoreErrors: 11 | - 12 | message: '#Property App\\Entity\\[a-zA-Z]+::\$id is unused.#' 13 | paths: 14 | - 'src/Entity/*' 15 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | tests 14 | 15 | 16 | 17 | 18 | 19 | src 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | replier->findReply($update); 25 | try { 26 | $chatId = $update->getChatId(); 27 | } catch (\RuntimeException $exception) { 28 | $this->logger->error('Cannot extract message nor sender', ['exception' => $exception]); 29 | 30 | return; 31 | } 32 | 33 | /** @var TelegramOptions $options */ 34 | $options = $chatMessage->getOptions() ?? new TelegramOptions(); 35 | $options 36 | ->chatId((string) $chatId) 37 | ->parseMode(TelegramOptions::PARSE_MODE_HTML); 38 | 39 | $this->chatter->send( 40 | $chatMessage->options($options) 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/ChatBot/Replier/AttendanceReplier.php: -------------------------------------------------------------------------------- 1 | getText(), 8); 36 | $talk = $this->repository->findByShortId($shortId); 37 | 38 | if (null === $talk) { 39 | return new ChatMessage('Missing or invalid talk ID.'); 40 | } 41 | 42 | try { 43 | $this->entityManager->persist(new Attendance($talk, $update->getChatId())); 44 | $this->entityManager->flush(); 45 | } catch (UniqueConstraintViolationException) { 46 | return new ChatMessage('You are already attending!'); 47 | } 48 | 49 | return new ChatMessage('Noted, have fun!'); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/ChatBot/Replier/CommandReplier.php: -------------------------------------------------------------------------------- 1 | getText(), sprintf('/%s', $this->getCommand())); 14 | } 15 | 16 | public function registerCommand(): bool 17 | { 18 | return true; 19 | } 20 | 21 | public function getDescription(): string 22 | { 23 | return ''; 24 | } 25 | 26 | abstract public function getCommand(): string; 27 | } 28 | -------------------------------------------------------------------------------- /src/ChatBot/Replier/CountdownReplier.php: -------------------------------------------------------------------------------- 1 | timer->getCountdown(); 30 | $message = 'Only %d days, %d hours and %d minutes until SymfonyCon starts.'; 31 | 32 | return new ChatMessage( 33 | sprintf($message, $countdown->d, $countdown->h, $countdown->i) 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/ChatBot/Replier/Day1Replier.php: -------------------------------------------------------------------------------- 1 | schedule->day1(); 34 | $message = $this->renderer->render('Schedule of Day 1', $slots); 35 | 36 | $button = (new InlineKeyboardButton('See Day 2'))->callbackData('/day2'); 37 | $markup = (new InlineKeyboardMarkup())->inlineKeyboard([$button]); 38 | $options = (new TelegramOptions())->replyMarkup($markup); 39 | 40 | if ($update->isCallback()) { 41 | $options->edit($update->getMessage()->messageId); 42 | } 43 | 44 | return new ChatMessage($message, $options); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/ChatBot/Replier/Day2Replier.php: -------------------------------------------------------------------------------- 1 | schedule->day2(); 34 | $message = $this->renderer->render('Schedule of Day 2', $slots); 35 | 36 | $button = (new InlineKeyboardButton('See Day 1'))->callbackData('/day1'); 37 | $markup = (new InlineKeyboardMarkup())->inlineKeyboard([$button]); 38 | $options = (new TelegramOptions())->replyMarkup($markup); 39 | 40 | if ($update->isCallback()) { 41 | $options->edit($update->getMessage()->messageId); 42 | } 43 | 44 | return new ChatMessage($message, $options); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/ChatBot/Replier/HelpReplier.php: -------------------------------------------------------------------------------- 1 | SymfonyConBot Help 26 | This bot will help you to keep on track with all talks at SymfonyCon Disneyland Paris 2022. 27 | 28 | Until SymfonyCon starts: 29 | /countdown - time until SymfonyCon starts 30 | /day1 - lists all talks of the first day 31 | /day2 - lists all talks of the second day 32 | 33 | While SymfonyCon: 34 | /today - lists all talks of today 35 | /now - lists all talks happening right now 36 | /next - lists all talks happening next slot 37 | 38 | About SymfonyConBot: 39 | Written with Symfony 6 and Notifier 40 | Checkout GitHub for more... 41 | HELP; 42 | 43 | return new ChatMessage($help); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/ChatBot/Replier/NextReplier.php: -------------------------------------------------------------------------------- 1 | schedule->next(); 32 | 33 | if (null === $slot) { 34 | return new ChatMessage('Next Slot'.PHP_EOL.PHP_EOL.'Nothing found.'); 35 | } 36 | 37 | return new ChatMessage( 38 | $this->renderer->render('Next Slot', $slot), 39 | (new TelegramOptions())->replyMarkup($this->renderer->buttons($slot)), 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/ChatBot/Replier/NowReplier.php: -------------------------------------------------------------------------------- 1 | schedule->now(); 32 | 33 | if (null === $slot) { 34 | return new ChatMessage('Current Slot'.PHP_EOL.PHP_EOL.'Nothing found.'); 35 | } 36 | 37 | return new ChatMessage( 38 | $this->renderer->render('Current Slot', $slot), 39 | (new TelegramOptions())->replyMarkup($this->renderer->buttons($slot)), 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/ChatBot/Replier/RatingReplier.php: -------------------------------------------------------------------------------- 1 | '⭐', 24 | 2 => '⭐⭐', 25 | 3 => '⭐⭐⭐', 26 | ]; 27 | 28 | public function __construct( 29 | private readonly TalkRepository $repository, 30 | private readonly ClockInterface $clock, 31 | private readonly Shortener $shortener, 32 | private readonly EntityManagerInterface $entityManager, 33 | ) { 34 | } 35 | 36 | public function getCommand(): string 37 | { 38 | return 'rate'; 39 | } 40 | 41 | public function registerCommand(): bool 42 | { 43 | return false; 44 | } 45 | 46 | public function reply(Update $update): ChatMessage 47 | { 48 | // remove "/rate@" from message text 49 | $payload = substr($update->getText(), 6); 50 | $hasRating = str_contains($payload, '='); 51 | 52 | $shortId = substr($payload, 0, $hasRating ? -2 : null); 53 | $rating = $hasRating ? substr($payload, -1) : null; 54 | 55 | dump($shortId, $rating); 56 | 57 | $talk = $this->repository->findByShortId($shortId); 58 | 59 | if (null === $talk) { 60 | return new ChatMessage('Missing or invalid talk ID.'); 61 | } 62 | 63 | if (!$talk->isOver($this->clock->now())) { 64 | return new ChatMessage('You cannot rate a talk before it\'s over.'); 65 | } 66 | 67 | if (null === $rating) { 68 | $buttons = array_map(function (int $rating) use ($talk) { 69 | return (new InlineKeyboardButton(self::LABEL[$rating]))->callbackData(sprintf( 70 | '/rate@%s=%d', $this->shortener->reduce($talk->getId()), $rating 71 | )); 72 | }, array_keys(self::LABEL)); 73 | $options = (new TelegramOptions()) 74 | ->replyMarkup((new InlineKeyboardMarkup())->inlineKeyboard($buttons)); 75 | 76 | return new ChatMessage('Please enter a rating for "'.$talk->getTitle().'"', $options); 77 | } 78 | 79 | try { 80 | $this->entityManager->persist(new AttendeeRating($talk, $update->getChatId(), Rating::from((int) $rating))); 81 | $this->entityManager->flush(); 82 | } catch (UniqueConstraintViolationException) { 83 | return new ChatMessage('You already rated this talk!'); 84 | } 85 | 86 | return new ChatMessage('Thanks for your rating!'); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/ChatBot/Replier/Renderer/DayRenderer.php: -------------------------------------------------------------------------------- 1 | $slots 19 | */ 20 | public function render(string $headline, array $slots): string 21 | { 22 | $slot = reset($slots); 23 | 24 | if (false === $slot) { 25 | return ''.$headline.''.PHP_EOL.PHP_EOL.'Nothing found.'; 26 | } 27 | 28 | $message = ''.$headline.' ('.$slot->getTimeSpan()->getEnd()->format('M d').')'; 29 | $message .= PHP_EOL.PHP_EOL; 30 | 31 | foreach ($slots as $slot) { 32 | $message .= sprintf('___ %s - %s _______', 33 | $slot->getTimeSpan()->getStart()->format('H:i'), 34 | $slot->getTimeSpan()->getEnd()->format('H:i'), 35 | ); 36 | $message .= PHP_EOL.PHP_EOL; 37 | 38 | foreach ($slot->getEvents() as $event) { 39 | if ($event instanceof Talk) { 40 | $message .= ''.$event->getTrack().''.PHP_EOL; 41 | } 42 | $message .= ''.$event->getTitle().''.PHP_EOL; 43 | if ($event instanceof Talk) { 44 | $message .= '» /talk@'.$this->shortener->reduce($event->getId()).PHP_EOL; 45 | } 46 | $message .= PHP_EOL; 47 | } 48 | } 49 | 50 | return $message; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/ChatBot/Replier/Renderer/SlotRenderer.php: -------------------------------------------------------------------------------- 1 | isFirst()) { 23 | $buttons[] = (new InlineKeyboardButton('Previous Slot')) 24 | ->callbackData('/slot@'.$this->shortener->reduce($slot->getPrevious()->getId())); 25 | } 26 | if (!$slot->isLast()) { 27 | $buttons[] = (new InlineKeyboardButton('Next Slot')) 28 | ->callbackData('/slot@'.$this->shortener->reduce($slot->getNext()->getId())); 29 | } 30 | 31 | return (new InlineKeyboardMarkup())->inlineKeyboard($buttons); 32 | } 33 | 34 | public function render(string $headline, Slot $slot): string 35 | { 36 | $message = ''.$headline.' ('.$slot->getTimeSpan()->toString().')'; 37 | $message .= PHP_EOL.PHP_EOL; 38 | 39 | foreach ($slot->getEvents() as $event) { 40 | if ($event instanceof Talk) { 41 | $message .= ''.$event->getTrack().''.PHP_EOL; 42 | } 43 | $message .= ''.$event->getTitle().''.PHP_EOL; 44 | if ($event instanceof Talk) { 45 | $message .= '» /talk@'.$this->shortener->reduce($event->getId()).PHP_EOL; 46 | } 47 | $message .= PHP_EOL; 48 | } 49 | 50 | return $message; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/ChatBot/Replier/ReplierInterface.php: -------------------------------------------------------------------------------- 1 | getText(), 7)); 32 | 33 | if (empty($query)) { 34 | return new ChatMessage('Please add a search term, like "/search Symfony 6.2".'); 35 | } 36 | 37 | return new ChatMessage( 38 | $this->twig->render('search.html.twig', [ 39 | 'query' => $query, 40 | 'talks' => $this->search->search($query), 41 | ]) 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/ChatBot/Replier/SlotReplier.php: -------------------------------------------------------------------------------- 1 | getText(), 6); 33 | $slot = $this->repository->findByShortId($shortId); 34 | 35 | if (null === $slot) { 36 | return new ChatMessage('Missing or invalid Slot ID.'); 37 | } 38 | 39 | return new ChatMessage( 40 | $this->renderer->render('Slot Details', $slot), 41 | (new TelegramOptions()) 42 | ->replyMarkup($this->renderer->buttons($slot)) 43 | // update previously send message instead of new one 44 | ->edit($update->getMessage()->messageId), 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/ChatBot/Replier/StartReplier.php: -------------------------------------------------------------------------------- 1 | Welcome to SymfonyConBot, %s! :)', $update->getMessage()->from->firstName) 26 | .PHP_EOL.PHP_EOL. 27 | 'Use /help to see all commands.' 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/ChatBot/Replier/TalkReplier.php: -------------------------------------------------------------------------------- 1 | getText(), 6); 39 | $talk = $this->repository->findByShortId($shortId); 40 | 41 | if (null === $talk) { 42 | return new ChatMessage('Missing or invalid talk ID.'); 43 | } 44 | 45 | $buttons = []; 46 | $buttons[] = (new InlineKeyboardButton('Attend')) 47 | ->callbackData('/attend@'.$this->shortener->reduce($talk->getId())); 48 | $buttons[] = (new InlineKeyboardButton('Rate Talk')) 49 | ->callbackData('/rate@'.$this->shortener->reduce($talk->getId())); 50 | 51 | return new ChatMessage( 52 | $this->twig->render('talk.html.twig', ['talk' => $talk]), 53 | (new TelegramOptions())->replyMarkup((new InlineKeyboardMarkup())->inlineKeyboard($buttons)), 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/ChatBot/Replier/TodayReplier.php: -------------------------------------------------------------------------------- 1 | schedule->today(); 31 | $message = $this->renderer->render('Schedule of Today', $slots); 32 | 33 | return new ChatMessage($message); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/ChatBot/ReplyMachine.php: -------------------------------------------------------------------------------- 1 | $repliers 15 | */ 16 | public function __construct( 17 | private readonly iterable $repliers, 18 | ) { 19 | } 20 | 21 | public function findReply(Update $update): ChatMessage 22 | { 23 | foreach ($this->repliers as $replier) { 24 | if ($replier->supports($update)) { 25 | return $replier->reply($update); 26 | } 27 | } 28 | 29 | return new ChatMessage( 30 | 'Sorry, I didn\'t get that!' 31 | .PHP_EOL.PHP_EOL. 32 | 'Please try /help instead!' 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/ChatBot/Telegram/Client.php: -------------------------------------------------------------------------------- 1 | $commands 22 | */ 23 | public function registerCommands(array $commands): void 24 | { 25 | $this->callEndpoint('setMyCommands', ['commands' => $commands]); 26 | } 27 | 28 | public function registerWebhook(): void 29 | { 30 | $url = $this->baseUrl.$this->urlGenerator->generate('webhook'); 31 | 32 | $this->callEndpoint('setWebhook', ['url' => $url]); 33 | } 34 | 35 | /** 36 | * @phpstan-param array $payload 37 | */ 38 | private function callEndpoint(string $endpoint, array $payload): void 39 | { 40 | $endpoint = sprintf('https://api.telegram.org/bot%s/%s', $this->token, $endpoint); 41 | 42 | $this->httpClient->request('POST', $endpoint, ['json' => $payload]); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/ChatBot/Telegram/Data/CallbackQuery.php: -------------------------------------------------------------------------------- 1 | message || null !== $this->editedMessage; 17 | } 18 | 19 | public function isCallback(): bool 20 | { 21 | return $this->callbackQuery instanceof CallbackQuery; 22 | } 23 | 24 | public function getText(): string 25 | { 26 | if ($this->isCallback()) { 27 | return $this->callbackQuery->data ?? ''; 28 | } 29 | 30 | return $this->getMessage()->text; 31 | } 32 | 33 | public function getMessage(): Message 34 | { 35 | if ($this->message instanceof Message) { 36 | return $this->message; 37 | } 38 | 39 | if ($this->editedMessage instanceof Message) { 40 | return $this->editedMessage; 41 | } 42 | 43 | if ($this->callbackQuery instanceof CallbackQuery && $this->callbackQuery->message instanceof Message) { 44 | return $this->callbackQuery->message; 45 | } 46 | 47 | throw new \RuntimeException('Unable to extract message.'); 48 | } 49 | 50 | public function getChatId(): int 51 | { 52 | return $this->getMessage()->chat->id; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/ChatBot/Telegram/Data/User.php: -------------------------------------------------------------------------------- 1 | $commandRepliers 20 | */ 21 | public function __construct( 22 | private readonly iterable $commandRepliers, 23 | private readonly Client $telegramClient, 24 | ) { 25 | parent::__construct(); 26 | } 27 | 28 | protected function execute(InputInterface $input, OutputInterface $output): int 29 | { 30 | $io = new SymfonyStyle($input, $output); 31 | $io->title('Registering Bot Commands at Telegram'); 32 | 33 | if (!$io->confirm('Really want to replace the registered commands?', false)) { 34 | return 0; 35 | } 36 | 37 | $commands = []; 38 | foreach ($this->commandRepliers as $replier) { 39 | if (!$replier->registerCommand()) { 40 | continue; 41 | } 42 | $commands[] = [ 43 | 'command' => $replier->getCommand(), 44 | 'description' => $replier->getDescription(), 45 | ]; 46 | } 47 | $this->telegramClient->registerCommands($commands); 48 | 49 | $io->success('Done'); 50 | 51 | return 0; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Command/ConferenceAnalysisCommand.php: -------------------------------------------------------------------------------- 1 | title('Conference Analysis'); 26 | $io->text('Let\'s see how the talks performed ...'); 27 | $io->newLine(); 28 | 29 | $io->table( 30 | ['Talk', 'Speaker', 'Attendees', 'No of Ratings', 'Average Rating'], 31 | $this->analyzer->createAnalysis(), 32 | ); 33 | 34 | return 0; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Command/ScheduleCrawlerCommand.php: -------------------------------------------------------------------------------- 1 | title('Crawling latest schedule information from live.symfony.com'); 29 | 30 | if (!$io->confirm('Really want to replace the current schedule?', false)) { 31 | return 0; 32 | } 33 | 34 | $this->truncateSlots(); 35 | $this->crawler->loadSchedule(); 36 | 37 | return 0; 38 | } 39 | 40 | private function truncateSlots(): void 41 | { 42 | $connection = $this->entityManager->getConnection(); 43 | $platform = $connection->getDatabasePlatform(); 44 | 45 | $connection->executeStatement($platform->getTruncateTableSQL('slot', true)); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Command/WebhookRegisterCommand.php: -------------------------------------------------------------------------------- 1 | title('Registering Telegram Webhook'); 27 | 28 | if (!$io->confirm('Really want to replace the webhook?', false)) { 29 | return 0; 30 | } 31 | 32 | $this->telegramClient->registerWebhook(); 33 | 34 | $io->success('Done'); 35 | 36 | return 0; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Controller/WebhookController.php: -------------------------------------------------------------------------------- 1 | 'json'])] 26 | public function connect(Request $request): Response 27 | { 28 | /** @var Update $update */ 29 | $update = $this->serializer->deserialize($request->getContent(), Update::class, 'json', [ 30 | DateTimeNormalizer::FORMAT_KEY => 'U', 31 | ]); 32 | 33 | $this->chatBot->consume($update); 34 | 35 | return new Response(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/DataFixtures/AppFixtures.php: -------------------------------------------------------------------------------- 1 | loadDayOne(); 18 | foreach ($day as $slot) { 19 | $manager->persist($slot); 20 | } 21 | $manager->flush(); 22 | 23 | $day = $this->loadDayTwo(); 24 | foreach ($day as $slot) { 25 | $manager->persist($slot); 26 | } 27 | $manager->flush(); 28 | } 29 | 30 | /** 31 | * @return array 32 | */ 33 | private function loadDayOne(): array 34 | { 35 | $slots = []; 36 | 37 | $timeSpan1 = new TimeSpan($this->newParisTime('11/21/19 8:00'), $this->newParisTime('11/21/19 9:00')); 38 | $slot1 = new Slot($timeSpan1); 39 | $slot1->addEvent(new Event('Badge pickup and welcome light breakfast ☕🥐', $timeSpan1, $slot1)); 40 | $slots[] = $slot1; 41 | 42 | $timeSpan2 = new TimeSpan($this->newParisTime('11/21/19 9:00'), $this->newParisTime('11/21/19 9:15')); 43 | $slot2 = new Slot($timeSpan2, $slot1); 44 | $slot2->addEvent(new Event('🎉 Opening / Welcome session 👋', $timeSpan2, $slot2)); 45 | $slot1->setNext($slot2); 46 | $slots[] = $slot2; 47 | 48 | $timeSpan3 = new TimeSpan($this->newParisTime('11/21/19 9:15'), $this->newParisTime('11/21/19 9:55')); 49 | $slot3 = new Slot($timeSpan3, $slot2); 50 | $slot3->addEvent(new Talk('Keynote', 'Fabien Potencier', '', $timeSpan3, Track::SymfonyRoom, $slot3)); 51 | $slot2->setNext($slot3); 52 | $slots[] = $slot3; 53 | 54 | $timeSpan4 = new TimeSpan($this->newParisTime('11/21/19 10:05'), $this->newParisTime('11/21/19 10:45')); 55 | $slot4 = new Slot($timeSpan4, $slot3); 56 | $slot4->addEvent(new Talk('HTTP/3: It\'s all about the transport!', 'Benoit Jacquemont', '', $timeSpan4, Track::SymfonyRoom, $slot4)); 57 | $slot4->addEvent(new Talk('How to contribute to Symfony and why you should give it a try', 'Valentin Udaltsov', '', $timeSpan4, Track::FrameworkRoom, $slot4)); 58 | $slot4->addEvent(new Talk('A view in the PHP Virtual Machine', 'Julien Pauli', '', $timeSpan4, Track::PlatformRoom, $slot4)); 59 | $slot3->setNext($slot4); 60 | $slots[] = $slot4; 61 | 62 | $timeSpan5 = new TimeSpan($this->newParisTime('11/21/19 10:45'), $this->newParisTime('11/21/19 11:15')); 63 | $slot5 = new Slot($timeSpan5, $slot4); 64 | $slot5->addEvent(new Event('Break ☕ 🥐', $timeSpan5, $slot5)); 65 | $slot4->setNext($slot5); 66 | $slots[] = $slot5; 67 | 68 | $timeSpan6 = new TimeSpan($this->newParisTime('11/21/19 11:15'), $this->newParisTime('11/21/19 11:55')); 69 | $slot6 = new Slot($timeSpan6, $slot5); 70 | $slot6->addEvent(new Talk('How Doctrine caching can skyrocket your application', 'Jachim Coudenys', '', $timeSpan6, Track::SymfonyRoom, $slot6)); 71 | $slot6->addEvent(new Talk('Using the Workflow component for e-commerce', 'Michelle Sanver', '', $timeSpan6, Track::FrameworkRoom, $slot6)); 72 | $slot6->addEvent(new Talk('Crazy Fun Experiments with PHP (Not for Production)', 'Zan Baldwin', '', $timeSpan6, Track::PlatformRoom, $slot6)); 73 | $slot5->setNext($slot6); 74 | $slots[] = $slot6; 75 | 76 | $timeSpan7 = new TimeSpan($this->newParisTime('11/21/19 12:00'), $this->newParisTime('11/21/19 13:30')); 77 | $slot7 = new Slot($timeSpan7, $slot6); 78 | $slot7->addEvent(new Event('Lunch', $timeSpan7, $slot7)); 79 | $slot6->setNext($slot7); 80 | $slots[] = $slot7; 81 | 82 | $timeSpan8 = new TimeSpan($this->newParisTime('11/21/19 13:30'), $this->newParisTime('11/21/19 14:10')); 83 | $slot8 = new Slot($timeSpan8, $slot7); 84 | $slot8->addEvent(new Talk('Hexagonal Architecture with Symfony', 'Matthias Noback', '', $timeSpan8, Track::SymfonyRoom, $slot8)); 85 | $slot8->addEvent(new Talk('Crawling the Web with the New Symfony Components', 'Adiel Cristo', '', $timeSpan8, Track::FrameworkRoom, $slot8)); 86 | $slot8->addEvent(new Talk('Adding Event Sourcing to an existing PHP project (for the right reasons)', 'Alessandro Lai', '', $timeSpan8, Track::PlatformRoom, $slot8)); 87 | $slot7->setNext($slot8); 88 | $slots[] = $slot8; 89 | 90 | $timeSpan9 = new TimeSpan($this->newParisTime('11/21/19 14:20'), $this->newParisTime('11/21/19 15:00')); 91 | $slot9 = new Slot($timeSpan9, $slot8); 92 | $slot9->addEvent(new Talk('HYPErmedia: leveraging HTTP/2 and Symfony for better and faster web APIs', 'Kévin Dunglas', '', $timeSpan9, Track::SymfonyRoom, $slot9)); 93 | $slot9->addEvent(new Talk('PHP, Symfony and Security', 'Diana Ungaro Arnos', '', $timeSpan9, Track::FrameworkRoom, $slot9)); 94 | $slot9->addEvent(new Talk('What happens when you press enter?', 'Tobias Sjösten', '', $timeSpan9, Track::PlatformRoom, $slot9)); 95 | $slot8->setNext($slot9); 96 | $slots[] = $slot9; 97 | 98 | $timeSpan10 = new TimeSpan($this->newParisTime('11/21/19 15:00'), $this->newParisTime('11/21/19 15:30')); 99 | $slot10 = new Slot($timeSpan10, $slot9); 100 | $slot10->addEvent(new Event('Break ☕ 🥐', $timeSpan10, $slot10)); 101 | $slot9->setNext($slot10); 102 | $slots[] = $slot10; 103 | 104 | $timeSpan11 = new TimeSpan($this->newParisTime('11/21/19 15:30'), $this->newParisTime('11/21/19 16:10')); 105 | $slot11 = new Slot($timeSpan11, $slot10); 106 | $slot11->addEvent(new Talk('Configuring Symfony - from localhost to High Availability', 'Nicolas Grekas', '', $timeSpan11, Track::SymfonyRoom, $slot11)); 107 | $slot11->addEvent(new Talk('HTTP Caching with Symfony 101', 'Matthias Pigulla', '', $timeSpan11, Track::FrameworkRoom, $slot11)); 108 | $slot11->addEvent(new Talk('How fitness helps you become a better developer', 'Magnus Nordlander', '', $timeSpan11, Track::PlatformRoom, $slot11)); 109 | $slot10->setNext($slot11); 110 | $slots[] = $slot11; 111 | 112 | $timeSpan12 = new TimeSpan($this->newParisTime('11/21/19 16:20'), $this->newParisTime('11/21/19 17:00')); 113 | $slot12 = new Slot($timeSpan12, $slot11); 114 | $slot12->addEvent(new Event('Meet the Core Team - Roundtable', $timeSpan12, $slot12)); 115 | $slot11->setNext($slot12); 116 | $slots[] = $slot12; 117 | 118 | $timeSpan13 = new TimeSpan($this->newParisTime('11/21/19 18:00'), $this->newParisTime('11/21/19 21:00')); 119 | $slot13 = new Slot($timeSpan13, $slot12); 120 | $slot13->addEvent(new Event('Social event (drinks and snacks)', $timeSpan13, $slot13)); 121 | $slot12->setNext($slot13); 122 | $slots[] = $slot13; 123 | 124 | return $slots; 125 | } 126 | 127 | /** 128 | * @return array 129 | */ 130 | private function loadDayTwo(): array 131 | { 132 | $slots = []; 133 | 134 | $timeSpan1 = new TimeSpan($this->newParisTime('11/22/19 8:00'), $this->newParisTime('11/22/19 9:00')); 135 | $slot1 = new Slot($timeSpan1); 136 | $slot1->addEvent(new Event('Light breakfast ☕🥐', $timeSpan1, $slot1)); 137 | $slots[] = $slot1; 138 | 139 | $timeSpan2 = new TimeSpan($this->newParisTime('11/22/19 9:00'), $this->newParisTime('11/22/19 9:40')); 140 | $slot2 = new Slot($timeSpan2, $slot1); 141 | $slot2->addEvent(new Talk('PHPUnit Best Practices', 'Sebastian Bergmann', '', $timeSpan2, Track::SymfonyRoom, $slot2)); 142 | $slot1->setNext($slot2); 143 | $slots[] = $slot2; 144 | 145 | $timeSpan3 = new TimeSpan($this->newParisTime('11/22/19 09:50'), $this->newParisTime('11/22/19 10:30')); 146 | $slot3 = new Slot($timeSpan3, $slot2); 147 | $slot3->addEvent(new Talk('Using API Platform to build ticketing system', 'Antonio Peric-Mazar', '', $timeSpan3, Track::SymfonyRoom, $slot3)); 148 | $slot3->addEvent(new Talk('Make the Most out of Twig', 'Andrii Yatsenko', '', $timeSpan3, Track::FrameworkRoom, $slot3)); 149 | $slot3->addEvent(new Talk('Mental Health in the Workplace', 'Stefan Koopmanschap', '', $timeSpan3, Track::PlatformRoom, $slot3)); 150 | $slot2->setNext($slot3); 151 | $slots[] = $slot3; 152 | 153 | $timeSpan4 = new TimeSpan($this->newParisTime('11/22/19 10:30'), $this->newParisTime('11/22/19 11:00')); 154 | $slot4 = new Slot($timeSpan4, $slot3); 155 | $slot4->addEvent(new Event('Break ☕ 🥐', $timeSpan4, $slot4)); 156 | $slot3->setNext($slot4); 157 | $slots[] = $slot4; 158 | 159 | $timeSpan5 = new TimeSpan($this->newParisTime('11/22/19 11:00'), $this->newParisTime('11/22/19 11:40')); 160 | $slot5 = new Slot($timeSpan5, $slot4); 161 | $slot5->addEvent(new Talk('Importing bad data - Outputting good data with Symfony', 'Michelle Sanver', '', $timeSpan5, Track::SymfonyRoom, $slot5)); 162 | $slot5->addEvent(new Talk('Symfony Serializer: There and back again', 'Juciellen Cabrera', '', $timeSpan5, Track::FrameworkRoom, $slot5)); 163 | $slot5->addEvent(new Talk('Eeek, my tests are mutating!', 'Lander Vanderstraeten', '', $timeSpan5, Track::PlatformRoom, $slot5)); 164 | $slot4->setNext($slot5); 165 | $slots[] = $slot5; 166 | 167 | $timeSpan6 = new TimeSpan($this->newParisTime('11/22/19 11:50'), $this->newParisTime('11/22/19 12:30')); 168 | $slot6 = new Slot($timeSpan6, $slot5); 169 | $slot6->addEvent(new Talk('Integrating performance management in your development cycle', 'Marc Weistroff', '', $timeSpan6, Track::SymfonyRoom, $slot6)); 170 | $slot6->addEvent(new Talk('Demystifying React JS for Symfony developers', 'Titouan Galopin', '', $timeSpan6, Track::FrameworkRoom, $slot6)); 171 | $slot6->addEvent(new Talk('Head first into Symfony Cache, Redis & Redis Cluster', 'Andre Rømcke', '', $timeSpan6, Track::PlatformRoom, $slot6)); 172 | $slot5->setNext($slot6); 173 | $slots[] = $slot6; 174 | 175 | $timeSpan7 = new TimeSpan($this->newParisTime('11/22/19 12:30'), $this->newParisTime('11/22/19 14:00')); 176 | $slot7 = new Slot($timeSpan7, $slot6); 177 | $slot7->addEvent(new Event('Lunch', $timeSpan7, $slot7)); 178 | $slot6->setNext($slot7); 179 | $slots[] = $slot7; 180 | 181 | $timeSpan8 = new TimeSpan($this->newParisTime('11/22/19 14:00'), $this->newParisTime('11/22/19 14:40')); 182 | $slot8 = new Slot($timeSpan8, $slot7); 183 | $slot8->addEvent(new Talk('Prime Time with Messenger: Queues, Workers & more Fun!', 'Ryan Weaver', '', $timeSpan8, Track::SymfonyRoom, $slot8)); 184 | $slot8->addEvent(new Talk('SymfonyCloud: the infrastructure of the Symfony ecosystem', 'Tugdual Saunier', '', $timeSpan8, Track::FrameworkRoom, $slot8)); 185 | $slot8->addEvent(new Talk('Together towards an AI, NEAT plus ultra', 'Grégoire Hébert', '', $timeSpan8, Track::PlatformRoom, $slot8)); 186 | $slot7->setNext($slot8); 187 | $slots[] = $slot8; 188 | 189 | $timeSpan9 = new TimeSpan($this->newParisTime('11/22/19 14:50'), $this->newParisTime('11/22/19 15:30')); 190 | $slot9 = new Slot($timeSpan9, $slot8); 191 | $slot9->addEvent(new Talk('Building really fast applications', 'Tobias Nyholm', '', $timeSpan9, Track::SymfonyRoom, $slot9)); 192 | $slot9->addEvent(new Talk('Everything you wanted to know about Sylius, but didn’t find time to ask', 'Łukasz Chruściel', '', $timeSpan9, Track::FrameworkRoom, $slot9)); 193 | $slot9->addEvent(new Talk('DevCorp: Choose Your Own Adventure', 'Pauline Vos', '', $timeSpan9, Track::PlatformRoom, $slot9)); 194 | $slot8->setNext($slot9); 195 | $slots[] = $slot9; 196 | 197 | $timeSpan10 = new TimeSpan($this->newParisTime('11/22/19 15:30'), $this->newParisTime('11/22/19 16:00')); 198 | $slot10 = new Slot($timeSpan10, $slot9); 199 | $slot10->addEvent(new Event('Break ☕ 🥐', $timeSpan10, $slot10)); 200 | $slot9->setNext($slot10); 201 | $slots[] = $slot10; 202 | 203 | $timeSpan11 = new TimeSpan($this->newParisTime('11/22/19 16:00'), $this->newParisTime('11/22/19 16:40')); 204 | $slot11 = new Slot($timeSpan11, $slot10); 205 | $slot11->addEvent(new Talk('One Year of Symfony', 'Zan Baldwin & Nicolas Grekas', '', $timeSpan11, Track::SymfonyRoom, $slot11)); 206 | $slot10->setNext($slot11); 207 | $slots[] = $slot11; 208 | 209 | $timeSpan12 = new TimeSpan($this->newParisTime('11/22/19 16:40'), $this->newParisTime('11/22/19 17:00')); 210 | $slot12 = new Slot($timeSpan12, $slot11); 211 | $slot12->addEvent(new Event('Closing session', $timeSpan12, $slot12)); 212 | $slot11->setNext($slot12); 213 | $slots[] = $slot12; 214 | 215 | return $slots; 216 | } 217 | 218 | private function newParisTime(string $dateTime): \DateTimeImmutable 219 | { 220 | return new \DateTimeImmutable($dateTime, new \DateTimeZone('Europe/Paris')); 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /src/Entity/Attendance.php: -------------------------------------------------------------------------------- 1 | talk; 28 | } 29 | 30 | public function getAttendee(): int 31 | { 32 | return $this->attendee; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Entity/AttendeeRating.php: -------------------------------------------------------------------------------- 1 | talk; 30 | } 31 | 32 | public function getAttendee(): int 33 | { 34 | return $this->attendee; 35 | } 36 | 37 | public function getRating(): Rating 38 | { 39 | return $this->rating; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Entity/Event.php: -------------------------------------------------------------------------------- 1 | Event::class, 'talk' => Talk::class])] 13 | class Event 14 | { 15 | #[ORM\Id, ORM\Column(type: 'uuid', unique: true)] 16 | private Uuid $id; 17 | 18 | public function __construct( 19 | #[ORM\Column] 20 | private readonly string $title, 21 | #[ORM\Embedded(class: TimeSpan::class, columnPrefix: false)] 22 | private readonly TimeSpan $timeSpan, 23 | #[ORM\ManyToOne(targetEntity: Slot::class, inversedBy: 'events')] 24 | private readonly Slot $slot, 25 | ) { 26 | $this->id = Uuid::v4(); 27 | } 28 | 29 | public function getId(): string 30 | { 31 | return $this->id->toRfc4122(); 32 | } 33 | 34 | public function getTitle(): string 35 | { 36 | return $this->title; 37 | } 38 | 39 | public function getTimeSpan(): TimeSpan 40 | { 41 | return $this->timeSpan; 42 | } 43 | 44 | public function getSlot(): Slot 45 | { 46 | return $this->slot; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Entity/Rating.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | #[ORM\OneToMany(targetEntity: Event::class, mappedBy: 'slot', cascade: ['persist'])] 23 | private Collection $events; 24 | 25 | public function __construct( 26 | #[ORM\Embedded(class: TimeSpan::class, columnPrefix: false)] 27 | private readonly TimeSpan $timeSpan, 28 | #[ORM\OneToOne(targetEntity: Slot::class)] 29 | private readonly ?Slot $previous = null, 30 | #[ORM\OneToOne(targetEntity: Slot::class)] 31 | private ?Slot $next = null, 32 | ) { 33 | $this->id = Uuid::v4(); 34 | $this->events = new ArrayCollection(); 35 | } 36 | 37 | public function getId(): string 38 | { 39 | return $this->id->toRfc4122(); 40 | } 41 | 42 | public function getTimeSpan(): TimeSpan 43 | { 44 | return $this->timeSpan; 45 | } 46 | 47 | /** 48 | * @return list 49 | */ 50 | public function getEvents(): array 51 | { 52 | return $this->events->toArray(); 53 | } 54 | 55 | public function addEvent(Event $event): void 56 | { 57 | $this->events->add($event); 58 | } 59 | 60 | public function isFirst(): bool 61 | { 62 | return null === $this->previous; 63 | } 64 | 65 | public function getPrevious(): Slot 66 | { 67 | if (null === $this->previous) { 68 | throw new \DomainException('Cannot fetch previous slot of first slot.'); 69 | } 70 | 71 | return $this->previous; 72 | } 73 | 74 | public function isLast(): bool 75 | { 76 | return null === $this->next; 77 | } 78 | 79 | public function getNext(): Slot 80 | { 81 | if (null === $this->next) { 82 | throw new \DomainException('Cannot fetch next slot of last slot.'); 83 | } 84 | 85 | return $this->next; 86 | } 87 | 88 | public function setNext(Slot $next): void 89 | { 90 | $this->next = $next; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Entity/Talk.php: -------------------------------------------------------------------------------- 1 | speaker; 38 | } 39 | 40 | #[Groups('searchable')] 41 | public function getDescription(): string 42 | { 43 | return $this->description; 44 | } 45 | 46 | public function getTrack(): string 47 | { 48 | return $this->track->value; 49 | } 50 | 51 | public function isOver(\DateTimeImmutable $now): bool 52 | { 53 | return $now > $this->getTimeSpan()->getEnd(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Entity/TimeSpan.php: -------------------------------------------------------------------------------- 1 | start = $this->enforceParisTimeZone($this->start); 19 | $this->end = $this->enforceParisTimeZone($this->end); 20 | 21 | if ($this->start > $this->end) { 22 | throw new \InvalidArgumentException('The time span needs to start before it ends.'); 23 | } 24 | } 25 | 26 | public function getStart(): \DateTimeImmutable 27 | { 28 | return $this->enforceParisTimeZone($this->start); 29 | } 30 | 31 | public function getEnd(): \DateTimeImmutable 32 | { 33 | return $this->enforceParisTimeZone($this->end); 34 | } 35 | 36 | public function toString(): string 37 | { 38 | return sprintf('%s - %s', $this->getStart()->format('M d: H:i'), $this->getEnd()->format('H:i')); 39 | } 40 | 41 | private function enforceParisTimeZone(\DateTimeImmutable $dateTime): \DateTimeImmutable 42 | { 43 | return $dateTime->setTimezone(new \DateTimeZone('Europe/Paris')); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Entity/Track.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class SlotRepository extends ServiceEntityRepository 18 | { 19 | public function __construct(private readonly Shortener $shortener, ManagerRegistry $registry) 20 | { 21 | parent::__construct($registry, Slot::class); 22 | } 23 | 24 | public function findByShortId(string $shortId): ?Slot 25 | { 26 | try { 27 | return $this->find($this->shortener->expand($shortId)); 28 | } catch (DictionaryException) { 29 | return null; 30 | } 31 | } 32 | 33 | /** 34 | * @return Slot[] 35 | */ 36 | public function findByDay(\DateTimeImmutable $date): array 37 | { 38 | $day = $date->format('Y-m-d'); 39 | 40 | $queryBuilder = $this->createQueryBuilder('s'); 41 | $query = $queryBuilder 42 | ->where($queryBuilder->expr()->like('s.timeSpan.start', ':start')) 43 | ->setParameter('start', sprintf('%s%%', $day)) 44 | ->orderBy('s.timeSpan.start', 'ASC') 45 | ->getQuery() 46 | ->enableResultCache(3600, sprintf('schedule-%s', $day)); 47 | 48 | return $query->getResult(); 49 | } 50 | 51 | public function findByTime(\DateTimeImmutable $time): ?Slot 52 | { 53 | $queryBuilder = $this->createQueryBuilder('s'); 54 | $query = $queryBuilder 55 | ->where($queryBuilder->expr()->between(':time', 's.timeSpan.start', 's.timeSpan.end')) 56 | ->setParameter('time', $time) 57 | ->getQuery(); 58 | 59 | try { 60 | return $query->getSingleResult(); 61 | } catch (NoResultException) { 62 | return null; 63 | } 64 | } 65 | 66 | public function findFirst(): ?Slot 67 | { 68 | return $this->findOneBy([], ['timeSpan.start' => 'ASC']); 69 | } 70 | 71 | /** 72 | * @phpstan-return array{start: \DateTimeImmutable, end: \DateTimeImmutable} 73 | */ 74 | public function getTimeSpan(): array 75 | { 76 | return $this->createQueryBuilder('s') 77 | ->select('new DateTimeImmutable(min(s.timeSpan.start)) as start') 78 | ->addSelect('new DateTimeImmutable(max(s.timeSpan.end)) as end') 79 | ->getQuery() 80 | ->getSingleResult(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Repository/TalkRepository.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class TalkRepository extends ServiceEntityRepository 17 | { 18 | public function __construct(private readonly Shortener $shortener, ManagerRegistry $registry) 19 | { 20 | parent::__construct($registry, Talk::class); 21 | } 22 | 23 | public function findByShortId(string $shortId): ?Talk 24 | { 25 | try { 26 | return $this->find($this->shortener->expand($shortId)); 27 | } catch (DictionaryException) { 28 | return null; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/SymfonyCon/Analyzer.php: -------------------------------------------------------------------------------- 1 | > 17 | */ 18 | public function createAnalysis(): array 19 | { 20 | $query = <<connection->executeQuery($query)->fetchAllAssociative(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/SymfonyCon/Crawler.php: -------------------------------------------------------------------------------- 1 | client->getSchedule(); 23 | 24 | foreach ($this->parser->extractSlots($response) as $slot) { 25 | $this->entityManager->persist($slot); 26 | } 27 | 28 | $this->entityManager->flush(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/SymfonyCon/Crawler/Client.php: -------------------------------------------------------------------------------- 1 | httpClient->request('GET', 'https://live.symfony.com/2022-paris-con/schedule'); 18 | 19 | return $response->getContent(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/SymfonyCon/Crawler/Parser.php: -------------------------------------------------------------------------------- 1 | Track::SymfonyRoom, 20 | '1' => Track::FrameworkRoom, 21 | '2' => Track::PlatformRoom, 22 | ]; 23 | 24 | public function __construct(private readonly LoggerInterface $logger = new NullLogger()) 25 | { 26 | } 27 | 28 | /** 29 | * @return list 30 | */ 31 | public function extractSlots(string $response): array 32 | { 33 | $crawler = new Crawler($response); 34 | $slots = []; 35 | /** @var ?Slot $prevSlot */ 36 | $prevSlot = null; 37 | 38 | // Extract slots 39 | $crawler->filter('.schedule-row')->each(function (Crawler $row) use ($crawler, &$slots, &$prevSlot) { 40 | $startsAt = $row->filter('.schedule-time')->attr('data-starts-at'); 41 | $endsAt = $row->filter('.schedule-time')->attr('data-ends-at'); 42 | 43 | if (null === $startsAt || null === $endsAt) { 44 | $this->logger->warning('Cannot collect start or end time for slot'); 45 | 46 | return; 47 | } 48 | 49 | $start = new \DateTimeImmutable($startsAt); 50 | $end = new \DateTimeImmutable($endsAt); 51 | 52 | $timeSpan = new TimeSpan($start, $end); 53 | $slot = new Slot($timeSpan, $prevSlot); 54 | $prevSlot?->setNext($slot); 55 | 56 | // Extract events 57 | $row->filter('.schedule-event')->each(function (Crawler $event) use ($slot, $timeSpan) { 58 | $title = $event->filter('.schedule-event-title')->text(); 59 | 60 | $slot->addEvent(new Event($title, $timeSpan, $slot)); 61 | }); 62 | 63 | // Extract talks 64 | $row->filter('.schedule-talk')->each(function (Crawler $talk, int $index) use ($crawler, $slot, $timeSpan) { 65 | $title = $talk->filter('.schedule-talk-title')->text(); 66 | $id = $talk->filter('.schedule-talk-title')->attr('href'); 67 | 68 | $slot->addEvent(new Talk( 69 | $title, 70 | $talk->filter('.schedule-talk-author')->text(), 71 | null !== $id ? $crawler->filter('.schedule-list '.$id.' .editable-content')->text() : '', 72 | $timeSpan, 73 | '1' === $talk->attr('colspan') ? self::TRACKS[$index] : Track::SymfonyRoom, 74 | $slot, 75 | )); 76 | }); 77 | 78 | $slots[] = $slot; 79 | $prevSlot = $slot; 80 | }); 81 | 82 | return $slots; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/SymfonyCon/Schedule.php: -------------------------------------------------------------------------------- 1 | slotRepository->findByTime($this->timer->getNow()); 21 | } 22 | 23 | public function next(): ?Slot 24 | { 25 | if ($this->timer->isOver()) { 26 | return null; 27 | } 28 | 29 | return $this->timer->isRunning() ? $this->now()?->getNext() : $this->slotRepository->findFirst(); 30 | } 31 | 32 | /** 33 | * @return list 34 | */ 35 | public function today(): array 36 | { 37 | return $this->slotRepository->findByDay($this->timer->getNow()); 38 | } 39 | 40 | /** 41 | * @return list 42 | */ 43 | public function day1(): array 44 | { 45 | return $this->slotRepository->findByDay($this->timer->getStart()); 46 | } 47 | 48 | /** 49 | * @return list 50 | */ 51 | public function day2(): array 52 | { 53 | return $this->slotRepository->findByDay($this->timer->getEnd()); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/SymfonyCon/Search.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | public function search(string $query): array 23 | { 24 | /** @var list $talks */ 25 | $talks = $this->searchService->search($this->entityManager, Talk::class, $query); 26 | 27 | return $talks; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/SymfonyCon/Timer.php: -------------------------------------------------------------------------------- 1 | start; 19 | } 20 | 21 | public function getEnd(): \DateTimeImmutable 22 | { 23 | return $this->end; 24 | } 25 | 26 | public function getNow(): \DateTimeImmutable 27 | { 28 | return $this->now; 29 | } 30 | 31 | public function isOver(): bool 32 | { 33 | return $this->now >= $this->end; 34 | } 35 | 36 | public function hasStarted(): bool 37 | { 38 | return $this->now >= $this->start; 39 | } 40 | 41 | public function isRunning(): bool 42 | { 43 | return $this->hasStarted() && !$this->isOver(); 44 | } 45 | 46 | public function getCountdown(): \DateInterval 47 | { 48 | $delta = $this->hasStarted() ? $this->start : $this->now; 49 | 50 | return $this->start->diff($delta); 51 | } 52 | 53 | public function startsToday(): bool 54 | { 55 | return $this->start->format('m/d/y') === $this->now->format('m/d/y'); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/SymfonyCon/TimerFactory.php: -------------------------------------------------------------------------------- 1 | $start, 'end' => $end] = $this->repository->getTimeSpan(); 21 | 22 | return new Timer($start, $end, $this->clock->now()); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Twig/ShortExtension.php: -------------------------------------------------------------------------------- 1 | shortener, 'reduce']), 21 | ]; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /telegram-bot-api.http: -------------------------------------------------------------------------------- 1 | POST https://api.telegram.org/{{bot-token}}/setMyCommands 2 | Content-Type: application/json 3 | 4 | { 5 | "commands": [ 6 | {"command": "test", "description": "Hello"} 7 | ] 8 | } 9 | 10 | ### 11 | 12 | POST https://api.telegram.org/{{bot-token}}/sendDice 13 | Content-Type: application/json 14 | 15 | { 16 | "chat_id": "{{chat-id}}" 17 | } 18 | 19 | ### 20 | 21 | POST https://api.telegram.org/{{bot-token}}/sendMessage 22 | Content-Type: application/json 23 | 24 | { 25 | "chat_id": "{{chat-id}}", 26 | "text": "Nov 17th - 09:10-09:45", 27 | "reply_markup": { 28 | "inline_keyboard": [ 29 | [ 30 | { 31 | "text": "How to lazy load ABC in PHP", 32 | "callback_data": "how-to-lazy-load" 33 | } 34 | ],[ 35 | { 36 | "text": "Transactional vs. Analytical Processing", 37 | "callback_data": "transactional-vs-analytical" 38 | } 39 | ] 40 | ] 41 | } 42 | } 43 | 44 | ### 45 | 46 | POST https://api.telegram.org/{{bot-token}}/sendMessage 47 | Content-Type: application/json 48 | 49 | { 50 | "chat_id": "{{chat-id}}", 51 | "parse_mode": "Markdown", 52 | "text": "Hello [Test](https://t.me/SymfonyConTestBot?start=Testing)" 53 | } 54 | 55 | ### 56 | 57 | POST https://api.telegram.org/{{bot-token}}/sendMessage 58 | Content-Type: application/json 59 | 60 | { 61 | "chat_id": "{{chat-id}}", 62 | "parse_mode": "Markdown", 63 | "text": "More: /slot@test" 64 | } 65 | 66 | ### 67 | 68 | POST https://api.telegram.org/{{bot-token}}/editMessageText 69 | Content-Type: application/json 70 | 71 | { 72 | "chat_id": "{{chat-id}}", 73 | "message_id": 542, 74 | "text": "Hello Whoop Whoop", 75 | "reply_markup": { 76 | "inline_keyboard": [ 77 | [ 78 | { 79 | "text": "How to lazy load ABC in PHP", 80 | "callback_data": "how-to-lazy-load" 81 | } 82 | ],[ 83 | { 84 | "text": "Transactional vs. Analytical Processing", 85 | "callback_data": "transactional-vs-analytical" 86 | } 87 | ] 88 | ] 89 | } 90 | } 91 | 92 | ### 93 | -------------------------------------------------------------------------------- /templates/search.html.twig: -------------------------------------------------------------------------------- 1 | Results for "{{ query }}" 2 | 3 | {# @var \App\Entity\Talk talk #} 4 | {% for talk in talks %} 5 | • {{ talk.title }} 6 | {{ talk.timeSpan.toString }} 7 | » /talk@{{ talk.id|short }} 8 | 9 | {% else %} 10 | Nothing found! 11 | {% endfor %} 12 | -------------------------------------------------------------------------------- /templates/talk.html.twig: -------------------------------------------------------------------------------- 1 | {{ talk.title }} 2 | by {{ talk.speaker }} 3 | 4 | When {{ talk.timeSpan.toString }} 5 | Where {{ talk.track }} 6 | 7 | {{ talk.description }} 8 | -------------------------------------------------------------------------------- /tests/ChatBot/ChatBotTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder(ChatterInterface::class)->getMock(); 29 | 30 | $chatter 31 | ->expects(self::once()) 32 | ->method('send') 33 | ->with(self::isInstanceOf(ChatMessage::class)); 34 | 35 | $update = new Update(); 36 | $update->message = new Message(); 37 | $update->message->text = '/help'; 38 | $update->message->chat = new Chat(); 39 | $update->message->chat->id = 1234; 40 | 41 | $chatBot = new ChatBot($replyMachine, $chatter); 42 | $chatBot->consume($update); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/ChatBot/Replier/CountdownReplierTest.php: -------------------------------------------------------------------------------- 1 | text = '/countdown'; 21 | $update = new Update(); 22 | $update->message = $message; 23 | 24 | self::assertTrue($this->replier->supports($update)); 25 | } 26 | 27 | public function testNotSupportingRandomMessage(): void 28 | { 29 | $message = new Message(); 30 | $message->text = '/help'; 31 | $update = new Update(); 32 | $update->message = $message; 33 | 34 | self::assertFalse($this->replier->supports($update)); 35 | } 36 | 37 | public function testCountdownText(): void 38 | { 39 | $expectedText = 'Only 2 days, 12 hours and 45 minutes until SymfonyCon starts.'; 40 | 41 | $chatMessage = $this->replier->reply(new Update()); 42 | self::assertSame($expectedText, $chatMessage->getSubject()); 43 | } 44 | 45 | protected function setUp(): void 46 | { 47 | $timer = new Timer( 48 | new \DateTimeImmutable('11/21/19 08:00'), 49 | new \DateTimeImmutable('11/22/19 17:00'), 50 | new \DateTimeImmutable('11/18/19 19:15') 51 | ); 52 | $this->replier = new CountdownReplier($timer); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/ChatBot/Replier/Day1ReplierTest.php: -------------------------------------------------------------------------------- 1 | text = '/day1'; 26 | $update = new Update(); 27 | $update->message = $message; 28 | 29 | self::assertTrue($this->replier->supports($update)); 30 | } 31 | 32 | public function testNotSupportingRandomMessage(): void 33 | { 34 | $message = new Message(); 35 | $message->text = '/day2'; 36 | $update = new Update(); 37 | $update->message = $message; 38 | 39 | self::assertFalse($this->replier->supports($update)); 40 | } 41 | 42 | public function testDay1Reply(): void 43 | { 44 | $this->schedule 45 | ->expects(self::once()) 46 | ->method('day1'); 47 | 48 | $chatMessage = $this->replier->reply(new Update()); 49 | 50 | self::assertInstanceOf(ChatMessage::class, $chatMessage); 51 | } 52 | 53 | protected function setUp(): void 54 | { 55 | $this->schedule = $this->getMockBuilder(Schedule::class) 56 | ->disableOriginalConstructor() 57 | ->getMock(); 58 | $renderer = $this->createMock(DayRenderer::class); 59 | $this->replier = new Day1Replier($this->schedule, $renderer); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/ChatBot/Replier/Day2ReplierTest.php: -------------------------------------------------------------------------------- 1 | text = '/day2'; 26 | $update = new Update(); 27 | $update->message = $message; 28 | 29 | self::assertTrue($this->replier->supports($update)); 30 | } 31 | 32 | public function testNotSupportingRandomMessage(): void 33 | { 34 | $message = new Message(); 35 | $message->text = '/day1'; 36 | $update = new Update(); 37 | $update->message = $message; 38 | 39 | self::assertFalse($this->replier->supports($update)); 40 | } 41 | 42 | public function testDay2Reply(): void 43 | { 44 | $this->schedule 45 | ->expects(self::once()) 46 | ->method('day2'); 47 | 48 | $chatMessage = $this->replier->reply(new Update()); 49 | 50 | self::assertInstanceOf(ChatMessage::class, $chatMessage); 51 | } 52 | 53 | protected function setUp(): void 54 | { 55 | $this->schedule = $this->getMockBuilder(Schedule::class) 56 | ->disableOriginalConstructor() 57 | ->getMock(); 58 | $renderer = $this->createMock(DayRenderer::class); 59 | $this->replier = new Day2Replier($this->schedule, $renderer); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/ChatBot/Replier/HelpReplierTest.php: -------------------------------------------------------------------------------- 1 | text = '/help'; 20 | $update = new Update(); 21 | $update->message = $message; 22 | 23 | self::assertTrue($this->replier->supports($update)); 24 | } 25 | 26 | public function testNotSupportingRandomMessage(): void 27 | { 28 | $message = new Message(); 29 | $message->text = '/countdown'; 30 | $update = new Update(); 31 | $update->message = $message; 32 | 33 | self::assertFalse($this->replier->supports($update)); 34 | } 35 | 36 | public function testHelpText(): void 37 | { 38 | $chatMessage = $this->replier->reply(new Update()); 39 | 40 | self::assertStringContainsString('/countdown', $chatMessage->getSubject()); 41 | self::assertStringContainsString('/day1', $chatMessage->getSubject()); 42 | self::assertStringContainsString('/day2', $chatMessage->getSubject()); 43 | self::assertStringContainsString('/today', $chatMessage->getSubject()); 44 | self::assertStringContainsString('/now', $chatMessage->getSubject()); 45 | self::assertStringContainsString('/next', $chatMessage->getSubject()); 46 | } 47 | 48 | protected function setUp(): void 49 | { 50 | $this->replier = new HelpReplier(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/ChatBot/Replier/NextReplierTest.php: -------------------------------------------------------------------------------- 1 | text = '/next'; 28 | $update = new Update(); 29 | $update->message = $message; 30 | 31 | self::assertTrue($this->replier->supports($update)); 32 | } 33 | 34 | public function testNotSupportingRandomMessage(): void 35 | { 36 | $message = new Message(); 37 | $message->text = '/now'; 38 | $update = new Update(); 39 | $update->message = $message; 40 | 41 | self::assertFalse($this->replier->supports($update)); 42 | } 43 | 44 | public function testNextReply(): void 45 | { 46 | $this->schedule 47 | ->expects(self::once()) 48 | ->method('next'); 49 | 50 | $chatMessage = $this->replier->reply(new Update()); 51 | 52 | self::assertInstanceOf(ChatMessage::class, $chatMessage); 53 | } 54 | 55 | protected function setUp(): void 56 | { 57 | $this->setUpSlotRenderer(); 58 | $this->schedule = $this->getMockBuilder(Schedule::class) 59 | ->disableOriginalConstructor() 60 | ->getMock(); 61 | $this->replier = new NextReplier($this->schedule, $this->slotRenderer); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/ChatBot/Replier/NowReplierTest.php: -------------------------------------------------------------------------------- 1 | text = '/now'; 28 | $update = new Update(); 29 | $update->message = $message; 30 | 31 | self::assertTrue($this->replier->supports($update)); 32 | } 33 | 34 | public function testNotSupportingRandomMessage(): void 35 | { 36 | $message = new Message(); 37 | $message->text = '/next'; 38 | $update = new Update(); 39 | $update->message = $message; 40 | 41 | self::assertFalse($this->replier->supports($update)); 42 | } 43 | 44 | public function testNowReply(): void 45 | { 46 | $this->schedule 47 | ->expects(self::once()) 48 | ->method('now'); 49 | 50 | $chatMessage = $this->replier->reply(new Update()); 51 | 52 | self::assertInstanceOf(ChatMessage::class, $chatMessage); 53 | } 54 | 55 | protected function setUp(): void 56 | { 57 | $this->setUpSlotRenderer(); 58 | $this->schedule = $this->getMockBuilder(Schedule::class) 59 | ->disableOriginalConstructor() 60 | ->getMock(); 61 | $this->replier = new NowReplier($this->schedule, $this->slotRenderer); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/ChatBot/Replier/SearchReplierTest.php: -------------------------------------------------------------------------------- 1 | setUpTwig(); 27 | $this->search = $this->getMockBuilder(Search::class) 28 | ->disableOriginalConstructor() 29 | ->getMock(); 30 | $this->replier = new SearchReplier($this->search, $this->environment); 31 | } 32 | 33 | public function testSupportingTodayMessage(): void 34 | { 35 | $message = new Message(); 36 | $message->text = '/search'; 37 | $update = new Update(); 38 | $update->message = $message; 39 | 40 | self::assertTrue($this->replier->supports($update)); 41 | } 42 | 43 | public function testNotSupportingRandomMessage(): void 44 | { 45 | $message = new Message(); 46 | $message->text = '/start'; 47 | $update = new Update(); 48 | $update->message = $message; 49 | 50 | self::assertFalse($this->replier->supports($update)); 51 | } 52 | 53 | public function testSearchReplyWithoutQuery(): void 54 | { 55 | $message = new Message(); 56 | $message->text = '/search '; 57 | $update = new Update(); 58 | $update->message = $message; 59 | 60 | $this->search 61 | ->expects(self::never()) 62 | ->method('search'); 63 | 64 | $chatMessage = $this->replier->reply($update); 65 | 66 | self::assertInstanceOf(ChatMessage::class, $chatMessage); 67 | self::assertSame('Please add a search term, like "/search Symfony 6.2".', $chatMessage->getSubject()); 68 | } 69 | 70 | public function testSearchReplyWithQuery(): void 71 | { 72 | $message = new Message(); 73 | $message->text = '/search testing'; 74 | $update = new Update(); 75 | $update->message = $message; 76 | 77 | $this->search 78 | ->expects(self::once()) 79 | ->method('search') 80 | ->with('testing'); 81 | 82 | $chatMessage = $this->replier->reply($update); 83 | 84 | self::assertInstanceOf(ChatMessage::class, $chatMessage); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/ChatBot/Replier/StartReplierTest.php: -------------------------------------------------------------------------------- 1 | text = '/start'; 21 | $update = new Update(); 22 | $update->message = $message; 23 | 24 | self::assertTrue($this->replier->supports($update)); 25 | } 26 | 27 | public function testNotSupportingRandomMessage(): void 28 | { 29 | $message = new Message(); 30 | $message->text = '/countdown'; 31 | $update = new Update(); 32 | $update->message = $message; 33 | 34 | self::assertFalse($this->replier->supports($update)); 35 | } 36 | 37 | public function testStartText(): void 38 | { 39 | $user = new User(); 40 | $user->firstName = 'Chris'; 41 | $message = new Message(); 42 | $message->from = $user; 43 | $update = new Update(); 44 | $update->message = $message; 45 | 46 | $expectedText = 'Welcome to SymfonyConBot, Chris! :)'.PHP_EOL.PHP_EOL.'Use /help to see all commands.'; 47 | $chatMessage = $this->replier->reply($update); 48 | 49 | self::assertSame($expectedText, $chatMessage->getSubject()); 50 | } 51 | 52 | protected function setUp(): void 53 | { 54 | $this->replier = new StartReplier(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tests/ChatBot/Replier/TodayReplierTest.php: -------------------------------------------------------------------------------- 1 | text = '/today'; 26 | $update = new Update(); 27 | $update->message = $message; 28 | 29 | self::assertTrue($this->replier->supports($update)); 30 | } 31 | 32 | public function testNotSupportingRandomMessage(): void 33 | { 34 | $message = new Message(); 35 | $message->text = '/start'; 36 | $update = new Update(); 37 | $update->message = $message; 38 | 39 | self::assertFalse($this->replier->supports($update)); 40 | } 41 | 42 | public function testTodayReply(): void 43 | { 44 | $this->schedule 45 | ->expects(self::once()) 46 | ->method('today'); 47 | 48 | $chatMessage = $this->replier->reply(new Update()); 49 | 50 | self::assertInstanceOf(ChatMessage::class, $chatMessage); 51 | } 52 | 53 | protected function setUp(): void 54 | { 55 | $this->schedule = $this->getMockBuilder(Schedule::class) 56 | ->disableOriginalConstructor() 57 | ->getMock(); 58 | $renderer = $this->createMock(DayRenderer::class); 59 | $this->replier = new TodayReplier($this->schedule, $renderer); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/ChatBot/ReplyMachineTest.php: -------------------------------------------------------------------------------- 1 | firstName = 'Chris'; 31 | $message = new Message(); 32 | $message->text = $text; 33 | $message->from = $user; 34 | $update = new Update(); 35 | $update->message = $message; 36 | 37 | $chatMessage = $this->replyMachine->findReply($update); 38 | self::assertStringStartsWith($expectedReply, $chatMessage->getSubject()); 39 | } 40 | 41 | /** 42 | * @return array 43 | */ 44 | public static function provideValidMessage(): array 45 | { 46 | return [ 47 | ['/start', 'Welcome to SymfonyConBot, Chris! :)'.PHP_EOL.PHP_EOL.'Use /help to see all commands.'], 48 | ['/help', 'SymfonyConBot Help'.PHP_EOL.'This bot will help you to keep on track with all talks at SymfonyCon Disneyland Paris 2022.'], 49 | ['/countdown', 'Only 2 days, 12 hours and 45 minutes until SymfonyCon starts.'], 50 | ]; 51 | } 52 | 53 | public function testInvalidMessageGetsReply(): void 54 | { 55 | $message = new Message(); 56 | $message->text = '/invalid'; 57 | $update = new Update(); 58 | $update->message = $message; 59 | 60 | $chatMessage = $this->replyMachine->findReply($update); 61 | self::assertSame('Sorry, I didn\'t get that!'.PHP_EOL.PHP_EOL.'Please try /help instead!', $chatMessage->getSubject()); 62 | } 63 | 64 | protected function setUp(): void 65 | { 66 | $timer = new Timer( 67 | new \DateTimeImmutable('11/21/19 08:00'), 68 | new \DateTimeImmutable('11/22/19 17:00'), 69 | new \DateTimeImmutable('11/18/19 19:15'), 70 | ); 71 | $replier = [ 72 | new StartReplier(), 73 | new CountdownReplier($timer), 74 | new HelpReplier(), 75 | ]; 76 | 77 | $this->replyMachine = new ReplyMachine($replier); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /tests/ChatBot/Telegram/ClientTest.php: -------------------------------------------------------------------------------- 1 | createMock(HttpClientInterface::class); 20 | $httpClient 21 | ->expects($this->once()) 22 | ->method('request') 23 | ->with( 24 | 'POST', 25 | 'https://api.telegram.org/bottoken:1234567890/setWebhook', 26 | ['json' => ['url' => 'https://www.example.com/chatbot']] 27 | ); 28 | 29 | $urlGenerator = new CompiledUrlGenerator( 30 | ['webhook' => [[], ['_controller' => 'App\\Controller\\WebhookController::connect'], [], [['text', '/chatbot']], [], []]], 31 | new RequestContext() 32 | ); 33 | 34 | $client = new Client($httpClient, $urlGenerator, 'https://www.example.com', 'token:1234567890'); 35 | $client->registerWebhook(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/ChatBot/Telegram/Data/UpdateTest.php: -------------------------------------------------------------------------------- 1 | expectException(\RuntimeException::class); 19 | $this->expectExceptionMessage('Unable to extract message.'); 20 | 21 | $update->getMessage(); 22 | } 23 | 24 | public function testRetrieveCallbackMessage(): void 25 | { 26 | $update = new Update(); 27 | 28 | $callbackMessage = new Message(); 29 | $callback = new CallbackQuery(); 30 | $callback->message = $callbackMessage; 31 | $update->callbackQuery = $callback; 32 | 33 | self::assertSame($callbackMessage, $update->getMessage()); 34 | } 35 | 36 | public function testRetrieveEditedMessage(): void 37 | { 38 | $update = new Update(); 39 | 40 | $editedMessage = new Message(); 41 | $update->editedMessage = $editedMessage; 42 | 43 | self::assertSame($editedMessage, $update->getMessage()); 44 | } 45 | 46 | public function testRetrieveMessage(): void 47 | { 48 | $update = new Update(); 49 | 50 | $message = new Message(); 51 | $update->message = $message; 52 | 53 | self::assertSame($message, $update->getMessage()); 54 | } 55 | 56 | public function testRetrieveMessageBeforeEditedMessage(): void 57 | { 58 | $update = new Update(); 59 | 60 | $message = new Message(); 61 | $update->message = $message; 62 | 63 | $editedMessage = new Message(); 64 | $update->editedMessage = $editedMessage; 65 | 66 | self::assertSame($message, $update->getMessage()); 67 | } 68 | 69 | public function testRetrieveMessageBeforeEditedMessageBeforeCallbackMessage(): void 70 | { 71 | $update = new Update(); 72 | 73 | $message = new Message(); 74 | $update->message = $message; 75 | 76 | $editedMessage = new Message(); 77 | $update->editedMessage = $editedMessage; 78 | 79 | $callbackMessage = new Message(); 80 | $callback = new CallbackQuery(); 81 | $callback->message = $callbackMessage; 82 | $update->callbackQuery = $callback; 83 | 84 | self::assertSame($message, $update->getMessage()); 85 | } 86 | 87 | public function testRetrieveEditedMessageBeforeCallbackMessage(): void 88 | { 89 | $update = new Update(); 90 | 91 | $editedMessage = new Message(); 92 | $update->editedMessage = $editedMessage; 93 | 94 | $callbackMessage = new Message(); 95 | $callback = new CallbackQuery(); 96 | $callback->message = $callbackMessage; 97 | $update->callbackQuery = $callback; 98 | 99 | self::assertSame($editedMessage, $update->getMessage()); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /tests/Command/WebhookRegisterCommandTest.php: -------------------------------------------------------------------------------- 1 | telegramClient 25 | ->expects($this->never()) 26 | ->method('registerWebhook'); 27 | 28 | $this->commandTester->setInputs(['no']); 29 | $this->commandTester->execute([]); 30 | 31 | $output = $this->commandTester->getDisplay(); 32 | self::assertStringContainsString('Registering Telegram Webhook', $output); 33 | self::assertStringContainsString('Really want to replace the webhook?', $output); 34 | self::assertStringNotContainsString('Done', $output); 35 | 36 | self::assertSame(0, $this->commandTester->getStatusCode()); 37 | } 38 | 39 | public function testWebhookNotRegisteredOnNoInteraction(): void 40 | { 41 | $this->telegramClient 42 | ->expects($this->never()) 43 | ->method('registerWebhook'); 44 | 45 | $this->commandTester->execute([], ['interactive' => false]); 46 | 47 | $output = $this->commandTester->getDisplay(); 48 | self::assertStringContainsString('Registering Telegram Webhook', $output); 49 | self::assertStringNotContainsString('Really want to replace the webhook?', $output); 50 | self::assertStringNotContainsString('Done', $output); 51 | 52 | self::assertSame(0, $this->commandTester->getStatusCode()); 53 | } 54 | 55 | public function testWebhookRegisteredOnConfirmation(): void 56 | { 57 | $this->telegramClient 58 | ->expects($this->once()) 59 | ->method('registerWebhook'); 60 | 61 | $this->commandTester->setInputs(['yes']); 62 | $this->commandTester->execute([]); 63 | 64 | $output = $this->commandTester->getDisplay(); 65 | self::assertStringContainsString('Registering Telegram Webhook', $output); 66 | self::assertStringContainsString('Really want to replace the webhook?', $output); 67 | self::assertStringContainsString('Done', $output); 68 | 69 | self::assertSame(0, $this->commandTester->getStatusCode()); 70 | } 71 | 72 | protected function setUp(): void 73 | { 74 | $this->telegramClient = $this->createMock(Client::class); 75 | 76 | $command = new WebhookRegisterCommand($this->telegramClient); 77 | $this->commandTester = new CommandTester($command); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /tests/ConferenceFixtures.php: -------------------------------------------------------------------------------- 1 | load($this->entityManager); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/Controller/WebhookControllerTest.php: -------------------------------------------------------------------------------- 1 | client = self::createClient(); 28 | /** @var Registry $registry */ 29 | $registry = $this->client->getContainer()->get('doctrine'); 30 | /** @var EntityManagerInterface $manager */ 31 | $manager = $registry->getManager(); 32 | $this->entityManager = $manager; 33 | 34 | $this->setUpSchema(); 35 | $this->setUpFixtures(); 36 | } 37 | 38 | public function testSuccessfulStartMessage(): void 39 | { 40 | $message = (string) file_get_contents(__DIR__.'/fixtures/start.json'); 41 | 42 | $this->client->request('POST', '/chatbot', [], [], [], $message); 43 | 44 | self::assertResponseIsSuccessful(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/Controller/fixtures/start.json: -------------------------------------------------------------------------------- 1 | { 2 | "update_id":4561337187, 3 | "message":{ 4 | "message_id":123, 5 | "from":{ 6 | "id":12345, 7 | "is_bot":false, 8 | "first_name":"Jane", 9 | "last_name":"Doe", 10 | "language_code":"de" 11 | }, 12 | "chat":{ 13 | "id":12345, 14 | "first_name":"Jane", 15 | "last_name":"Doe", 16 | "type":"private" 17 | }, 18 | "date":1598096057, 19 | "text":"/start", 20 | "entities":[ 21 | { 22 | "offset":0, 23 | "length":6, 24 | "type":"bot_command" 25 | } 26 | ] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Entity/AttendanceTest.php: -------------------------------------------------------------------------------- 1 | modify('+45 minutes')); 20 | $talk = new Talk('Working with events', 'John Doe', 'This is a dummy', $timeSpan, Track::SymfonyRoom, new Slot($timeSpan)); 21 | $attendance = new Attendance($talk, 1234567890); 22 | 23 | self::assertSame($talk, $attendance->getTalk()); 24 | self::assertSame(1234567890, $attendance->getAttendee()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/Entity/AttendeeRatingTest.php: -------------------------------------------------------------------------------- 1 | modify('+45 minutes')); 21 | $talk = new Talk('Working with events', 'John Doe', 'This is a dummy', $timeSpan, Track::SymfonyRoom, new Slot($timeSpan)); 22 | $rating = new AttendeeRating($talk, 1234567890, Rating::TwoStars); 23 | 24 | self::assertSame($talk, $rating->getTalk()); 25 | self::assertSame(1234567890, $rating->getAttendee()); 26 | self::assertSame(Rating::TwoStars, $rating->getRating()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Entity/EventTest.php: -------------------------------------------------------------------------------- 1 | modify('+45 minutes')); 18 | $slot = new Slot($timeSpan); 19 | $event = new Event('Lunch Break', $timeSpan, $slot); 20 | 21 | $uuidPattern = '#[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}#'; 22 | self::assertMatchesRegularExpression($uuidPattern, $event->getId()); 23 | self::assertSame('Lunch Break', $event->getTitle()); 24 | self::assertSame($timeSpan, $event->getTimeSpan()); 25 | self::assertSame($slot, $event->getSlot()); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/Entity/SlotTest.php: -------------------------------------------------------------------------------- 1 | modify('+45 minutes')); 20 | $this->slot = new Slot($timeSpan); 21 | } 22 | 23 | public function testConstructWithoutSlots(): void 24 | { 25 | $start = new \DateTimeImmutable('12.12.2012 12:12'); 26 | $timeSpan = new TimeSpan($start, $start->modify('+45 minutes')); 27 | 28 | $uuidPattern = '#[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}#'; 29 | self::assertMatchesRegularExpression($uuidPattern, $this->slot->getId()); 30 | self::assertEquals($timeSpan, $this->slot->getTimeSpan()); 31 | self::assertCount(0, $this->slot->getEvents()); 32 | } 33 | 34 | public function testConstructWithSlots(): void 35 | { 36 | $slotPrev = $this->slot; 37 | $startNext = new \DateTimeImmutable('12.12.2012 13:12'); 38 | $timeSpanNext = new TimeSpan($startNext, $startNext->modify('+45 minutes')); 39 | $slotNext = new Slot($timeSpanNext); 40 | $start = new \DateTimeImmutable('12.12.2012 14:12'); 41 | $timeSpan = new TimeSpan($start, $start->modify('+45 minutes')); 42 | $slot = new Slot($timeSpan, $slotPrev, $slotNext); 43 | 44 | self::assertSame($slotPrev, $slot->getPrevious()); 45 | self::assertSame($slotNext, $slot->getNext()); 46 | self::assertFalse($slot->isFirst()); 47 | self::assertFalse($slot->isLast()); 48 | } 49 | 50 | public function testExceptionOnGetterWithoutPrevious(): void 51 | { 52 | self::expectException(\DomainException::class); 53 | 54 | $this->slot->getPrevious(); 55 | } 56 | 57 | public function testExceptionOnGetterWithoutNext(): void 58 | { 59 | self::expectException(\DomainException::class); 60 | 61 | $this->slot->getNext(); 62 | } 63 | 64 | public function testSingleSlotIsFirstAndLast(): void 65 | { 66 | self::assertTrue($this->slot->isFirst()); 67 | self::assertTrue($this->slot->isLast()); 68 | } 69 | 70 | public function testSlotWithNextIsNotLast(): void 71 | { 72 | $slot = clone $this->slot; 73 | $slot->setNext($this->slot); 74 | 75 | self::assertFalse($slot->isLast()); 76 | } 77 | 78 | public function testSlotWithPreviousIsNotFirst(): void 79 | { 80 | $start = new \DateTimeImmutable('12.12.2012 10:12'); 81 | $timeSpan = new TimeSpan($start, $start->modify('+45 minutes')); 82 | $slot = new Slot($timeSpan, $this->slot); 83 | 84 | self::assertFalse($slot->isFirst()); 85 | } 86 | 87 | public function testAddingEvents(): void 88 | { 89 | $event = new Event('Test Event', $this->slot->getTimeSpan(), $this->slot); 90 | $this->slot->addEvent($event); 91 | 92 | $events = $this->slot->getEvents(); 93 | self::assertCount(1, $events); 94 | self::assertContainsOnlyInstancesOf(Event::class, $events); 95 | self::assertSame([$event], $events); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tests/Entity/TalkTest.php: -------------------------------------------------------------------------------- 1 | modify('+45 minutes')); 21 | $slot = new Slot($timeSpan); 22 | $this->talk = new Talk('Working with events', 'John Doe', 'This is a dummy', $timeSpan, Track::SymfonyRoom, $slot); 23 | } 24 | 25 | public function testConstruct(): void 26 | { 27 | $start = new \DateTimeImmutable('12.12.2012 12:12'); 28 | $timeSpan = new TimeSpan($start, $start->modify('+45 minutes')); 29 | 30 | $uuidPattern = '#[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}#'; 31 | self::assertMatchesRegularExpression($uuidPattern, $this->talk->getId()); 32 | self::assertSame('Working with events', $this->talk->getTitle()); 33 | self::assertSame('John Doe', $this->talk->getSpeaker()); 34 | self::assertSame('This is a dummy', $this->talk->getDescription()); 35 | self::assertEquals($timeSpan, $this->talk->getTimeSpan()); 36 | self::assertSame('The Symfony room', $this->talk->getTrack()); 37 | } 38 | 39 | public function testIsOver(): void 40 | { 41 | self::assertFalse($this->talk->isOver(new \DateTimeImmutable('11.12.2012 18:00'))); 42 | self::assertTrue($this->talk->isOver(new \DateTimeImmutable('12.12.2012 18:00'))); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Entity/TimeSpanTest.php: -------------------------------------------------------------------------------- 1 | getStart()); 19 | } 20 | 21 | public function testGetEnd(): void 22 | { 23 | $start = new \DateTimeImmutable('2020-11-21 09:10', new \DateTimeZone('Europe/Paris')); 24 | $end = new \DateTimeImmutable('2020-11-21 09:55', new \DateTimeZone('Europe/Paris')); 25 | $timeSpan = new TimeSpan($start, $end); 26 | 27 | self::assertEquals($end, $timeSpan->getEnd()); 28 | } 29 | 30 | public function testEndBeforeEndInvalid(): void 31 | { 32 | self::expectException(\InvalidArgumentException::class); 33 | self::expectExceptionMessage('The time span needs to start before it ends.'); 34 | 35 | $start = new \DateTimeImmutable('2020-11-21 07:10'); 36 | $end = new \DateTimeImmutable('2020-11-21 06:55'); 37 | new TimeSpan($start, $end); 38 | } 39 | 40 | public function testEnforcingParisTimeZone(): void 41 | { 42 | $start = new \DateTimeImmutable('2020-11-21 07:10'); 43 | $end = new \DateTimeImmutable('2020-11-21 09:55', new \DateTimeZone('Europe/Helsinki')); 44 | $timeSpan = new TimeSpan($start, $end); 45 | 46 | $expectedStart = new \DateTimeImmutable('2020-11-21 08:10', new \DateTimeZone('Europe/Paris')); 47 | $expectedEnd = new \DateTimeImmutable('2020-11-21 08:55', new \DateTimeZone('Europe/Paris')); 48 | 49 | self::assertEquals($expectedStart, $timeSpan->getStart()); 50 | self::assertEquals($expectedEnd, $timeSpan->getEnd()); 51 | } 52 | 53 | public function testToString(): void 54 | { 55 | $start = new \DateTimeImmutable('2020-11-21 09:10', new \DateTimeZone('Europe/Paris')); 56 | $end = new \DateTimeImmutable('2020-11-21 09:55', new \DateTimeZone('Europe/Paris')); 57 | $timeSpan = new TimeSpan($start, $end); 58 | 59 | self::assertSame('Nov 21: 09:10 - 09:55', $timeSpan->toString()); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/Renderer.php: -------------------------------------------------------------------------------- 1 | dayRenderer = new DayRenderer($this->createShortener()); 20 | } 21 | 22 | protected function setUpSlotRenderer(): void 23 | { 24 | $this->slotRenderer = new SlotRenderer($this->createShortener()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/Repository/SlotRepositoryTest.php: -------------------------------------------------------------------------------- 1 | setUpDatabase(); 23 | $this->setUpSchema(); 24 | $this->setUpFixtures(); 25 | } 26 | 27 | #[DataProvider('provideDays')] 28 | public function testFindByDay(string $date, int $expectedCount, string $cacheKey): void 29 | { 30 | $dateTime = new \DateTimeImmutable($date); 31 | $slots = $this->repository->findByDay($dateTime); 32 | 33 | self::assertCount($expectedCount, array_filter($slots, static function (Slot $slot) use ($dateTime) { 34 | $dayStart = $dateTime->modify('midnight'); 35 | $dayEnd = $dateTime->modify('midnight +1 day'); 36 | $start = $slot->getTimeSpan()->getStart(); 37 | 38 | return $dayStart < $start && $start < $dayEnd; 39 | })); 40 | self::assertTrue($this->resultCache->hasItem($cacheKey)); 41 | } 42 | 43 | /** 44 | * @return array 45 | */ 46 | public static function provideDays(): array 47 | { 48 | return [ 49 | 'day1' => ['11/21/19 08:00', 13, 'schedule-2019-11-21'], 50 | 'day2' => ['11/22/19 17:00', 12, 'schedule-2019-11-22'], 51 | ]; 52 | } 53 | 54 | /** 55 | * @param array $titles 56 | */ 57 | #[DataProvider('provideTimes')] 58 | public function testFindByTime(string $time, array $titles): void 59 | { 60 | $slot = $this->repository->findByTime(new \DateTimeImmutable($time)); 61 | self::assertInstanceOf(Slot::class, $slot); 62 | 63 | $events = $slot->getEvents(); 64 | self::assertCount(count($titles), $events); 65 | foreach ($events as $i => $event) { 66 | self::assertSame($titles[$i], $event->getTitle()); 67 | } 68 | } 69 | 70 | /** 71 | * @return array}> 72 | */ 73 | public static function provideTimes(): array 74 | { 75 | return [ 76 | 'day1_keynote' => ['11/21/19 09:36', ['Keynote']], 77 | 'day1_lunch' => ['11/21/19 13:15', ['Lunch']], 78 | 'day1_slot2' => ['11/21/19 11:32', ['How Doctrine caching can skyrocket your application', 'Using the Workflow component for e-commerce', 'Crazy Fun Experiments with PHP (Not for Production)']], 79 | 'day2_slot1' => ['11/22/19 10:08', ['Using API Platform to build ticketing system', 'Make the Most out of Twig', 'Mental Health in the Workplace']], 80 | 'day2_closing' => ['11/22/19 16:57', ['Closing session']], 81 | ]; 82 | } 83 | 84 | public function testGetTimeSpan(): void 85 | { 86 | $result = $this->repository->getTimeSpan(); 87 | 88 | self::assertArrayHasKey('start', $result); 89 | self::assertArrayHasKey('end', $result); 90 | self::assertEquals(new \DateTimeImmutable('11/21/19 8:00'), $result['start']); 91 | self::assertEquals(new \DateTimeImmutable('11/22/19 17:00'), $result['end']); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tests/SchemaSetup.php: -------------------------------------------------------------------------------- 1 | entityManager); 14 | $schemaTool->updateSchema($this->entityManager->getMetadataFactory()->getAllMetadata()); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/SymfonyCon/Crawler/ClientTest.php: -------------------------------------------------------------------------------- 1 | responseFactory(...)); 18 | $client = new Client($httpClient); 19 | 20 | $expectedFile = dirname(__DIR__).'/fixtures/full-schedule.html'; 21 | self::assertStringEqualsFile($expectedFile, $client->getSchedule()); 22 | } 23 | 24 | private function responseFactory(string $method, string $url): ResponseInterface 25 | { 26 | self::assertSame('GET', $method); 27 | self::assertSame('https://live.symfony.com/2022-paris-con/schedule', $url); 28 | 29 | return new MockResponse((string) file_get_contents(dirname(__DIR__).'/fixtures/full-schedule.html')); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/SymfonyCon/Crawler/ParserTest.php: -------------------------------------------------------------------------------- 1 | extractSlots($response); 24 | 25 | self::assertCount(27, $slots); 26 | } 27 | 28 | /** 29 | * @param list $expectedSlot 30 | */ 31 | #[DataProvider('provideSnippetsAndSlots')] 32 | public function testSlotExtractionWithData(string $fileName, array $expectedSlot): void 33 | { 34 | $parser = new Parser(); 35 | $snippet = (string) file_get_contents($fileName); 36 | 37 | $actualSlots = $parser->extractSlots($snippet); 38 | 39 | self::assertSameCollection($expectedSlot, $actualSlots); 40 | } 41 | 42 | /** 43 | * @return iterable}> 44 | */ 45 | public static function provideSnippetsAndSlots(): iterable 46 | { 47 | $timeSpan = new TimeSpan(new \DateTimeImmutable('17-11-2022 08:15'), new \DateTimeImmutable('17-11-2022 08:55')); 48 | $slot = new Slot($timeSpan); 49 | $slot->addEvent(new Talk('Keynote', 'Fabien Potencier', '', $timeSpan, Track::SymfonyRoom, $slot)); 50 | yield 'keynote' => [ 51 | dirname(__DIR__).'/fixtures/keynote.html', [$slot], 52 | ]; 53 | 54 | $timeSpan = new TimeSpan(new \DateTimeImmutable('18-11-2022 11:20'), new \DateTimeImmutable('18-11-2022 13:30')); 55 | $slot = new Slot($timeSpan); 56 | $slot->addEvent(new Event('Lunch 🍽', $timeSpan, $slot)); 57 | yield 'lunch' => [ 58 | dirname(__DIR__).'/fixtures/lunch.html', [$slot], 59 | ]; 60 | 61 | $timeSpan = new TimeSpan(new \DateTimeImmutable('17-11-2022 10:10'), new \DateTimeImmutable('17-11-2022 10:45')); 62 | $slot = new Slot($timeSpan); 63 | $slot->addEvent(new Talk('Unleashing the power of lazy objects in PHP 🪄', 'Nicolas Grekas', '', $timeSpan, Track::SymfonyRoom, $slot)); 64 | $slot->addEvent(new Talk('Transactional vs. Analytical Processing', 'Christopher Hertel', '', $timeSpan, Track::FrameworkRoom, $slot)); 65 | yield 'two-talks' => [ 66 | dirname(__DIR__).'/fixtures/two-talks.html', [$slot], 67 | ]; 68 | 69 | $timeSpan = new TimeSpan(new \DateTimeImmutable('18-11-2022 10:00'), new \DateTimeImmutable('18-11-2022 10:35')); 70 | $slot = new Slot($timeSpan); 71 | $slot->addEvent(new Talk('Advanced Test Driven Development', 'Diego Aguiar', '', $timeSpan, Track::SymfonyRoom, $slot)); 72 | $slot->addEvent(new Talk('How to handle content editing in Symfony', 'Titouan Galopin', '', $timeSpan, Track::FrameworkRoom, $slot)); 73 | $slot->addEvent(new Talk('What is FleetOps and why you should care?', 'Jessica Orozco', '', $timeSpan, Track::PlatformRoom, $slot)); 74 | yield 'three-talks' => [ 75 | dirname(__DIR__).'/fixtures/three-talks.html', [$slot], 76 | ]; 77 | 78 | $timeSpan1 = new TimeSpan(new \DateTimeImmutable('17-11-2022 13:30'), new \DateTimeImmutable('17-11-2022 14:05')); 79 | $slot1 = new Slot($timeSpan1); 80 | $slot1->addEvent(new Talk('PHPStan: Advanced Types', 'Ondřej Mirtes', '', $timeSpan1, Track::SymfonyRoom, $slot1)); 81 | $slot1->addEvent(new Talk('Schrödinger\'s SQL - The SQL inside the Doctrine box', 'Claudio Zizza', '', $timeSpan1, Track::FrameworkRoom, $slot1)); 82 | $slot1->addEvent(new Talk('Build apps, not platforms: operational maturity in a box', 'Ori Pekelman', '', $timeSpan1, Track::PlatformRoom, $slot1)); 83 | $timeSpan2 = new TimeSpan(new \DateTimeImmutable('17-11-2022 14:15'), new \DateTimeImmutable('17-11-2022 14:50')); 84 | $slot2 = new Slot($timeSpan2); 85 | $slot2->addEvent(new Talk('The PHP Stack’s Supply Chain', 'Sebastian Bergmann', '', $timeSpan2, Track::SymfonyRoom, $slot2)); 86 | $slot2->addEvent(new Talk('Symfony & Hotwire: an efficient combo to quickly develop complex applications', 'Florent Destremau', '', $timeSpan2, Track::FrameworkRoom, $slot2)); 87 | $slot2->addEvent(new Talk('Building a great product means designing for your users.', 'Natalie Harper', '', $timeSpan2, Track::PlatformRoom, $slot2)); 88 | yield 'two-rows' => [ 89 | dirname(__DIR__).'/fixtures/two-rows.html', [$slot1, $slot2], 90 | ]; 91 | } 92 | 93 | public function testExtractingDescription(): void 94 | { 95 | $parser = new Parser(); 96 | $response = (string) file_get_contents(dirname(__DIR__).'/fixtures/full-schedule.html'); 97 | 98 | $expectedDescription = 'We all love Disney movies, right? They are entertaining, trigger emotional response but also contain a lot of important lessons. And these lessons can also be applied to your career as a developer. During this talk I\'ll have a look at 7 situations from some of my favorite Disney movies to analyze and see what lesson we can learn from that.'; 99 | $actualDescription = ''; 100 | 101 | $slots = $parser->extractSlots($response); 102 | foreach ($slots as $slot) { 103 | foreach ($slot->getEvents() as $event) { 104 | if ('7 Lessons You Can Learn From Disney Movies' === $event->getTitle()) { 105 | self::assertInstanceOf(Talk::class, $event); 106 | $actualDescription = $event->getDescription(); 107 | } 108 | } 109 | } 110 | 111 | self::assertSame($expectedDescription, $actualDescription); 112 | } 113 | 114 | /** 115 | * @param list $expected 116 | * @param list $actual 117 | */ 118 | private static function assertSameCollection(array $expected, array $actual): void 119 | { 120 | self::assertSameSize($expected, $actual); 121 | 122 | foreach ($actual as $i => $slot) { 123 | self::assertArrayHasKey($i, $expected); 124 | self::assertSame($expected[$i]->getEvents()[0]->getTitle(), $slot->getEvents()[0]->getTitle()); 125 | self::assertEquals($expected[$i]->getTimeSpan(), $slot->getTimeSpan()); 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /tests/SymfonyCon/CrawlerTest.php: -------------------------------------------------------------------------------- 1 | setUpDatabase(); 25 | $this->setUpSchema(); 26 | } 27 | 28 | public function testFetchData(): void 29 | { 30 | $httpClient = new MockHttpClient($this->responseFactory(...)); 31 | $client = new Client($httpClient); 32 | $crawler = new Crawler($client, new Parser(), $this->entityManager); 33 | 34 | $crawler->loadSchedule(); 35 | 36 | self::assertSame(27, $this->repository->count([])); 37 | } 38 | 39 | private function responseFactory(string $method, string $url): ResponseInterface 40 | { 41 | self::assertSame('GET', $method); 42 | self::assertSame('https://live.symfony.com/2022-paris-con/schedule', $url); 43 | 44 | return new MockResponse((string) file_get_contents(__DIR__.'/fixtures/full-schedule.html')); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/SymfonyCon/ScheduleTest.php: -------------------------------------------------------------------------------- 1 | scheduleBefore->now()); 28 | } 29 | 30 | public function testNowStartingMorning(): void 31 | { 32 | self::assertNull($this->scheduleStartingMorning->now()); 33 | } 34 | 35 | public function testNowWhileRunning(): void 36 | { 37 | $slot = $this->scheduleWhileRunning->now(); 38 | self::assertNotNull($slot); 39 | self::assertCount(3, $slot->getEvents()); 40 | } 41 | 42 | public function testNowAfter(): void 43 | { 44 | self::assertNull($this->scheduleAfter->now()); 45 | } 46 | 47 | public function testNextBefore(): void 48 | { 49 | $slot = $this->scheduleBefore->next(); 50 | self::assertNotNull($slot); 51 | self::assertCount(1, $slot->getEvents()); 52 | } 53 | 54 | public function testNextStartingMorning(): void 55 | { 56 | $slot = $this->scheduleStartingMorning->next(); 57 | self::assertNotNull($slot); 58 | self::assertCount(1, $slot->getEvents()); 59 | } 60 | 61 | public function testNextWhileRunning(): void 62 | { 63 | $slot = $this->scheduleWhileRunning->next(); 64 | self::assertNotNull($slot); 65 | self::assertCount(1, $slot->getEvents()); 66 | } 67 | 68 | public function testNextAfter(): void 69 | { 70 | self::assertNull($this->scheduleAfter->next()); 71 | } 72 | 73 | public function testTodayBefore(): void 74 | { 75 | self::assertCount(0, $this->scheduleBefore->today()); 76 | } 77 | 78 | public function testTodayStartingMorning(): void 79 | { 80 | self::assertCount(13, $this->scheduleStartingMorning->today()); 81 | } 82 | 83 | public function testTodayWhileRunning(): void 84 | { 85 | self::assertCount(12, $this->scheduleWhileRunning->today()); 86 | } 87 | 88 | public function testTodayAfter(): void 89 | { 90 | self::assertCount(0, $this->scheduleAfter->today()); 91 | } 92 | 93 | public function testDay1Before(): void 94 | { 95 | self::assertCount(13, $this->scheduleBefore->day1()); 96 | } 97 | 98 | public function testDay1StartingMorning(): void 99 | { 100 | self::assertCount(13, $this->scheduleStartingMorning->day1()); 101 | } 102 | 103 | public function testDay1WhileRunning(): void 104 | { 105 | self::assertCount(13, $this->scheduleWhileRunning->day1()); 106 | } 107 | 108 | public function testDay1After(): void 109 | { 110 | self::assertCount(13, $this->scheduleAfter->day1()); 111 | } 112 | 113 | public function testDay2Before(): void 114 | { 115 | self::assertCount(12, $this->scheduleBefore->day2()); 116 | } 117 | 118 | public function testDay2StartingMorning(): void 119 | { 120 | self::assertCount(12, $this->scheduleStartingMorning->day2()); 121 | } 122 | 123 | public function testDay2WhileRunning(): void 124 | { 125 | self::assertCount(12, $this->scheduleWhileRunning->day2()); 126 | } 127 | 128 | public function testDay2After(): void 129 | { 130 | self::assertCount(12, $this->scheduleAfter->day2()); 131 | } 132 | 133 | protected function setUp(): void 134 | { 135 | $this->setUpDatabase(); 136 | $this->setUpSchema(); 137 | $this->setUpFixtures(); 138 | 139 | $timerBefore = $this->getTimerWithNow('11/20/19 08:00'); 140 | $this->scheduleBefore = new Schedule($timerBefore, $this->repository); 141 | 142 | $timerStartingMorning = $this->getTimerWithNow('11/21/19 06:50'); 143 | $this->scheduleStartingMorning = new Schedule($timerStartingMorning, $this->repository); 144 | 145 | $timerWhileRunning = $this->getTimerWithNow('11/22/19 14:55'); 146 | $this->scheduleWhileRunning = new Schedule($timerWhileRunning, $this->repository); 147 | 148 | $timerAfter = $this->getTimerWithNow('11/23/19 14:00'); 149 | $this->scheduleAfter = new Schedule($timerAfter, $this->repository); 150 | } 151 | 152 | private function getTimerWithNow(string $now): Timer 153 | { 154 | return new Timer( 155 | new \DateTimeImmutable('11/21/19 08:00'), 156 | new \DateTimeImmutable('11/22/19 17:00'), 157 | new \DateTimeImmutable($now), 158 | ); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /tests/SymfonyCon/TimerFactoryTest.php: -------------------------------------------------------------------------------- 1 | setUpDatabase(); 23 | $this->setUpSchema(); 24 | $this->setUpFixtures(); 25 | } 26 | 27 | public function testCreateTimer(): void 28 | { 29 | $clock = new MockClock('11/18/19 19:15'); 30 | $factory = new TimerFactory($this->repository, $clock); 31 | $timer = $factory->createTimer(); 32 | $countdown = $timer->getCountdown(); 33 | 34 | self::assertSame(2, $countdown->days); 35 | self::assertSame(12, $countdown->h); 36 | self::assertSame(45, $countdown->i); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/SymfonyCon/TimerTest.php: -------------------------------------------------------------------------------- 1 | getTimerWithNow('11/20/19 08:00'); 16 | 17 | self::assertEquals(new \DateTimeImmutable('11/21/19 08:00'), $timer->getStart()); 18 | } 19 | 20 | public function testEndTimeGetter(): void 21 | { 22 | $timer = $this->getTimerWithNow('11/20/19 08:00'); 23 | 24 | self::assertEquals(new \DateTimeImmutable('11/22/19 17:00'), $timer->getEnd()); 25 | } 26 | 27 | public function testNowTimeGetter(): void 28 | { 29 | $timer = $this->getTimerWithNow('11/20/19 08:00'); 30 | 31 | self::assertEquals(new \DateTimeImmutable('11/20/19 08:00'), $timer->getNow()); 32 | } 33 | 34 | public function testHasStartedBefore(): void 35 | { 36 | $timer = $this->getTimerWithNow('11/20/19 08:00'); 37 | 38 | self::assertFalse($timer->hasStarted()); 39 | } 40 | 41 | public function testHasStartedOnTime(): void 42 | { 43 | $timer = $this->getTimerWithNow('11/21/19 08:00'); 44 | 45 | self::assertTrue($timer->hasStarted()); 46 | } 47 | 48 | public function testHasStartedAfterwards(): void 49 | { 50 | $timer = $this->getTimerWithNow('11/22/19 08:00'); 51 | 52 | self::assertTrue($timer->hasStarted()); 53 | } 54 | 55 | public function testIsOverBefore(): void 56 | { 57 | $timer = $this->getTimerWithNow('11/20/19 08:00'); 58 | 59 | self::assertFalse($timer->isOver()); 60 | } 61 | 62 | public function testIsOverOnTime(): void 63 | { 64 | $timer = $this->getTimerWithNow('11/22/19 17:00'); 65 | 66 | self::assertTrue($timer->isOver()); 67 | } 68 | 69 | public function testIsOverAfterwards(): void 70 | { 71 | $timer = $this->getTimerWithNow('11/23/19 08:00'); 72 | 73 | self::assertTrue($timer->isOver()); 74 | } 75 | 76 | public function testIsRunningBefore(): void 77 | { 78 | $timer = $this->getTimerWithNow('11/20/19 08:00'); 79 | 80 | self::assertFalse($timer->isRunning()); 81 | } 82 | 83 | public function testIsRunningInBetween(): void 84 | { 85 | $timer = $this->getTimerWithNow('11/22/19 09:00'); 86 | 87 | self::assertTrue($timer->isRunning()); 88 | } 89 | 90 | public function testIsRunningAfterwards(): void 91 | { 92 | $timer = $this->getTimerWithNow('11/23/19 08:00'); 93 | 94 | self::assertFalse($timer->isRunning()); 95 | } 96 | 97 | public function testCountdownBefore(): void 98 | { 99 | $timer = $this->getTimerWithNow('11/18/19 19:15'); 100 | $countdown = $timer->getCountdown(); 101 | 102 | self::assertSame(2, $countdown->days); 103 | self::assertSame(12, $countdown->h); 104 | self::assertSame(45, $countdown->i); 105 | } 106 | 107 | public function testCountdownOnTime(): void 108 | { 109 | $timer = $this->getTimerWithNow('11/21/19 08:00'); 110 | $countdown = $timer->getCountdown(); 111 | 112 | self::assertSame(0, $countdown->days); 113 | self::assertSame(0, $countdown->h); 114 | self::assertSame(0, $countdown->i); 115 | } 116 | 117 | public function testCountdownWhile(): void 118 | { 119 | $timer = $this->getTimerWithNow('11/21/19 09:00'); 120 | $countdown = $timer->getCountdown(); 121 | 122 | self::assertSame(0, $countdown->days); 123 | self::assertSame(0, $countdown->h); 124 | self::assertSame(0, $countdown->i); 125 | } 126 | 127 | public function testCountdownAfter(): void 128 | { 129 | $timer = $this->getTimerWithNow('11/23/19 18:00'); 130 | $countdown = $timer->getCountdown(); 131 | 132 | self::assertSame(0, $countdown->days); 133 | self::assertSame(0, $countdown->h); 134 | self::assertSame(0, $countdown->i); 135 | } 136 | 137 | #[DataProvider('provideStartsTodayData')] 138 | public function testStartsToday(string $now, bool $expectedBoolean): void 139 | { 140 | $timer = $this->getTimerWithNow($now); 141 | 142 | self::assertSame($expectedBoolean, $timer->startsToday()); 143 | } 144 | 145 | /** 146 | * @return array 147 | */ 148 | public static function provideStartsTodayData(): array 149 | { 150 | return [ 151 | ['11/21/19 07:20', true], 152 | ['11/22/19 07:20', false], 153 | ['11/20/19 07:20', false], 154 | ['11/21/19 17:20', true], 155 | ]; 156 | } 157 | 158 | private function getTimerWithNow(string $now): Timer 159 | { 160 | return new Timer( 161 | new \DateTimeImmutable('11/21/19 08:00'), 162 | new \DateTimeImmutable('11/22/19 17:00'), 163 | new \DateTimeImmutable($now), 164 | ); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /tests/SymfonyCon/fixtures/keynote.html: -------------------------------------------------------------------------------- 1 |
2 |

November 17, 2022

3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 20 | 21 | 27 | 28 | 29 |
The Symfony roomThe Framework roomThe Platform.sh room
17 |

09:15

18 |

09:55

19 |
22 |
23 | Keynote 24 | Fabien Potencier 25 |
26 |
30 |
31 | 32 | 33 |
34 | 35 |
36 |
37 |
38 |

39 | Keynote 40 |

41 | 42 |

43 | Avatar of Fabien Potencier 44 | 45 | Fabien Potencier 46 |

47 | 48 |
49 |
50 |

Keynote

51 |
52 | 53 |
54 |
55 |
56 | 57 | 58 |
59 |
60 | Delivered in English 61 |
62 |
63 | 64 |
65 | 66 | 67 | 68 | 69 | Rooms: 70 | 71 | The Symfony room, The Platform.sh room, The Framework room 72 | 73 |
74 | 75 |
76 |
77 | 78 | 79 |
80 |
81 |

82 | 83 | Thursday, November 17, 2022 at 09:15 AM 84 | – 85 | 09:55 AM 86 | 87 |

88 |
89 | 90 |
91 |
92 |
93 |
94 | 95 |
96 |
97 | -------------------------------------------------------------------------------- /tests/SymfonyCon/fixtures/lunch.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

12:20

5 |

14:30

6 | 7 | 8 | 9 |
10 | Lunch 🍽 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/SymfonyCon/fixtures/two-talks.html: -------------------------------------------------------------------------------- 1 |
2 |

November 17, 2022

3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 20 | 21 | 27 | 33 | 34 | 35 | 36 |
The Symfony roomThe Framework roomThe Platform.sh room
17 |

11:10

18 |

11:45

19 |
22 |
23 | Unleashing the power of lazy objects in PHP 🪄 24 | Nicolas Grekas 25 |
26 |
28 |
29 | Transactional vs. Analytical Processing 30 | Christopher Hertel 31 |
32 |
 
37 |
38 | 39 | 40 |
41 | 42 |
43 |
44 |
45 |

46 | Unleashing the power of lazy objects in PHP 🪄 47 |

48 | 49 |

50 | Avatar of Nicolas Grekas 51 | 52 | Nicolas Grekas 53 |

54 | 55 |
56 |
57 |

Lazy-objects are a bit magical. They are created empty and populate themselves on-demand. They are useful when an object is heavy to instantiate but is not always used, like for example Doctrine entities or Symfony lazy-services.
58 |
59 | But do you know how they work internally? In this talk, I'll tell you about the mechanisms provided by PHP to enable such use cases 🧙. Because doing this sort of wizardry is not common practice, I'll also introduce you to two new traits that package those lazy-loading behaviors introduced in Symfony 6.2: one for virtual inheritance proxies, and one for ghost objects 👻.
60 |
61 | While lazy objects used to require complex code generation, these new traits make it way easier to leverage them, opening up possible innovations; lazy arguments, lazy properties, or by-design lazy classes to name a few ones. What will you come up with? Let me know after you've seen this talk!

62 |
63 | 64 |
65 |
66 |
67 | 68 | 69 |
70 |
71 | Delivered in English 72 |
73 |
74 | 75 |
76 | 77 | 78 | 79 | 80 | Room: 81 | 82 | The Symfony room 83 | 84 |
85 | 86 |
87 |
88 | 89 | 90 |
91 |
92 |

93 | 94 | Thursday, November 17, 2022 at 11:10 AM 95 | – 96 | 11:45 AM 97 | 98 |

99 |
100 | 101 |
102 |
103 |
104 |
105 | 106 |
107 |
108 |
109 |

110 | Transactional vs. Analytical Processing 111 |

112 | 113 |

114 | Avatar of Christopher Hertel 115 | 116 | Christopher Hertel 117 |

118 | 119 |
120 |
121 |

When it comes to the design of your Symfony application, data plays a central role. From an architectural point of view there are two common ways this data is processed. Transactional processing ensures that changes to your data are consistent and safe. Analytical processing aims to make even complex queries fast and efficient.
122 | We should always consider the nature of processing while implementing our application’s use cases. So let's have a look at the main criterias, strategies and trade offs that will help us to navigate through all the options we have and see how we can bring your own data warehouse to life in your Symfony application leveraging tools like Doctrine, Messenger and more.

123 |
124 | 125 |
126 |
127 |
128 | 129 | 130 |
131 |
132 | Delivered in English 133 |
134 |
135 | 136 |
137 | 138 | 139 | 140 | 141 | Room: 142 | 143 | The Framework room 144 | 145 |
146 | 147 |
148 |
149 | 150 | 151 |
152 |
153 |

154 | 155 | Thursday, November 17, 2022 at 11:10 AM 156 | – 157 | 11:45 AM 158 | 159 |

160 |
161 | 162 |
163 |
164 |
165 |
166 | 167 |
168 |
169 | -------------------------------------------------------------------------------- /tests/Templates.php: -------------------------------------------------------------------------------- 1 | environment = new Environment(new FilesystemLoader(dirname(__DIR__).'/templates')); 20 | $this->environment->addExtension(new ShortExtension(Shortener::make(Dictionary::createUnmistakable()))); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/TestDatabase.php: -------------------------------------------------------------------------------- 1 | resultCache = new ArrayAdapter(); 30 | 31 | $config = $this->createConfiguration(); 32 | 33 | $params = [ 34 | 'driver' => 'pdo_sqlite', 35 | 'memory' => true, 36 | ]; 37 | $this->entityManager = EntityManager::create($params, $config); 38 | 39 | $container = new Container(); 40 | $container->set('connection', $this->entityManager->getConnection()); 41 | $container->set('entity_manager', $this->entityManager); 42 | $registry = new Registry($container, ['connection'], ['entity_manager'], 'connection', 'entity_manager'); 43 | $this->repository = new SlotRepository($this->createShortener(), $registry); 44 | 45 | if (!Type::hasType('uuid')) { 46 | Type::addType('uuid', UuidType::class); 47 | } 48 | } 49 | 50 | private function createConfiguration(): Configuration 51 | { 52 | $config = new Configuration(); 53 | $config->setEntityNamespaces(['SymfonyTestsDoctrine' => 'Symfony\Bridge\Doctrine\Tests\Fixtures']); 54 | $config->setAutoGenerateProxyClasses(true); 55 | $config->setProxyDir(sys_get_temp_dir()); 56 | $config->setProxyNamespace('SymfonyTests\Doctrine'); 57 | $config->setMetadataDriverImpl(new AttributeDriver([__DIR__.'/../src/Entity'])); 58 | $config->setResultCache($this->resultCache); 59 | 60 | return $config; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/UuidShortener.php: -------------------------------------------------------------------------------- 1 | bootEnv(dirname(__DIR__).'/.env'); 11 | } 12 | --------------------------------------------------------------------------------