├── .castor ├── database.php ├── docker.php └── qa.php ├── .env ├── .env.test ├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .php-cs-fixer.php ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bin ├── console ├── db └── phpunit ├── castor.php ├── composer.json ├── composer.lock ├── config ├── bundles.php ├── packages │ ├── cache.yaml │ ├── debug.yaml │ ├── doctrine.yaml │ ├── doctrine_migrations.yaml │ ├── framework.yaml │ ├── monolog.yaml │ ├── routing.yaml │ ├── security.yaml │ ├── twig.yaml │ └── web_profiler.yaml ├── preload.php ├── routes.yaml ├── routes │ ├── framework.yaml │ └── web_profiler.yaml └── services.yaml ├── doc └── slack.yaml ├── infrastructure └── docker │ ├── docker-compose.builder.yml │ ├── docker-compose.yml │ └── services │ ├── php │ ├── Dockerfile │ ├── base │ │ └── php-configuration │ │ │ └── mods-available │ │ │ └── app-default.ini │ ├── builder │ │ ├── etc │ │ │ └── sudoers.d │ │ │ │ └── sudo │ │ └── php-configuration │ │ │ └── mods-available │ │ │ └── app-builder.ini │ ├── entrypoint │ └── frontend │ │ ├── etc │ │ ├── nginx │ │ │ ├── environments │ │ │ └── nginx.conf │ │ └── service │ │ │ ├── nginx │ │ │ └── run │ │ │ └── php-fpm │ │ │ └── run │ │ └── php-configuration │ │ ├── fpm │ │ └── php-fpm.conf │ │ └── mods-available │ │ └── app-fpm.ini │ └── router │ ├── Dockerfile │ ├── certs │ └── .gitkeep │ ├── generate-ssl.sh │ ├── openssl.cnf │ └── traefik │ ├── dynamic_conf.yaml │ └── traefik.yaml ├── migrations ├── Version20190903163028.php ├── Version20190911161036.php └── Version20210426140411.php ├── phpstan-baseline.neon ├── phpstan.neon ├── phpunit.xml.dist ├── public └── index.php ├── rector.php ├── src ├── ControlTower │ ├── BigBrowser.php │ ├── DebtAcker.php │ ├── DebtCreator.php │ ├── Government.php │ └── NewDebtNotifier.php ├── Controller │ ├── HomepageController.php │ └── SlackController.php ├── Doctrine │ └── Type │ │ └── DateTimeImmutableWithMillis.php ├── Entity │ ├── Amnesty.php │ ├── Debt.php │ └── Event.php ├── EventSubscriber │ ├── ChallengeSubscriber.php │ └── SignatureSubscriber.php ├── Kernel.php ├── Repository │ ├── AmnestyRepository.php │ ├── DebtRepository.php │ └── EventRepository.php ├── Slack │ ├── DebtAckPoster.php │ ├── DebtListBlockBuilder.php │ ├── DebtListPoster.php │ ├── MessagePoster.php │ └── PayloadFilter.php └── Util │ └── Uuid.php ├── symfony.lock ├── templates ├── base.html.twig └── homepage │ └── homepage.html.twig ├── tests ├── Acceptance │ ├── AcceptenceTest.php │ └── fixtures │ │ ├── 001_message_user_A.json │ │ ├── 002_message_user_A.json │ │ ├── 003_message_user_B.json │ │ └── 004_mark_as_paid.json ├── Integration │ └── ControlTower │ │ └── DebtCreatorTest.php ├── allowed.json └── bootstrap.php └── tools ├── bin ├── php-cs-fixer └── phpstan ├── php-cs-fixer ├── .gitignore ├── composer.json └── composer.lock └── phpstan ├── .gitignore ├── composer.json └── composer.lock /.castor/database.php: -------------------------------------------------------------------------------- 1 | title('Connecting to the PostgreSQL database'); 13 | 14 | docker_compose(['exec', 'postgres', 'psql', '-U', 'app', 'app'], context()->toInteractive()); 15 | } 16 | -------------------------------------------------------------------------------- /.castor/docker.php: -------------------------------------------------------------------------------- 1 | title('About this project'); 31 | 32 | io()->comment('Run castor to display all available commands.'); 33 | io()->comment('Run castor about to display this project help.'); 34 | io()->comment('Run castor help [command] to display Castor help.'); 35 | 36 | io()->section('Available URLs for this project:'); 37 | $urls = [variable('root_domain'), ...variable('extra_domains')]; 38 | 39 | try { 40 | $routers = http_client() 41 | ->request('GET', \sprintf('http://%s:8080/api/http/routers', variable('root_domain'))) 42 | ->toArray() 43 | ; 44 | $projectName = variable('project_name'); 45 | foreach ($routers as $router) { 46 | if (!preg_match("{^{$projectName}-(.*)@docker$}", $router['name'])) { 47 | continue; 48 | } 49 | if ("frontend-{$projectName}" === $router['service']) { 50 | continue; 51 | } 52 | if (!preg_match('{^Host\(`(?P.*)`\)$}', $router['rule'], $matches)) { 53 | continue; 54 | } 55 | $hosts = explode('`) || Host(`', $matches['hosts']); 56 | $urls = [...$urls, ...$hosts]; 57 | } 58 | } catch (HttpExceptionInterface) { 59 | } 60 | 61 | io()->listing(array_map(fn ($url) => "https://{$url}", array_unique($urls))); 62 | } 63 | 64 | #[AsTask(description: 'Opens the project in your browser', namespace: '', aliases: ['open'])] 65 | function open_project(): void 66 | { 67 | open('https://' . variable('root_domain')); 68 | } 69 | 70 | #[AsTask(description: 'Builds the infrastructure', aliases: ['build'])] 71 | function build( 72 | ?string $service = null, 73 | ?string $profile = null, 74 | ): void { 75 | io()->title('Building infrastructure'); 76 | 77 | $command = []; 78 | 79 | if ($profile) { 80 | $command[] = '--profile'; 81 | $command[] = $profile; 82 | } else { 83 | $command[] = '--profile'; 84 | $command[] = 'default'; 85 | } 86 | 87 | $command = [ 88 | ...$command, 89 | 'build', 90 | '--build-arg', 'USER_ID=' . variable('user_id'), 91 | '--build-arg', 'PHP_VERSION=' . variable('php_version'), 92 | '--build-arg', 'PROJECT_NAME=' . variable('project_name'), 93 | ]; 94 | 95 | if ($service) { 96 | $command[] = $service; 97 | } 98 | 99 | docker_compose($command, withBuilder: true); 100 | } 101 | 102 | #[AsTask(description: 'Pull images from the registry')] 103 | function pull(): void 104 | { 105 | docker_compose(['pull', '-q'], withBuilder: true); 106 | } 107 | 108 | /** 109 | * @param list $profiles 110 | */ 111 | #[AsTask(description: 'Builds and starts the infrastructure', aliases: ['up'])] 112 | function up( 113 | ?string $service = null, 114 | #[AsOption(mode: InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED)] 115 | array $profiles = [], 116 | ): void { 117 | if (!$service && !$profiles) { 118 | io()->title('Starting infrastructure'); 119 | } 120 | 121 | $command = ['up', '--detach', '--wait', '--no-build']; 122 | 123 | if ($service) { 124 | $command[] = $service; 125 | } 126 | 127 | try { 128 | docker_compose($command, profiles: $profiles); 129 | } catch (ExceptionInterface $e) { 130 | io()->error('An error occured while starting the infrastructure.'); 131 | io()->note('Did you forget to run "castor docker:build"?'); 132 | io()->note('Or you forget to login to the registry?'); 133 | 134 | throw $e; 135 | } 136 | } 137 | 138 | /** 139 | * @param list $profiles 140 | */ 141 | #[AsTask(description: 'Stops the infrastructure', aliases: ['stop'])] 142 | function stop( 143 | ?string $service = null, 144 | #[AsOption(mode: InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED)] 145 | array $profiles = [], 146 | ): void { 147 | if (!$service || !$profiles) { 148 | io()->title('Stopping infrastructure'); 149 | } 150 | 151 | $command = ['stop']; 152 | 153 | if ($service) { 154 | $command[] = $service; 155 | } 156 | 157 | docker_compose($command, profiles: $profiles); 158 | } 159 | 160 | #[AsTask(description: 'Opens a shell (bash) into a builder container', aliases: ['builder'])] 161 | function builder(): void 162 | { 163 | $c = context() 164 | ->withTimeout(null) 165 | ->withTty() 166 | ->withEnvironment($_ENV + $_SERVER) 167 | ->withAllowFailure() 168 | ; 169 | docker_compose_run('bash', c: $c); 170 | } 171 | 172 | /** 173 | * @param list $profiles 174 | */ 175 | #[AsTask(description: 'Displays infrastructure logs', aliases: ['logs'])] 176 | function logs( 177 | ?string $service = null, 178 | #[AsOption(mode: InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED)] 179 | array $profiles = [], 180 | ): void { 181 | $command = ['logs', '-f', '--tail', '150']; 182 | 183 | if ($service) { 184 | $command[] = $service; 185 | } 186 | 187 | docker_compose($command, c: context()->withTty(), profiles: $profiles); 188 | } 189 | 190 | #[AsTask(description: 'Lists containers status', aliases: ['ps'])] 191 | function ps(): void 192 | { 193 | docker_compose(['ps'], withBuilder: false); 194 | } 195 | 196 | #[AsTask(description: 'Cleans the infrastructure (remove container, volume, networks)', aliases: ['destroy'])] 197 | function destroy( 198 | #[AsOption(description: 'Force the destruction without confirmation', shortcut: 'f')] 199 | bool $force = false, 200 | ): void { 201 | io()->title('Destroying infrastructure'); 202 | 203 | if (!$force) { 204 | io()->warning('This will permanently remove all containers, volumes, networks... created for this project.'); 205 | io()->note('You can use the --force option to avoid this confirmation.'); 206 | if (!io()->confirm('Are you sure?', false)) { 207 | io()->comment('Aborted.'); 208 | 209 | return; 210 | } 211 | } 212 | 213 | docker_compose(['down', '--remove-orphans', '--volumes', '--rmi=local'], withBuilder: true); 214 | $files = finder() 215 | ->in(variable('root_dir') . '/infrastructure/docker/services/router/certs/') 216 | ->name('*.pem') 217 | ->files() 218 | ; 219 | fs()->remove($files); 220 | } 221 | 222 | #[AsTask(description: 'Generates SSL certificates (with mkcert if available or self-signed if not)')] 223 | function generate_certificates( 224 | #[AsOption(description: 'Force the certificates re-generation without confirmation', shortcut: 'f')] 225 | bool $force = false, 226 | ): void { 227 | $sslDir = variable('root_dir') . '/infrastructure/docker/services/router/certs'; 228 | 229 | if (file_exists("{$sslDir}/cert.pem") && !$force) { 230 | io()->comment('SSL certificates already exists.'); 231 | io()->note('Run "castor docker:generate-certificates --force" to generate new certificates.'); 232 | 233 | return; 234 | } 235 | 236 | io()->title('Generating SSL certificates'); 237 | 238 | if ($force) { 239 | if (file_exists($f = "{$sslDir}/cert.pem")) { 240 | io()->comment('Removing existing certificates in infrastructure/docker/services/router/certs/*.pem.'); 241 | unlink($f); 242 | } 243 | 244 | if (file_exists($f = "{$sslDir}/key.pem")) { 245 | unlink($f); 246 | } 247 | } 248 | 249 | $finder = new ExecutableFinder(); 250 | $mkcert = $finder->find('mkcert'); 251 | 252 | if ($mkcert) { 253 | $pathCaRoot = capture(['mkcert', '-CAROOT']); 254 | 255 | if (!is_dir($pathCaRoot)) { 256 | io()->warning('You must have mkcert CA Root installed on your host with "mkcert -install" command.'); 257 | 258 | return; 259 | } 260 | 261 | $rootDomain = variable('root_domain'); 262 | 263 | run([ 264 | 'mkcert', 265 | '-cert-file', "{$sslDir}/cert.pem", 266 | '-key-file', "{$sslDir}/key.pem", 267 | $rootDomain, 268 | "*.{$rootDomain}", 269 | ...variable('extra_domains'), 270 | ]); 271 | 272 | io()->success('Successfully generated SSL certificates with mkcert.'); 273 | 274 | if ($force) { 275 | io()->note('Please restart the infrastructure to use the new certificates with "castor up" or "castor start".'); 276 | } 277 | 278 | return; 279 | } 280 | 281 | run(['infrastructure/docker/services/router/generate-ssl.sh'], context: context()->withQuiet()); 282 | 283 | io()->success('Successfully generated self-signed SSL certificates in infrastructure/docker/services/router/certs/*.pem.'); 284 | io()->comment('Consider installing mkcert to generate locally trusted SSL certificates and run "castor docker:generate-certificates --force".'); 285 | 286 | if ($force) { 287 | io()->note('Please restart the infrastructure to use the new certificates with "castor up" or "castor start".'); 288 | } 289 | } 290 | 291 | #[AsTask(description: 'Starts the workers', namespace: 'docker:worker', name: 'start', aliases: ['start-workers'])] 292 | function workers_start(): void 293 | { 294 | io()->title('Starting workers'); 295 | 296 | up(profiles: ['worker']); 297 | } 298 | 299 | #[AsTask(description: 'Stops the workers', namespace: 'docker:worker', name: 'stop', aliases: ['stop-workers'])] 300 | function workers_stop(): void 301 | { 302 | io()->title('Stopping workers'); 303 | 304 | stop(profiles: ['worker']); 305 | } 306 | 307 | #[AsContext(default: true)] 308 | function create_default_context(): Context 309 | { 310 | $data = create_default_variables() + [ 311 | 'project_name' => 'app', 312 | 'root_domain' => 'app.test', 313 | 'extra_domains' => [], 314 | 'project_directory' => 'application', 315 | 'php_version' => '8.2', 316 | 'docker_compose_files' => [ 317 | 'docker-compose.yml', 318 | ], 319 | 'macos' => false, 320 | 'power_shell' => false, 321 | // check if posix_geteuid is available, if not, use getmyuid (windows) 322 | 'user_id' => \function_exists('posix_geteuid') ? posix_geteuid() : getmyuid(), 323 | 'root_dir' => \dirname(__DIR__), 324 | ]; 325 | 326 | if (file_exists($data['root_dir'] . '/infrastructure/docker/docker-compose.override.yml')) { 327 | $data['docker_compose_files'][] = 'docker-compose.override.yml'; 328 | } 329 | 330 | // We need an empty context to run command, since the default context has 331 | // not been set in castor, since we ARE creating it right now 332 | $emptyContext = new Context(); 333 | 334 | $data['composer_cache_dir'] = cache('composer_cache_dir', function () use ($emptyContext): string { 335 | $composerCacheDir = capture(['composer', 'global', 'config', 'cache-dir', '-q'], onFailure: '', context: $emptyContext); 336 | // If PHP is broken, the output will not be a valid path but an error message 337 | if (!is_dir($composerCacheDir)) { 338 | $composerCacheDir = sys_get_temp_dir() . '/castor/composer'; 339 | // If the directory does not exist, we create it. Otherwise, docker 340 | // will do, as root, and the user will not be able to write in it. 341 | if (!is_dir($composerCacheDir)) { 342 | mkdir($composerCacheDir, 0o777, true); 343 | } 344 | } 345 | 346 | return $composerCacheDir; 347 | }); 348 | 349 | $platform = strtolower(php_uname('s')); 350 | if (str_contains($platform, 'darwin')) { 351 | $data['macos'] = true; 352 | } elseif (\in_array($platform, ['win32', 'win64', 'windows nt'])) { 353 | $data['power_shell'] = true; 354 | } 355 | 356 | if (false === $data['user_id'] || $data['user_id'] > 256000) { 357 | $data['user_id'] = 1000; 358 | } 359 | 360 | if (0 === $data['user_id']) { 361 | log('Running as root? Fallback to fake user id.', 'warning'); 362 | $data['user_id'] = 1000; 363 | } 364 | 365 | return new Context( 366 | $data, 367 | pty: Process::isPtySupported(), 368 | environment: [ 369 | 'BUILDKIT_PROGRESS' => 'plain', 370 | ] 371 | ); 372 | } 373 | 374 | #[AsContext(name: 'ci')] 375 | function create_ci_context(): Context 376 | { 377 | $c = create_default_context(); 378 | 379 | return $c 380 | ->withData([ 381 | // override the default context here 382 | ]) 383 | ->withEnvironment([ 384 | 'COMPOSE_ANSI' => 'never', 385 | ]) 386 | ; 387 | } 388 | 389 | /** 390 | * @param list $subCommand 391 | * @param list $profiles 392 | */ 393 | function docker_compose(array $subCommand, ?Context $c = null, bool $withBuilder = false, array $profiles = []): Process 394 | { 395 | $c ??= context(); 396 | $profiles = $profiles ?: ['default']; 397 | 398 | $domains = [variable('root_domain'), ...variable('extra_domains')]; 399 | $domains = '`' . implode('`) || Host(`', $domains) . '`'; 400 | 401 | $c = $c 402 | ->withTimeout(null) 403 | ->withEnvironment([ 404 | 'PROJECT_NAME' => variable('project_name'), 405 | 'PROJECT_ROOT_DOMAIN' => variable('root_domain'), 406 | 'PROJECT_DOMAINS' => $domains, 407 | 'USER_ID' => variable('user_id'), 408 | 'COMPOSER_CACHE_DIR' => variable('composer_cache_dir'), 409 | 'PHP_VERSION' => variable('php_version'), 410 | ]) 411 | ; 412 | 413 | $command = [ 414 | 'docker', 415 | 'compose', 416 | '-p', variable('project_name'), 417 | ]; 418 | foreach ($profiles as $profile) { 419 | $command[] = '--profile'; 420 | $command[] = $profile; 421 | } 422 | 423 | foreach (variable('docker_compose_files') as $file) { 424 | $command[] = '-f'; 425 | $command[] = variable('root_dir') . '/infrastructure/docker/' . $file; 426 | } 427 | 428 | if ($withBuilder) { 429 | $command[] = '-f'; 430 | $command[] = variable('root_dir') . '/infrastructure/docker/docker-compose.builder.yml'; 431 | } 432 | 433 | $command = array_merge($command, $subCommand); 434 | 435 | return run($command, context: $c); 436 | } 437 | 438 | function docker_compose_run( 439 | string $runCommand, 440 | ?Context $c = null, 441 | string $service = 'builder', 442 | bool $noDeps = true, 443 | ?string $workDir = null, 444 | bool $portMapping = false, 445 | bool $withBuilder = true, 446 | ): Process { 447 | $command = [ 448 | 'run', 449 | '--rm', 450 | ]; 451 | 452 | if ($noDeps) { 453 | $command[] = '--no-deps'; 454 | } 455 | 456 | if ($portMapping) { 457 | $command[] = '--service-ports'; 458 | } 459 | 460 | if (null !== $workDir) { 461 | $command[] = '-w'; 462 | $command[] = $workDir; 463 | } 464 | 465 | $command[] = $service; 466 | $command[] = '/bin/bash'; 467 | $command[] = '-c'; 468 | $command[] = "{$runCommand}"; 469 | 470 | return docker_compose($command, c: $c, withBuilder: $withBuilder); 471 | } 472 | 473 | function docker_exit_code( 474 | string $runCommand, 475 | ?Context $c = null, 476 | string $service = 'builder', 477 | bool $noDeps = true, 478 | ?string $workDir = null, 479 | bool $withBuilder = true, 480 | ): int { 481 | $c = ($c ?? context())->withAllowFailure(); 482 | 483 | $process = docker_compose_run( 484 | runCommand: $runCommand, 485 | c: $c, 486 | service: $service, 487 | noDeps: $noDeps, 488 | workDir: $workDir, 489 | withBuilder: $withBuilder, 490 | ); 491 | 492 | return $process->getExitCode() ?? 0; 493 | } 494 | 495 | // Mac users have a lot of problems running Yarn / Webpack on the Docker stack 496 | // so this func allow them to run these tools on their host 497 | function run_in_docker_or_locally_for_mac(string $command, ?Context $c = null): void 498 | { 499 | $c ??= context(); 500 | 501 | if (variable('macos')) { 502 | run($command, context: $c->withPath(variable('root_dir'))); 503 | } else { 504 | docker_compose_run($command, c: $c); 505 | } 506 | } 507 | -------------------------------------------------------------------------------- /.castor/qa.php: -------------------------------------------------------------------------------- 1 | title('Installing QA tooling'); 27 | 28 | docker_compose_run('composer install -o', workDir: '/var/www/tools/php-cs-fixer'); 29 | docker_compose_run('composer install -o', workDir: '/var/www/tools/phpstan'); 30 | } 31 | 32 | #[AsTask(description: 'Update tooling')] 33 | function update(): void 34 | { 35 | io()->title('Update QA tooling'); 36 | 37 | docker_compose_run('composer update -o', workDir: '/var/www/tools/php-cs-fixer'); 38 | docker_compose_run('composer update -o', workDir: '/var/www/tools/phpstan'); 39 | } 40 | 41 | #[AsTask(description: 'Runs PHPUnit', aliases: ['phpunit'])] 42 | function phpunit(): int 43 | { 44 | return docker_exit_code('bin/phpunit'); 45 | } 46 | 47 | #[AsTask(description: 'Runs PHPStan', aliases: ['phpstan'])] 48 | function phpstan(): int 49 | { 50 | if (!is_dir(variable('root_dir') . '/tools/phpstan/vendor')) { 51 | install(); 52 | } 53 | 54 | return docker_exit_code('phpstan', workDir: '/var/www'); 55 | } 56 | 57 | #[AsTask(description: 'Fixes Coding Style', aliases: ['cs'])] 58 | function cs(bool $dryRun = false): int 59 | { 60 | if (!is_dir(variable('root_dir') . '/tools/php-cs-fixer/vendor')) { 61 | install(); 62 | } 63 | 64 | if ($dryRun) { 65 | return docker_exit_code('php-cs-fixer fix --dry-run --diff', workDir: '/var/www'); 66 | } 67 | 68 | return docker_exit_code('php-cs-fixer fix', workDir: '/var/www'); 69 | } 70 | -------------------------------------------------------------------------------- /.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=870686bf53a35819535f3c8158327e28 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&charset=utf8mb4" 28 | DATABASE_URL="postgresql://monologue:monologue@postgres:5432/monologue?serverVersion=14&charset=utf8" 29 | ###< doctrine/doctrine-bundle ### 30 | 31 | TIMEZONE=Europe/Paris 32 | 33 | SLACK_CHANNEL=FIXME 34 | SLACK_TOKEN=FIXME 35 | SLACK_SIGNING_SECRET=FIXME 36 | SLACK_BOT_IGNORED_IDS='["BOT_ID_124"]' # use to ignore event from some bots 37 | DASHBOARD_PASSWORD=FIXME 38 | AMNESTY_THRESHOLD=5 39 | -------------------------------------------------------------------------------- /.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 | 6 | SLACK_CHANNEL=MY_CHANNEL_ID 7 | SLACK_SIGNING_SECRET='' 8 | AMNESTY_THRESHOLD=2 9 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Force LF line ending (mandatory for Windows) 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | "on": 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | schedule: 9 | - cron: "0 0 * * MON" 10 | 11 | permissions: 12 | contents: read 13 | packages: read 14 | 15 | env: 16 | # Fix for symfony/color detection. We know GitHub Actions can handle it 17 | ANSICON: 1 18 | CASTOR_CONTEXT: ci 19 | 20 | jobs: 21 | check-dockerfiles: 22 | name: Check Dockerfile 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | 28 | - name: Check php/Dockerfile 29 | uses: hadolint/hadolint-action@v3.1.0 30 | with: 31 | dockerfile: infrastructure/docker/services/php/Dockerfile 32 | 33 | ci: 34 | name: Continuous Integration 35 | runs-on: ubuntu-latest 36 | steps: 37 | - name: Log in to the Container registry 38 | uses: docker/login-action@v2 39 | with: 40 | registry: 'ghcr.io' 41 | username: ${{ github.actor }} 42 | password: ${{ secrets.GITHUB_TOKEN }} 43 | 44 | - uses: actions/checkout@v4 45 | 46 | - name: setup-castor 47 | uses: castor-php/setup-castor@v0.1.0 48 | 49 | - name: "Build and start the infrastructure" 50 | run: "castor start" 51 | 52 | - name: "Check PHP coding standards" 53 | run: "castor qa:cs --dry-run" 54 | 55 | - name: "Run PHPStan" 56 | run: "castor qa:phpstan" 57 | 58 | - name: "Run PHPUnit" 59 | run: "castor qa:phpunit" 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | 3 | ###> symfony/framework-bundle ### 4 | /.env.local 5 | /.env.local.php 6 | /.env.*.local 7 | /config/secrets/prod/prod.decrypt.private.php 8 | /public/bundles/ 9 | /var/ 10 | /vendor/ 11 | ###< symfony/framework-bundle ### 12 | 13 | ###> symfony/phpunit-bridge ### 14 | .phpunit 15 | .phpunit.result.cache 16 | /phpunit.xml 17 | ###< symfony/phpunit-bridge ### 18 | /.serverless/ 19 | /serverless.yml 20 | 21 | ###> friendsofphp/php-cs-fixer ### 22 | /.php-cs-fixer.cache 23 | ###< friendsofphp/php-cs-fixer ### 24 | 25 | # Infra stuff 26 | /.castor.stub.php 27 | /infrastructure/docker/docker-compose.override.yml 28 | /infrastructure/docker/services/router/certs/*.pem 29 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | in(__DIR__) 5 | ->exclude('var') 6 | ; 7 | 8 | return (new PhpCsFixer\Config()) 9 | ->setRiskyAllowed(true) 10 | ->setRules([ 11 | '@PHP81Migration' => true, 12 | '@PhpCsFixer' => true, 13 | '@Symfony' => true, 14 | '@Symfony:risky' => true, 15 | 'php_unit_internal_class' => false, // From @PhpCsFixer but we don't want it 16 | 'php_unit_test_class_requires_covers' => false, // From @PhpCsFixer but we don't want it 17 | 'phpdoc_add_missing_param_annotation' => false, // From @PhpCsFixer but we don't want it 18 | 'concat_space' => ['spacing' => 'one'], 19 | 'ordered_class_elements' => true, // Symfony(PSR12) override the default value, but we don't want 20 | 'blank_line_before_statement' => true, // Symfony(PSR12) override the default value, but we don't want 21 | ]) 22 | ->setFinder($finder) 23 | ; 24 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First of all, **thank you** for contributing, **you are awesome**! 4 | 5 | Everybody should be able to help. Here's how you can do it: 6 | 7 | 1. [Fork it](https://github.com/jolicode/monologue/fork_select) 8 | 2. improve it 9 | 3. submit a [pull request](https://help.github.com/articles/creating-a-pull-request) 10 | 11 | Here's some tips to make you the best contributor ever: 12 | 13 | * [Rules](#rules) 14 | * [Green tests](#green-tests) 15 | * [Standard code](#standard-code) 16 | * [Keeping your fork up-to-date](#keeping-your-fork-up-to-date) 17 | 18 | ## Rules 19 | 20 | Here are a few rules to follow in order to ease code reviews, and discussions 21 | before maintainers accept and merge your work. 22 | 23 | * You MUST follow the [PSR-1](http://www.php-fig.org/psr/1/) and 24 | [PSR-2](http://www.php-fig.org/psr/2/) (see [Rules](#rules)). 25 | * You MUST run the test suite (see [Green tests](#green-tests)). 26 | * You MUST write (or update) unit tests. 27 | * You SHOULD write documentation. 28 | 29 | Please, write [commit messages that make 30 | sense](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html), 31 | and [rebase your branch](http://git-scm.com/book/en/Git-Branching-Rebasing) 32 | before submitting your Pull Request (see also how to [keep your 33 | fork up-to-date](#keeping-your-fork-up-to-date)). 34 | 35 | One may ask you to [squash your 36 | commits](http://gitready.com/advanced/2009/02/10/squashing-commits-with-rebase.html) 37 | too. This is used to "clean" your Pull Request before merging it (we don't want 38 | commits such as `fix tests`, `fix 2`, `fix 3`, etc.). 39 | 40 | Also, while creating your Pull Request on GitHub, you MUST write a description 41 | which gives the context and/or explains why you are creating it. 42 | 43 | Your work will then be reviewed as soon as possible (suggestions about some 44 | changes, improvements or alternatives may be given). 45 | 46 | ## Green tests 47 | 48 | Run the tests using the following script: 49 | 50 | ```shell 51 | # Only for the first time 52 | symfony run bin/db --env=test 53 | symfony php bin/phpunit 54 | ``` 55 | 56 | ## Standard code 57 | 58 | Use [PHP CS fixer](http://cs.sensiolabs.org/) to make your code compliant with 59 | Monologue's coding standards: 60 | 61 | ```shell 62 | php-cs-fixer fix 63 | ``` 64 | 65 | ## Keeping your fork up-to-date 66 | 67 | To keep your fork up-to-date, you should track the upstream (original) one 68 | using the following command: 69 | 70 | 71 | ```shell 72 | git remote add upstream https://github.com/jolicode/monologue.git 73 | ``` 74 | 75 | Then get the upstream changes: 76 | 77 | ```shell 78 | git checkout main 79 | git pull --rebase origin main 80 | git pull --rebase upstream main 81 | git checkout 82 | git rebase main 83 | ``` 84 | 85 | Finally, publish your changes: 86 | 87 | ```shell 88 | git push -f origin 89 | ``` 90 | 91 | Your pull request will be automatically updated. 92 | 93 | Thank you! 94 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 JoliCode 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 | # Monologue Bot 2 | 3 | At [JoliCode](https://jolicode.com/) we use [Slack](https://slack.com/) to 4 | communicate. And to bring fun to our daily life, we created a *#monologue* channel 5 | where we can share our thoughts, our feelings, our dreams, our fears, our jokes, 6 | our memes, our pictures, our videos, our music, our links, our code, our life. 7 | But we have a special rule in this channel. 8 | 9 | > Only **one person can use this channel per day**. The others cannot: no 10 | > message, no reaction, no changing the topic, no message deleting, no poll. The 11 | > pledge is to offer breakfast 🍪🍩🥐. 12 | 13 | This bot is here to help us to respect this rule. It will post a message in the 14 | channel as soon as someone breaks the rule! 15 | 16 |  17 |  18 | 19 | If you want to get more information, you can read the [announce of the release](https://jolicode.com/blog/we-are-open-sourcing-a-silly-slack-bot-guess-what-it-does). 20 | 21 | ## Installation 22 | 23 | ### Configure the Slack Application 24 | 25 | We provide a app manifest to help you to create the Slack application. You can 26 | load the file in `doc/slack.yaml` when adding a new application in your 27 | workspace. Don't forget to replace all callback URLs! 28 | 29 | Otherwise, you can follow the steps below: 30 | 31 | * In "Basic Infirmation" 32 | * Copy the `Signin secret` to the `.env.local` file, `SLACK_SIGNING_SECRET` 33 | key 34 | * Set permission on bot (in "OAuth & Permissions") 35 | * `commands` 36 | * `channels:history` 37 | * `chat:write` 38 | * Invite the bot/apps in your channel 39 | * In "Interactivity & Shortcuts" 40 | * Enable interactivity 41 | * Add this URL: `https://example.com/action` 42 | * Slack command 43 | * Command: `/monologue` (or whatever you like) 44 | * Request URL: `https://example.com/command/list` 45 | * Short Description: `List all debts` 46 | * Command: `/amnesty` (or whatever you like) 47 | * Request URL: `https://example.com/command/amnesty` 48 | * Short Description: `Ask for a general amnesty` 49 | * Event Subscription 50 | * Enable events 51 | * URL: `https://example.com/message` 52 | * Events: 53 | * `message.channels` 54 | * `reaction_added` 55 | * In "Install App": 56 | * install the application 57 | * Copy the `Bot User OAuth Token` to the `.env.local` file, `SLACK_TOKEN` 58 | key 59 | * From somewhere (this information is always hard to find) 60 | * Copy the channel ID (where you'll invite the bot) to the `.env.local` 61 | file, `SLACK_CHANNEL` key 62 | 63 | ### Install the PHP application 64 | 65 | This project uses [castor](https://github.com/jolicode/castor) to manage common 66 | tasks. It's not mandatory but it's easier with it. 67 | 68 | # configure remaining parameters in .env.local 69 | castor start 70 | 71 | ## Test 72 | 73 | castor qa:all 74 | 75 | ## Usage 76 | 77 | In slack you have two commands 78 | 79 | * `/monologue` to list all the debts; 80 | * `/amnesty` to ask for a general amnesty. 81 | 82 | ## Credits 83 | 84 | Thanks JoliCode for sponsoring this project. 85 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getParameterOption(['--env', '-e'], null, true)) { 24 | putenv('APP_ENV='.$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = $env); 25 | } 26 | 27 | if ($input->hasParameterOption('--no-debug', true)) { 28 | putenv('APP_DEBUG='.$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = '0'); 29 | } 30 | 31 | (new Dotenv())->bootEnv(dirname(__DIR__).'/.env'); 32 | 33 | if ($_SERVER['APP_DEBUG']) { 34 | umask(0000); 35 | 36 | if (class_exists(Debug::class)) { 37 | Debug::enable(); 38 | } 39 | } 40 | 41 | $kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']); 42 | $application = new Application($kernel); 43 | $application->run($input); 44 | -------------------------------------------------------------------------------- /bin/db: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | BASE=$(dirname $0)/.. 5 | 6 | $BASE/bin/console doctrine:database:drop --force --if-exists $@ 7 | $BASE/bin/console doctrine:database:create --if-not-exists $@ 8 | $BASE/bin/console doctrine:migration:migrate -n $@ 9 | -------------------------------------------------------------------------------- /bin/phpunit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | $projectName, 30 | 'root_domain' => "{$projectName}.{$tld}", 31 | 'extra_domains' => [ 32 | "www.{$projectName}.{$tld}", 33 | ], 34 | 'php_version' => '8.3', 35 | ]; 36 | } 37 | 38 | #[AsTask(description: 'Builds and starts the infrastructure, then install the application (composer, yarn, ...)')] 39 | function start(): void 40 | { 41 | io()->title('Starting the stack'); 42 | 43 | generate_certificates(force: false); 44 | build(); 45 | up(profiles: ['default']); // We can't start worker now, they are not installed 46 | cache_clear(); 47 | install(); 48 | migrate(); 49 | migrate('test'); 50 | 51 | notify('The stack is now up and running.'); 52 | io()->success('The stack is now up and running.'); 53 | 54 | about(); 55 | } 56 | 57 | #[AsTask(description: 'Installs the application (composer, yarn, ...)', namespace: 'app', aliases: ['install'])] 58 | function install(): void 59 | { 60 | io()->title('Installing the application'); 61 | 62 | $basePath = variable('root_dir'); 63 | 64 | if (is_file("{$basePath}/composer.json")) { 65 | io()->section('Installing PHP dependencies'); 66 | docker_compose_run('composer install -n --prefer-dist --optimize-autoloader'); 67 | } 68 | if (is_file("{$basePath}/yarn.lock")) { 69 | io()->section('Installing Node.js dependencies'); 70 | docker_compose_run('yarn install --frozen-lockfile'); 71 | } elseif (is_file("{$basePath}/package.json")) { 72 | io()->section('Installing Node.js dependencies'); 73 | 74 | if (is_file("{$basePath}/package-lock.json")) { 75 | docker_compose_run('npm ci'); 76 | } else { 77 | docker_compose_run('npm install'); 78 | } 79 | } 80 | if (is_file("{$basePath}/importmap.php")) { 81 | io()->section('Installing importmap'); 82 | docker_compose_run('bin/console importmap:install'); 83 | } 84 | 85 | qa\install(); 86 | } 87 | 88 | #[AsTask(description: 'Clear the application cache', namespace: 'app', aliases: ['cache-clear'])] 89 | function cache_clear(): void 90 | { 91 | io()->title('Clearing the application cache'); 92 | 93 | docker_compose_run('rm -rf var/cache/'); 94 | // On the very first run, the vendor does not exist yet 95 | if (is_dir(variable('root_dir') . '/vendor')) { 96 | docker_compose_run('bin/console cache:warmup'); 97 | } 98 | } 99 | 100 | #[AsTask(description: 'Migrates database schema', namespace: 'app:db', aliases: ['migrate'])] 101 | function migrate(string $env = 'dev'): void 102 | { 103 | io()->title('Migrating the database schema'); 104 | 105 | docker_compose_run('bin/console doctrine:database:create --if-not-exists --env=' . $env); 106 | docker_compose_run('bin/console doctrine:migration:migrate -n --allow-no-migration --all-or-nothing --env=' . $env); 107 | } 108 | 109 | #[AsTask(description: 'Loads fixtures', namespace: 'app:db', aliases: ['fixture'])] 110 | function fixtures(): void 111 | { 112 | io()->title('Loads fixtures'); 113 | 114 | docker_compose_run('bin/console doctrine:fixture:load -n'); 115 | } 116 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jolicode/monologue", 3 | "type": "project", 4 | "license": "MIT", 5 | "prefer-stable": true, 6 | "require": { 7 | "php": ">=8.1", 8 | "ext-ctype": "*", 9 | "ext-iconv": "*", 10 | "doctrine/doctrine-bundle": "^2.13.1", 11 | "doctrine/doctrine-migrations-bundle": "^3.3.1", 12 | "doctrine/orm": "^2.20.0", 13 | "symfony/console": "7.1.*", 14 | "symfony/dotenv": "7.1.*", 15 | "symfony/flex": "^2.4.7", 16 | "symfony/framework-bundle": "7.1.*", 17 | "symfony/http-client": "7.1.*", 18 | "symfony/monolog-bundle": "^3.10", 19 | "symfony/runtime": "7.1.*", 20 | "symfony/security-bundle": "7.1.*", 21 | "symfony/twig-bundle": "7.1.*", 22 | "symfony/yaml": "7.1.*" 23 | }, 24 | "require-dev": { 25 | "symfony/browser-kit": "7.1.*", 26 | "symfony/css-selector": "7.1.*", 27 | "symfony/debug-bundle": "7.1.*", 28 | "symfony/maker-bundle": "^1.61", 29 | "symfony/phpunit-bridge": "7.1.*", 30 | "symfony/stopwatch": "7.1.*", 31 | "symfony/web-profiler-bundle": "7.1.*" 32 | }, 33 | "replace": { 34 | "paragonie/random_compat": "2.*", 35 | "symfony/polyfill-ctype": "*", 36 | "symfony/polyfill-iconv": "*", 37 | "symfony/polyfill-php82": "*", 38 | "symfony/polyfill-php81": "*", 39 | "symfony/polyfill-php80": "*", 40 | "symfony/polyfill-php74": "*", 41 | "symfony/polyfill-php73": "*", 42 | "symfony/polyfill-php72": "*", 43 | "symfony/polyfill-php71": "*", 44 | "symfony/polyfill-php70": "*", 45 | "symfony/polyfill-php56": "*" 46 | }, 47 | "conflict": { 48 | "symfony/symfony": "*" 49 | }, 50 | "config": { 51 | "allow-plugins": { 52 | "symfony/flex": true, 53 | "symfony/runtime": true 54 | }, 55 | "bump-after-update": true, 56 | "optimize-autoloader": true, 57 | "sort-packages": true 58 | }, 59 | "extra": { 60 | "symfony": { 61 | "allow-contrib": false, 62 | "require": "7.1.*" 63 | } 64 | }, 65 | "autoload": { 66 | "psr-4": { 67 | "App\\": "src/" 68 | } 69 | }, 70 | "autoload-dev": { 71 | "psr-4": { 72 | "App\\Tests\\": "tests/" 73 | } 74 | }, 75 | "scripts": { 76 | "auto-scripts": { 77 | "cache:clear": "symfony-cmd", 78 | "assets:install %PUBLIC_DIR%": "symfony-cmd" 79 | }, 80 | "post-install-cmd": [ 81 | "@auto-scripts" 82 | ], 83 | "post-update-cmd": [ 84 | "@auto-scripts" 85 | ] 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 6 | Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], 7 | Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], 8 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], 9 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 10 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], 11 | Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true], 12 | Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], 13 | ]; 14 | -------------------------------------------------------------------------------- /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/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 | types: 5 | datetime_immutable_ms: App\Doctrine\Type\DateTimeImmutableWithMillis 6 | orm: 7 | auto_generate_proxy_classes: true 8 | naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware 9 | auto_mapping: true 10 | mappings: 11 | App: 12 | is_bundle: false 13 | dir: '%kernel.project_dir%/src/Entity' 14 | prefix: 'App\Entity' 15 | alias: App 16 | 17 | when@test: 18 | doctrine: 19 | dbal: 20 | # "TEST_TOKEN" is typically set by ParaTest 21 | dbname_suffix: '_test%env(default::TEST_TOKEN)%' 22 | 23 | when@prod: 24 | doctrine: 25 | orm: 26 | auto_generate_proxy_classes: false 27 | query_cache_driver: 28 | type: pool 29 | pool: doctrine.system_cache_pool 30 | result_cache_driver: 31 | type: pool 32 | pool: doctrine.result_cache_pool 33 | 34 | framework: 35 | cache: 36 | pools: 37 | doctrine.result_cache_pool: 38 | adapter: cache.app 39 | doctrine.system_cache_pool: 40 | adapter: cache.system 41 | -------------------------------------------------------------------------------- /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: false 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 | http_client: 22 | default_options: 23 | timeout: 5.0 24 | 25 | when@test: 26 | framework: 27 | test: true 28 | session: 29 | storage_factory_id: session.storage.factory.mock_file 30 | -------------------------------------------------------------------------------- /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/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/security.yaml: -------------------------------------------------------------------------------- 1 | security: 2 | password_hashers: 3 | Symfony\Component\Security\Core\User\InMemoryUser: 4 | algorithm: plaintext 5 | 6 | providers: 7 | in_memory: 8 | memory: 9 | users: 10 | monologue: { password: '%env(DASHBOARD_PASSWORD)%' } 11 | firewalls: 12 | dev: 13 | pattern: ^/(_(profiler|wdt)|css|images|js)/ 14 | security: false 15 | 16 | home: 17 | pattern: ^/$ 18 | http_basic: true 19 | 20 | access_control: 21 | - { path: ^/, roles: IS_AUTHENTICATED_FULLY } 22 | 23 | when@test: 24 | security: 25 | password_hashers: 26 | Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 27 | algorithm: auto 28 | cost: 4 29 | time_cost: 3 30 | memory_cost: 10 31 | -------------------------------------------------------------------------------- /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/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 | /etc/apt/sources.list.d/sury.list \ 16 | && apt-get clean \ 17 | && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/* 18 | 19 | RUN apt-get update \ 20 | && apt-get install -y --no-install-recommends \ 21 | bash-completion \ 22 | procps \ 23 | && apt-get clean \ 24 | && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/* 25 | 26 | ARG PHP_VERSION 27 | 28 | RUN apt-get update \ 29 | && apt-get install -y --no-install-recommends \ 30 | "php${PHP_VERSION}-apcu" \ 31 | "php${PHP_VERSION}-bcmath" \ 32 | "php${PHP_VERSION}-cli" \ 33 | "php${PHP_VERSION}-common" \ 34 | "php${PHP_VERSION}-curl" \ 35 | "php${PHP_VERSION}-iconv" \ 36 | "php${PHP_VERSION}-intl" \ 37 | "php${PHP_VERSION}-mbstring" \ 38 | "php${PHP_VERSION}-pgsql" \ 39 | "php${PHP_VERSION}-uuid" \ 40 | "php${PHP_VERSION}-xml" \ 41 | "php${PHP_VERSION}-zip" \ 42 | && apt-get clean \ 43 | && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/* 44 | 45 | # Fake user to maps with the one on the host 46 | ARG USER_ID 47 | COPY entrypoint / 48 | RUN addgroup --gid $USER_ID app && \ 49 | adduser --system --uid $USER_ID --home /home/app --shell /bin/bash app && \ 50 | curl -Ls https://github.com/tianon/gosu/releases/download/1.17/gosu-amd64 | \ 51 | install /dev/stdin /usr/local/bin/gosu && \ 52 | sed "s/{{ application_user }}/app/g" -i /entrypoint 53 | 54 | # Configuration 55 | COPY base/php-configuration /etc/php/${PHP_VERSION} 56 | 57 | ENV PHP_VERSION=${PHP_VERSION} 58 | 59 | WORKDIR /var/www 60 | 61 | ENTRYPOINT [ "/entrypoint" ] 62 | 63 | FROM php-base AS frontend 64 | 65 | RUN apt-get update \ 66 | && apt-get install -y --no-install-recommends \ 67 | nginx \ 68 | "php${PHP_VERSION}-fpm" \ 69 | runit \ 70 | && apt-get clean \ 71 | && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/* \ 72 | && rm -r "/etc/php/${PHP_VERSION}/fpm/pool.d/" 73 | 74 | RUN useradd -s /bin/false nginx 75 | 76 | COPY frontend/php-configuration /etc/php/${PHP_VERSION} 77 | COPY frontend/etc/nginx/. /etc/nginx/ 78 | COPY frontend/etc/service/. /etc/service/ 79 | 80 | RUN phpenmod app-default \ 81 | && phpenmod app-fpm 82 | 83 | EXPOSE 80 84 | 85 | CMD ["runsvdir", "-P", "/etc/service"] 86 | 87 | FROM php-base AS builder 88 | 89 | SHELL ["/bin/bash", "-o", "pipefail", "-c"] 90 | 91 | ARG NODEJS_VERSION=20.x 92 | RUN curl -s https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /usr/share/keyrings/nodesource.gpg \ 93 | && echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODEJS_VERSION} nodistro main" > /etc/apt/sources.list.d/nodesource.list 94 | 95 | # Default toys 96 | RUN apt-get update \ 97 | && apt-get install -y --no-install-recommends \ 98 | git \ 99 | make \ 100 | sudo \ 101 | unzip \ 102 | && apt-get clean \ 103 | && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/* 104 | 105 | # Config 106 | COPY builder/etc/. /etc/ 107 | COPY builder/php-configuration /etc/php/${PHP_VERSION} 108 | RUN adduser app sudo \ 109 | && mkdir /var/log/php \ 110 | && chmod 777 /var/log/php \ 111 | && phpenmod app-default \ 112 | && phpenmod app-builder 113 | 114 | # Composer 115 | COPY --from=composer/composer:2.8.2 /usr/bin/composer /usr/bin/composer 116 | RUN mkdir -p "/home/app/.composer/cache" \ 117 | && chown app: /home/app/.composer -R 118 | 119 | ADD https://raw.githubusercontent.com/symfony/symfony/refs/heads/7.2/src/Symfony/Component/Console/Resources/completion.bash /tmp/completion.bash 120 | 121 | # Composer symfony/console version is too old, and doest not support "API version feature", so we remove it 122 | # Hey, while we are at it, let's add some more completion 123 | RUN sed /tmp/completion.bash \ 124 | -e "s/{{ COMMAND_NAME }}/composer/g" \ 125 | -e 's/"-a{{ VERSION }}"//g' \ 126 | -e "s/{{ VERSION }}/1/g" \ 127 | > /etc/bash_completion.d/composer \ 128 | && sed /tmp/completion.bash \ 129 | -e "s/{{ COMMAND_NAME }}/console/g" \ 130 | -e "s/{{ VERSION }}/1/g" \ 131 | > /etc/bash_completion.d/console 132 | 133 | # Third party tools 134 | ENV PATH="$PATH:/var/www/tools/bin" 135 | 136 | # Good default customization 137 | RUN cat >> /etc/bash.bashrc </dev/null || command -v sh)" -l 16 | fi 17 | 18 | if [ "$UID" != 0 ]; then 19 | usermod -u "$UID" "{{ application_user }}" >/dev/null 2>/dev/null && { 20 | groupmod -g "$GID" "{{ application_user }}" >/dev/null 2>/dev/null || 21 | usermod -a -G "$GID" "{{ application_user }}" >/dev/null 2>/dev/null 22 | } 23 | set -- gosu "${UID}:${GID}" "${@}" 24 | fi 25 | 26 | exec "$@" 27 | -------------------------------------------------------------------------------- /infrastructure/docker/services/php/frontend/etc/nginx/environments: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jolicode/monologue/582f60956b61d5deabba6e93d6acf718858a3885/infrastructure/docker/services/php/frontend/etc/nginx/environments -------------------------------------------------------------------------------- /infrastructure/docker/services/php/frontend/etc/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | pid /var/run/nginx.pid; 3 | daemon off; 4 | error_log /proc/self/fd/2; 5 | include /etc/nginx/modules-enabled/*.conf; 6 | 7 | http { 8 | access_log /proc/self/fd/1; 9 | sendfile on; 10 | tcp_nopush on; 11 | tcp_nodelay on; 12 | keepalive_timeout 65; 13 | types_hash_max_size 2048; 14 | include /etc/nginx/mime.types; 15 | default_type application/octet-stream; 16 | client_max_body_size 20m; 17 | server_tokens off; 18 | 19 | gzip on; 20 | gzip_disable "msie6"; 21 | gzip_vary on; 22 | gzip_proxied any; 23 | gzip_comp_level 6; 24 | gzip_buffers 16 8k; 25 | gzip_http_version 1.1; 26 | gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml; 27 | 28 | server { 29 | listen 0.0.0.0:80; 30 | root /var/www/public; 31 | 32 | location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg)$ { 33 | access_log off; 34 | add_header Cache-Control "no-cache"; 35 | } 36 | 37 | # Remove this block if you want to access to PHP FPM monitoring 38 | # dashboard (on URL: /php-fpm-status). WARNING: on production, you must 39 | # secure this page (by user IP address, with a password, for example) 40 | location ~ ^/php-fpm-status$ { 41 | deny all; 42 | fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; 43 | fastcgi_index index.php; 44 | include fastcgi_params; 45 | fastcgi_pass 127.0.0.1:9000; 46 | } 47 | 48 | location / { 49 | # try to serve file directly, fallback to index.php 50 | try_files $uri /index.php$is_args$args; 51 | } 52 | 53 | location ~ ^/index\.php(/|$) { 54 | fastcgi_pass 127.0.0.1:9000; 55 | fastcgi_split_path_info ^(.+\.php)(/.*)$; 56 | 57 | include fastcgi_params; 58 | include environments; 59 | 60 | fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; 61 | fastcgi_param HTTPS on; 62 | fastcgi_param SERVER_NAME $http_host; 63 | # # Uncomment if you want to use /php-fpm-status endpoint **with** 64 | # # real request URI. It may have some side effects, that's why it's 65 | # # commented by default 66 | # fastcgi_param SCRIPT_NAME $request_uri; 67 | } 68 | 69 | error_log /proc/self/fd/2; 70 | access_log /proc/self/fd/1; 71 | } 72 | } 73 | 74 | events {} 75 | -------------------------------------------------------------------------------- /infrastructure/docker/services/php/frontend/etc/service/nginx/run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | exec /usr/sbin/nginx 4 | -------------------------------------------------------------------------------- /infrastructure/docker/services/php/frontend/etc/service/php-fpm/run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | exec /usr/sbin/php-fpm${PHP_VERSION} -y /etc/php/${PHP_VERSION}/fpm/php-fpm.conf -O 4 | -------------------------------------------------------------------------------- /infrastructure/docker/services/php/frontend/php-configuration/fpm/php-fpm.conf: -------------------------------------------------------------------------------- 1 | [global] 2 | pid = /var/run/php-fpm.pid 3 | error_log = /proc/self/fd/2 4 | daemonize = no 5 | 6 | [www] 7 | user = app 8 | group = app 9 | listen = 127.0.0.1:9000 10 | pm = dynamic 11 | pm.max_children = 25 12 | pm.start_servers = 2 13 | pm.min_spare_servers = 2 14 | pm.max_spare_servers = 3 15 | pm.max_requests = 500 16 | pm.status_path = /php-fpm-status 17 | clear_env = no 18 | request_terminate_timeout = 120s 19 | catch_workers_output = yes 20 | -------------------------------------------------------------------------------- /infrastructure/docker/services/php/frontend/php-configuration/mods-available/app-fpm.ini: -------------------------------------------------------------------------------- 1 | ; priority=40 2 | [PHP] 3 | expose_php = off 4 | memory_limit = 128M 5 | max_execution_time = 30 6 | -------------------------------------------------------------------------------- /infrastructure/docker/services/router/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM traefik:v3.1 2 | 3 | COPY traefik /etc/traefik 4 | 5 | ARG PROJECT_NAME 6 | RUN sed -i "s/{{ PROJECT_NAME }}/${PROJECT_NAME}/g" /etc/traefik/traefik.yaml 7 | 8 | VOLUME [ "/etc/ssl/certs" ] 9 | -------------------------------------------------------------------------------- /infrastructure/docker/services/router/certs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jolicode/monologue/582f60956b61d5deabba6e93d6acf718858a3885/infrastructure/docker/services/router/certs/.gitkeep -------------------------------------------------------------------------------- /infrastructure/docker/services/router/generate-ssl.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Script used in dev to generate a basic SSL cert 4 | 5 | BASE=$(dirname $0) 6 | 7 | CERTS_DIR=$BASE/certs 8 | 9 | rm -rf $CERTS_DIR 10 | mkdir -p $CERTS_DIR 11 | touch $CERTS_DIR/.gitkeep 12 | 13 | openssl req -x509 -sha256 -newkey rsa:4096 \ 14 | -keyout $CERTS_DIR/key.pem \ 15 | -out $CERTS_DIR/cert.pem \ 16 | -days 3650 -nodes -config \ 17 | $BASE/openssl.cnf 18 | -------------------------------------------------------------------------------- /infrastructure/docker/services/router/openssl.cnf: -------------------------------------------------------------------------------- 1 | # Configuration used in dev to generate a basic SSL cert 2 | [req] 3 | default_bits = 2048 4 | prompt = no 5 | default_md = sha256 6 | distinguished_name = dn 7 | x509_extensions = v3_req 8 | 9 | [v3_req] 10 | subjectKeyIdentifier=hash 11 | authorityKeyIdentifier=keyid:always,issuer:always 12 | basicConstraints=CA:true 13 | subjectAltName = @alt_names 14 | 15 | [dn] 16 | CN=app.test 17 | 18 | [alt_names] 19 | DNS.1 = app.test 20 | -------------------------------------------------------------------------------- /infrastructure/docker/services/router/traefik/dynamic_conf.yaml: -------------------------------------------------------------------------------- 1 | tls: 2 | stores: 3 | default: 4 | defaultCertificate: 5 | certFile: /etc/ssl/certs/cert.pem 6 | keyFile: /etc/ssl/certs/key.pem 7 | 8 | http: 9 | middlewares: 10 | redirect-to-https: 11 | redirectScheme: 12 | scheme: https 13 | -------------------------------------------------------------------------------- /infrastructure/docker/services/router/traefik/traefik.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | checkNewVersion: false 3 | sendAnonymousUsage: false 4 | 5 | providers: 6 | docker: 7 | exposedByDefault: false 8 | constraints: "Label(`project-name`,`{{ PROJECT_NAME }}`)" 9 | file: 10 | filename: /etc/traefik/dynamic_conf.yaml 11 | 12 | # # Uncomment get all DEBUG logs 13 | #log: 14 | # level: "DEBUG" 15 | 16 | # # Uncomment to view all access logs 17 | #accessLog: {} 18 | 19 | api: 20 | dashboard: true 21 | insecure: true # No authentication are required 22 | 23 | entryPoints: 24 | http: 25 | address: ":80" 26 | https: 27 | address: ":443" 28 | traefik: # this one exists by default 29 | address: ":8080" 30 | -------------------------------------------------------------------------------- /migrations/Version20190903163028.php: -------------------------------------------------------------------------------- 1 | addSql('CREATE TABLE event (id UUID NOT NULL, type VARCHAR(255) NOT NULL, content TEXT NOT NULL, author VARCHAR(255) NOT NULL, created_at TIMESTAMP(3) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); 15 | $this->addSql('COMMENT ON COLUMN event.created_at IS \'(DC2Type:datetime_immutable_ms)\''); 16 | $this->addSql('CREATE TABLE debt (id UUID NOT NULL, event_id UUID NOT NULL, cause_id UUID NOT NULL, author VARCHAR(255) NOT NULL, created_at DATE NOT NULL, paid BOOLEAN NOT NULL, PRIMARY KEY(id))'); 17 | $this->addSql('CREATE UNIQUE INDEX UNIQ_DBBF0A8371F7E88B ON debt (event_id)'); 18 | $this->addSql('CREATE INDEX IDX_DBBF0A8366E2221E ON debt (cause_id)'); 19 | $this->addSql('COMMENT ON COLUMN debt.created_at IS \'(DC2Type:date_immutable)\''); 20 | $this->addSql('ALTER TABLE debt ADD CONSTRAINT FK_DBBF0A8371F7E88B FOREIGN KEY (event_id) REFERENCES event (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); 21 | $this->addSql('ALTER TABLE debt ADD CONSTRAINT FK_DBBF0A8366E2221E FOREIGN KEY (cause_id) REFERENCES event (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); 22 | } 23 | 24 | public function down(Schema $schema): void 25 | { 26 | $this->addSql('CREATE SCHEMA public'); 27 | $this->addSql('ALTER TABLE debt DROP CONSTRAINT FK_DBBF0A8371F7E88B'); 28 | $this->addSql('ALTER TABLE debt DROP CONSTRAINT FK_DBBF0A8366E2221E'); 29 | $this->addSql('DROP TABLE event'); 30 | $this->addSql('DROP TABLE debt'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /migrations/Version20190911161036.php: -------------------------------------------------------------------------------- 1 | addSql('ALTER TABLE debt ADD paid_at DATE DEFAULT NULL'); 15 | $this->addSql('COMMENT ON COLUMN debt.paid_at IS \'(DC2Type:date_immutable)\''); 16 | } 17 | 18 | public function down(Schema $schema): void 19 | { 20 | $this->addSql('ALTER TABLE debt DROP paid_at'); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /migrations/Version20210426140411.php: -------------------------------------------------------------------------------- 1 | addSql('CREATE TABLE amnesty (id UUID NOT NULL, date DATE NOT NULL, user_ids JSON NOT NULL, redeemed BOOLEAN NOT NULL, PRIMARY KEY(id))'); 15 | $this->addSql('COMMENT ON COLUMN amnesty.date IS \'(DC2Type:date_immutable)\''); 16 | } 17 | 18 | public function down(Schema $schema): void 19 | { 20 | $this->addSql('DROP TABLE amnesty'); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - 4 | message: "#^Property App\\\\Entity\\\\Debt\\:\\:\\$paidAt is never read, only written\\.$#" 5 | count: 1 6 | path: src/Entity/Debt.php 7 | 8 | - 9 | message: "#^Property App\\\\Entity\\\\Event\\:\\:\\$id is never read, only written\\.$#" 10 | count: 1 11 | path: src/Entity/Event.php 12 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - phpstan-baseline.neon 3 | parameters: 4 | level: 8 5 | paths: 6 | - src 7 | - public 8 | - castor.php 9 | - .castor/ 10 | scanFiles: 11 | - .castor.stub.php 12 | tmpDir: tools/phpstan/var 13 | inferPrivatePropertyTypeFromConstructor: true 14 | ignoreErrors: 15 | - '{Property .* type has no value type specified in iterable type array\.}' 16 | - '{Method .* return type has no value type specified in iterable type array\.}' 17 | - '{Method .* has parameter .* with no value type specified in iterable type array\.}' 18 | 19 | symfony: 20 | container_xml_path: 'var/cache/dev/App_KernelDevDebugContainer.xml' 21 | 22 | typeAliases: 23 | ContextData: ''' 24 | array{ 25 | project_name: string, 26 | root_domain: string, 27 | extra_domains: string[], 28 | project_directory: string, 29 | php_version: string, 30 | docker_compose_files: string[], 31 | macos: bool, 32 | power_shell: bool, 33 | user_id: int, 34 | root_dir: string, 35 | composer_cache_dir: string, 36 | } 37 | ''' 38 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | tests 25 | 26 | 27 | 28 | 29 | 30 | src 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | paths([ 10 | __DIR__ . '/src', 11 | ]); 12 | 13 | $rectorConfig->importNames(); 14 | $rectorConfig->importShortClasses(false); 15 | $rectorConfig->symfonyContainerXml(__DIR__ . '/var/cache/dev/App_KernelDevDebugContainer.xml'); 16 | $rectorConfig->bootstrapFiles([__DIR__ . '/config/bootstrap.php']); 17 | 18 | $rectorConfig->sets([ 19 | LevelSetList::UP_TO_PHP_81, 20 | SymfonyLevelSetList::UP_TO_SYMFONY_60, 21 | DoctrineSetList::ANNOTATIONS_TO_ATTRIBUTES, 22 | ]); 23 | }; 24 | -------------------------------------------------------------------------------- /src/ControlTower/BigBrowser.php: -------------------------------------------------------------------------------- 1 | logger->info('New payload.', [ 23 | 'payload' => $payload, 24 | ]); 25 | 26 | if (!$this->payloadFilter->isNewMessageOrReaction($payload)) { 27 | $this->logger->debug('Discard the payload.'); 28 | 29 | return null; 30 | } 31 | 32 | $debt = $this->debtCreator->createDebtIfNeeded($payload); 33 | 34 | if ($debt) { 35 | $this->newDebtNotifier->notifyNewDebt($debt); 36 | 37 | $this->logger->notice('A new debt has been created.', [ 38 | 'debt' => $debt, 39 | ]); 40 | } 41 | 42 | return $debt; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/ControlTower/DebtAcker.php: -------------------------------------------------------------------------------- 1 | debtRepository->find($debtId); 23 | if (!$debt) { 24 | throw new \DomainException('There are not debt with this UUID.'); 25 | } 26 | 27 | if ($debt->getAuthor() === $payload['user']['id']) { 28 | throw new \DomainException('You can not ACK your own debt.'); 29 | } 30 | 31 | $debt->markAsPaid(); 32 | 33 | return $debt; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/ControlTower/DebtCreator.php: -------------------------------------------------------------------------------- 1 | em->getConnection()->beginTransaction(); 26 | $tableName = $this->em->getClassMetadata(Event::class)->getTableName(); 27 | $this->em->getConnection()->executeStatement("LOCK {$tableName} IN ACCESS EXCLUSIVE MODE"); 28 | 29 | $event = $this->insertEvent($payload); 30 | $debt = $this->doCreateDebtIfNeeded($event); 31 | 32 | $this->em->getConnection()->commit(); 33 | 34 | return $debt; 35 | } 36 | 37 | private function insertEvent(array $payload): Event 38 | { 39 | $e = $payload['event']; 40 | 41 | if ('message' === $e['type']) { 42 | $text = $e['text']; 43 | } elseif ('reaction_added' === $e['type']) { 44 | $text = $e['reaction']; 45 | } else { 46 | throw new \RuntimeException('The type is not supported.'); 47 | } 48 | 49 | $date = new \DateTimeImmutable('@' . $e['event_ts']); 50 | $date = $date->setTimezone(new \DateTimeZone($this->timezone)); 51 | $event = new Event($e['type'], $text, $e['user'], $date); 52 | 53 | $this->em->persist($event); 54 | $this->em->flush(); 55 | 56 | return $event; 57 | } 58 | 59 | private function doCreateDebtIfNeeded(Event $event): ?Debt 60 | { 61 | $firstMessageOfDay = $this->eventRepository->getFirstMessageOfDay($event); 62 | if (!$firstMessageOfDay) { 63 | return null; 64 | } 65 | 66 | if ($firstMessageOfDay->getAuthor() === $event->getAuthor()) { 67 | return null; 68 | } 69 | 70 | $isDebtExist = $this->debtRepository->isDebtExist($event); 71 | if ($isDebtExist) { 72 | return null; 73 | } 74 | 75 | $debt = new Debt($event, $firstMessageOfDay); 76 | $this->em->persist($debt); 77 | $this->em->flush(); 78 | 79 | return $debt; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/ControlTower/Government.php: -------------------------------------------------------------------------------- 1 | amnestyRepository->findOneBy([ 27 | 'date' => new \DateTimeImmutable(), 28 | ]); 29 | 30 | if (!$amnesty) { 31 | $amnesty = new Amnesty(); 32 | $this->em->persist($amnesty); 33 | } 34 | 35 | $amnesty->addUserId($userId); 36 | 37 | // Throw an exception if the threshold is reached 38 | $amnesty->redeem($this->threshold); 39 | 40 | $this->debtRepository->ackAllDebts(); 41 | 42 | $this->messagePoster->postMessage('The amnesty has been redeemed. All debts have been wiped. 🎆'); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/ControlTower/NewDebtNotifier.php: -------------------------------------------------------------------------------- 1 | messagePoster->postMessage('Fraud detected.', $this->buildBlocks($debt)); 18 | } 19 | 20 | private function buildBlocks(Debt $debt): array 21 | { 22 | $event = $debt->getEvent(); 23 | 24 | $explanation = ''; 25 | 26 | if ('message' === $event->getType()) { 27 | $explanation = 'message posted'; 28 | } elseif ('reaction_added' === $event->getType()) { 29 | $explanation = \sprintf('reaction "%s" added', $event->getContent()); 30 | } 31 | 32 | if ($explanation) { 33 | $explanation = \sprintf(' Reason: %s.', $explanation); 34 | } 35 | 36 | return [ 37 | [ 38 | 'type' => 'context', 39 | 'elements' => [ 40 | [ 41 | 'type' => 'mrkdwn', 42 | 'text' => \sprintf('Thanks <@%s> for the next breakfast!%s', $event->getAuthor(), $explanation), 43 | ], 44 | ], 45 | ], 46 | ]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Controller/HomepageController.php: -------------------------------------------------------------------------------- 1 | query->get('date')); 25 | } catch (\Exception) { 26 | return new Response('Bad date format.', 400); 27 | } 28 | 29 | return $this->render('homepage/homepage.html.twig', [ 30 | 'events' => $this->eventRepository->findByCreatedAt($date), 31 | 'debts' => $this->debtRepository->findPendings(), 32 | 'date' => $date, 33 | ]); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Controller/SlackController.php: -------------------------------------------------------------------------------- 1 | true])] 20 | class SlackController extends AbstractController 21 | { 22 | public function __construct( 23 | private readonly BigBrowser $bigBrowser, 24 | private readonly DebtAcker $debtAcker, 25 | private readonly DebtListBlockBuilder $debtListBlockBuilder, 26 | private readonly DebtListPoster $debtListPoster, 27 | private readonly EntityManagerInterface $em, 28 | private readonly DebtAckPoster $debAckPoster, 29 | private readonly Government $government, 30 | private readonly LoggerInterface $logger = new NullLogger(), 31 | ) { 32 | } 33 | 34 | #[Route('/message', methods: 'POST')] 35 | public function messages(Request $request): Response 36 | { 37 | $payload = $request->toArray(); 38 | 39 | $this->bigBrowser->control($payload); 40 | 41 | return new Response('OK'); 42 | } 43 | 44 | #[Route('/action', methods: 'POST')] 45 | public function action(Request $request): Response 46 | { 47 | try { 48 | $payload = json_decode((string) $request->request->get('payload'), true, 512, \JSON_THROW_ON_ERROR); 49 | } catch (\JsonException) { 50 | return new Response('No payload', 400); 51 | } 52 | 53 | if (!preg_match('{^ack\-(.*)$}', $payload['actions'][0]['value'] ?? '', $m)) { 54 | return new Response('Payload not supported.', 400); 55 | } 56 | 57 | $debtId = $m[1]; 58 | 59 | try { 60 | $debt = $this->debtAcker->ackDebt($payload, $debtId); 61 | } catch (\DomainException $e) { 62 | $this->logger->warning('Something went wrong.', [ 63 | 'exception' => $e, 64 | ]); 65 | 66 | return new Response($e->getMessage(), 400); 67 | } 68 | $this->em->flush(); 69 | $this->debtListPoster->postDebtList($payload['user']['id'], $payload['response_url']); 70 | $this->debAckPoster->postDebtAck($debt, $payload['user']['id']); 71 | 72 | return new Response('ok'); 73 | } 74 | 75 | #[Route('/command/list', methods: 'POST')] 76 | public function commandList(Request $request): Response 77 | { 78 | return $this->json([ 79 | 'text' => 'Pending debts', // Not used but mandatory 80 | 'blocks' => $this->debtListBlockBuilder->buildBlocks($request->request->getAlnum('user_id')), 81 | ]); 82 | } 83 | 84 | #[Route('/command/amnesty', methods: 'POST')] 85 | public function commandAmnesty(Request $request): Response 86 | { 87 | try { 88 | $this->government->redeem((string) $request->request->get('user_id')); 89 | } catch (\DomainException $e) { 90 | return $this->json([ 91 | 'text' => $e->getMessage(), 92 | ]); 93 | } finally { 94 | $this->em->flush(); 95 | } 96 | 97 | return new Response(); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Doctrine/Type/DateTimeImmutableWithMillis.php: -------------------------------------------------------------------------------- 1 | format('Y-m-d H:i:s.v'); 29 | } 30 | 31 | throw ConversionException::conversionFailedInvalidType($value, $this->getName(), ['null', \DateTime::class]); 32 | } 33 | 34 | public function convertToPHPValue($value, AbstractPlatform $platform): \DateTimeInterface|\DateTimeImmutable|null 35 | { 36 | if (null === $value || $value instanceof \DateTimeInterface) { 37 | return $value; 38 | } 39 | 40 | $val = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s.v', $value); 41 | 42 | if (!$val) { 43 | $val = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value); 44 | } 45 | 46 | if (!$val) { 47 | throw ConversionException::conversionFailedFormat($value, $this->getName(), $platform->getDateTimeFormatString()); 48 | } 49 | 50 | return $val; 51 | } 52 | 53 | public function getName(): string 54 | { 55 | return 'datetime_immutable_ms'; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Entity/Amnesty.php: -------------------------------------------------------------------------------- 1 | id = uuid_create(); 28 | $this->date = new \DateTimeImmutable(); 29 | $this->userIds = []; 30 | $this->redeemed = false; 31 | } 32 | 33 | public function getId(): string 34 | { 35 | return $this->id; 36 | } 37 | 38 | public function getDate(): \DateTimeImmutable 39 | { 40 | return $this->date; 41 | } 42 | 43 | public function getUserIds(): ?array 44 | { 45 | return $this->userIds; 46 | } 47 | 48 | public function addUserId(string $userIds): void 49 | { 50 | $this->userIds[$userIds] = true; 51 | } 52 | 53 | public function redeem(int $threshold): void 54 | { 55 | if ($this->redeemed) { 56 | throw new \DomainException('The Amnesty has already been redeemed.'); 57 | } 58 | 59 | if (($c = \count($this->userIds)) < $threshold) { 60 | throw new \DomainException(\sprintf('More people need to ask for amnesty to complete it! (%d/%d)', $c, $threshold)); 61 | } 62 | 63 | $this->redeemed = true; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Entity/Debt.php: -------------------------------------------------------------------------------- 1 | id = uuid_create(); 38 | $this->author = $event->getAuthor(); 39 | $this->createdAt = new \DateTimeImmutable($event->getCreatedAt()->format('Y-m-d')); 40 | $this->paid = false; 41 | } 42 | 43 | public function getId(): string 44 | { 45 | return $this->id; 46 | } 47 | 48 | public function getEvent(): Event 49 | { 50 | return $this->event; 51 | } 52 | 53 | public function getCause(): Event 54 | { 55 | return $this->cause; 56 | } 57 | 58 | public function getAuthor(): string 59 | { 60 | return $this->author; 61 | } 62 | 63 | public function getCreatedAt(): \DateTimeImmutable 64 | { 65 | return $this->createdAt; 66 | } 67 | 68 | public function isPaid(): bool 69 | { 70 | return $this->paid; 71 | } 72 | 73 | public function markAsPaid(): void 74 | { 75 | if ($this->paid) { 76 | throw new \DomainException('The debt is already paid.'); 77 | } 78 | 79 | $this->paid = true; 80 | $this->paidAt = new \DateTimeImmutable(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Entity/Event.php: -------------------------------------------------------------------------------- 1 | id = uuid_create(); 30 | } 31 | 32 | public function getType(): string 33 | { 34 | return $this->type; 35 | } 36 | 37 | public function getContent(): string 38 | { 39 | return $this->content; 40 | } 41 | 42 | public function getAuthor(): string 43 | { 44 | return $this->author; 45 | } 46 | 47 | public function getCreatedAt(): \DateTimeImmutable 48 | { 49 | return $this->createdAt; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/EventSubscriber/ChallengeSubscriber.php: -------------------------------------------------------------------------------- 1 | isMainRequest()) { 14 | return; 15 | } 16 | 17 | try { 18 | $payload = json_decode($event->getRequest()->getContent(), true, 512, \JSON_THROW_ON_ERROR); 19 | } catch (\JsonException) { 20 | return; 21 | } 22 | 23 | if ('url_verification' === $payload['type']) { 24 | $event->setResponse(new Response($payload['challenge'])); 25 | } 26 | } 27 | 28 | public static function getSubscribedEvents(): array 29 | { 30 | return [ 31 | RequestEvent::class => ['verifyChallenge', 1024], 32 | ]; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/EventSubscriber/SignatureSubscriber.php: -------------------------------------------------------------------------------- 1 | signinSecret) { 21 | return; 22 | } 23 | 24 | if (!$event->isMainRequest()) { 25 | return; 26 | } 27 | 28 | $request = $event->getRequest(); 29 | 30 | if (!$request->attributes->get('slack', false)) { 31 | return; 32 | } 33 | 34 | $body = (string) $request->getContent(); 35 | $signature = $request->headers->get('X-Slack-Signature'); 36 | $timestamp = $request->headers->get('X-Slack-Request-Timestamp'); 37 | 38 | $payload = "v0:{$timestamp}:{$body}"; 39 | 40 | $signatureTmp = 'v0=' . hash_hmac('sha256', $payload, $this->signinSecret); 41 | 42 | if ($signatureTmp !== $signature) { 43 | $event->setResponse(new Response('You are not slack', 401)); 44 | } 45 | } 46 | 47 | public static function getSubscribedEvents(): array 48 | { 49 | return [ 50 | RequestEvent::class => ['verifySignature', 32 - 1], 51 | ]; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Kernel.php: -------------------------------------------------------------------------------- 1 | 11 | * 12 | * @method Amnesty|null find($id, $lockMode = null, $lockVersion = null) 13 | * @method Amnesty|null findOneBy(array $criteria, array $orderBy = null) 14 | * @method Amnesty[] findAll() 15 | * @method Amnesty[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) 16 | */ 17 | class AmnestyRepository extends ServiceEntityRepository 18 | { 19 | public function __construct(ManagerRegistry $registry) 20 | { 21 | parent::__construct($registry, Amnesty::class); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Repository/DebtRepository.php: -------------------------------------------------------------------------------- 1 | 13 | * 14 | * @method Debt|null find($id, $lockMode = null, $lockVersion = null) 15 | * @method Debt|null findOneBy(array $criteria, array $orderBy = null) 16 | * @method Debt[] findAll() 17 | * @method Debt[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) 18 | */ 19 | class DebtRepository extends ServiceEntityRepository 20 | { 21 | public function __construct(ManagerRegistry $registry) 22 | { 23 | parent::__construct($registry, Debt::class); 24 | } 25 | 26 | public function isDebtExist(Event $event): bool 27 | { 28 | return (bool) $this 29 | ->createQueryBuilder('d') 30 | ->select('COUNT(1)') 31 | ->andWhere('d.author = :author')->setParameter('author', $event->getAuthor()) 32 | ->andWhere('d.createdAt = :day')->setParameter('day', $event->getCreatedAt()->format('Y-m-d')) 33 | ->getQuery() 34 | ->execute(null, Query::HYDRATE_SINGLE_SCALAR) 35 | ; 36 | } 37 | 38 | /** 39 | * @return Debt[] 40 | */ 41 | public function findPendings(): array 42 | { 43 | return $this 44 | ->createQueryBuilder('d') 45 | ->andWhere('d.paid = :paid')->setParameter('paid', false) 46 | ->addOrderBy('d.createdAt', 'ASC') 47 | ->addOrderBy('d.author', 'ASC') 48 | ->getQuery() 49 | ->getResult() 50 | ; 51 | } 52 | 53 | public function ackAllDebts(): void 54 | { 55 | $this 56 | ->createQueryBuilder('d') 57 | ->update() 58 | ->andWhere('d.paid = :paid')->setParameter('paid', false) 59 | ->set('d.paid', ':newPaid')->setParameter('newPaid', true) 60 | ->set('d.paidAt', ':newPaidAt')->setParameter('newPaidAt', new \DateTimeImmutable()) 61 | ->getQuery() 62 | ->execute() 63 | ; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Repository/EventRepository.php: -------------------------------------------------------------------------------- 1 | 11 | * 12 | * @method Event|null find($id, $lockMode = null, $lockVersion = null) 13 | * @method Event|null findOneBy(array $criteria, array $orderBy = null) 14 | * @method Event[] findAll() 15 | * @method Event[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) 16 | */ 17 | class EventRepository extends ServiceEntityRepository 18 | { 19 | public function __construct(ManagerRegistry $registry) 20 | { 21 | parent::__construct($registry, Event::class); 22 | } 23 | 24 | public function getFirstMessageOfDay(Event $event): ?Event 25 | { 26 | return $this 27 | ->createQueryBuilder('e') 28 | ->andWhere('e.createdAt >= :day')->setParameter('day', $event->getCreatedAt()->format('Y-m-d')) 29 | ->andWhere('e.createdAt < :eventDate')->setParameter('eventDate', $event->getCreatedAt()->format('Y-m-d H:i:s.v')) 30 | ->addOrderBy('e.createdAt', 'ASC') 31 | ->setMaxResults(1) 32 | ->getQuery() 33 | ->getOneOrNullResult() 34 | ; 35 | } 36 | 37 | public function findByCreatedAt(\DateTimeImmutable $date): array 38 | { 39 | $start = $date->format('Y-m-d'); 40 | $end = $date->modify('+1 day')->format('Y-m-d'); 41 | 42 | return $this 43 | ->createQueryBuilder('e') 44 | ->andWhere('e.createdAt >= :start')->setParameter('start', $start) 45 | ->andWhere('e.createdAt < :end')->setParameter('end', $end) 46 | ->addOrderBy('e.createdAt', 'ASC') 47 | ->getQuery() 48 | ->getResult() 49 | ; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Slack/DebtAckPoster.php: -------------------------------------------------------------------------------- 1 | 's debt was marked as paid by <@%s> !", $debt->getAuthor(), $user); 17 | 18 | $this->messagePoster->postMessage($message); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Slack/DebtListBlockBuilder.php: -------------------------------------------------------------------------------- 1 | debtRepository->findPendings(); 17 | 18 | if (!$debts) { 19 | return [ 20 | [ 21 | 'type' => 'section', 22 | 'text' => [ 23 | 'type' => 'mrkdwn', 24 | 'text' => '*There are no more debts*', 25 | ], 26 | ], 27 | ]; 28 | } 29 | 30 | $blocks = [ 31 | [ 32 | 'type' => 'section', 33 | 'text' => [ 34 | 'type' => 'mrkdwn', 35 | 'text' => '*Pending debts*', 36 | ], 37 | ], 38 | [ 39 | 'type' => 'divider', 40 | ], 41 | ]; 42 | 43 | foreach ($debts as $debt) { 44 | $event = $debt->getEvent(); 45 | $block = [ 46 | 'type' => 'section', 47 | 'text' => [ 48 | 'type' => 'mrkdwn', 49 | 'text' => \sprintf('<@%s>, %s days ago.', $event->getAuthor(), (new \DateTime())->diff($event->getCreatedAt())->format('%a')), 50 | ], 51 | ]; 52 | 53 | if ($debt->getAuthor() !== $usedId) { 54 | $block['accessory'] = [ 55 | 'type' => 'button', 56 | 'text' => [ 57 | 'type' => 'plain_text', 58 | 'text' => 'Mark as paid', 59 | 'emoji' => true, 60 | ], 61 | 'value' => 'ack-' . $debt->getId(), 62 | ]; 63 | } 64 | 65 | $blocks[] = $block; 66 | } 67 | 68 | if (\count($blocks) >= 50) { 69 | $blocks = \array_slice($blocks, -47); 70 | $blocks[] = [ 71 | 'type' => 'divider', 72 | ]; 73 | $blocks[] = [ 74 | 'type' => 'section', 75 | 'text' => [ 76 | 'type' => 'mrkdwn', 77 | 'text' => 'There is more debts, but slack can display only 50. It\'s time to ask for amnesty?', 78 | ], 79 | ]; 80 | } 81 | 82 | return $blocks; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Slack/DebtListPoster.php: -------------------------------------------------------------------------------- 1 | blockBuilder->buildBlocks($userId); 16 | 17 | $this->messagePoster->postMessage('Pending debts.', $blocks, $responseUrl); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Slack/MessagePoster.php: -------------------------------------------------------------------------------- 1 | [ 26 | 'Authorization' => 'Bearer ' . $this->token, 27 | ], 28 | 'json' => [ 29 | 'channel' => $this->channel, 30 | 'text' => $text, 31 | 'blocks' => $blocks, 32 | ], 33 | ]; 34 | 35 | $url = $responseUrl ?: 'https://slack.com/api/chat.postMessage'; 36 | 37 | $response = $this->httpClient->request('POST', $url, $headers); 38 | 39 | if (200 !== $response->getStatusCode()) { 40 | $this->logger->error('Posting message to slack failed.', [ 41 | 'response' => $response, 42 | 'text' => $text, 43 | ]); 44 | 45 | throw new \RuntimeException('Posting message to slack failed.'); 46 | } 47 | 48 | if (!$response->toArray()['ok']) { 49 | $this->logger->error('Posting message to slack failed.', [ 50 | 'response' => $response, 51 | 'responseDecoded' => $response->toArray(), 52 | 'text' => $text, 53 | ]); 54 | 55 | throw new \RuntimeException('Posting message to slack failed.'); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Slack/PayloadFilter.php: -------------------------------------------------------------------------------- 1 | botIgnoredIds, true)) { 27 | return false; 28 | } 29 | 30 | if ('message' === $e['type']) { 31 | if ($this->channel !== $e['channel']) { 32 | return false; 33 | } 34 | // Only handle special messages /me and file share (ignore others like bot message, channel join/leave, message edits, etc) 35 | if (($e['subtype'] ?? false) && 'me_message' !== $e['subtype'] && 'file_share' !== $e['subtype']) { 36 | return false; 37 | } 38 | // Bot 39 | if ($e['bot_profile'] ?? false) { 40 | return false; 41 | } 42 | // Slack Command 43 | if ('/' === mb_substr((string) $e['text'], 0, 1)) { 44 | return false; 45 | } 46 | } elseif ('reaction_added' === $e['type']) { 47 | if ($this->channel !== $e['item']['channel']) { 48 | return false; 49 | } 50 | } else { 51 | return false; 52 | } 53 | 54 | return true; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Util/Uuid.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Monologue 8 | 9 | 10 | 11 | {% block body '' %} 12 | 13 | 14 | -------------------------------------------------------------------------------- /templates/homepage/homepage.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title %}Monologue{% endblock %} 4 | 5 | {% block body %} 6 | 7 | 8 | 9 | Welcome to Monologue! 10 | 11 | 12 | 13 | 14 | 15 | 16 | Last events 17 | ({{ date|date('Y-m-d') }}) 18 | 19 | 20 | 21 | 22 | Author 23 | Content 24 | Created At 25 | 26 | {% for event in events %} 27 | 28 | {{ event.author }} 29 | {{ event.content }} 30 | {{ event.createdAt|date('H:i:s.v') }} 31 | 32 | {% else %} 33 | 34 | 35 | No events! 36 | 37 | 38 | {% endfor %} 39 | 40 | 41 | ⬅ 42 | ➡ 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | Pending depts 52 | 53 | 54 | 55 | 56 | Author 57 | Content 58 | Cause 59 | Created At 60 | 61 | {% for debt in debts %} 62 | 63 | {{ debt.author }} 64 | {{ debt.event.content }} 65 | {{ debt.cause.content }} 66 | {{ debt.createdAt|date('Y-m-d H:i:s.v') }} 67 | 68 | {% else %} 69 | 70 | 71 | No debts! 72 | 73 | 74 | {% endfor %} 75 | 76 | 77 | 78 | 79 | 80 | 81 | {% endblock %} 82 | -------------------------------------------------------------------------------- /tests/Acceptance/AcceptenceTest.php: -------------------------------------------------------------------------------- 1 | conn = self::getContainer()->get('doctrine.dbal.default_connection'); 17 | $this->conn->executeStatement('DELETE FROM event'); 18 | $this->conn->executeStatement('DELETE FROM debt'); 19 | $this->conn->executeStatement('DELETE FROM amnesty'); 20 | 21 | self::ensureKernelShutdown(); 22 | } 23 | 24 | public function testWithAck() 25 | { 26 | $client = self::createClient(); 27 | // We want to be able to mock some response 28 | $client->disableReboot(); 29 | /** @var MockHttpClient */ 30 | $mockHttpClient = self::getContainer()->get('http_client.transport'); 31 | 32 | // #1 message from user A 33 | 34 | $client->request('POST', '/message', content: $this->getFixtures('001_message_user_A')); 35 | 36 | $this->assertSame(200, $client->getResponse()->getStatusCode()); 37 | $this->assertSame(1, $this->conn->fetchOne('SELECT COUNT(*) FROM event')); 38 | $this->assertSame(0, $this->conn->fetchOne('SELECT COUNT(*) FROM debt')); 39 | 40 | // #2 message from user A 41 | 42 | $client->request('POST', '/message', content: $this->getFixtures('002_message_user_A')); 43 | 44 | $this->assertSame(200, $client->getResponse()->getStatusCode()); 45 | $this->assertSame(2, $this->conn->fetchOne('SELECT COUNT(*) FROM event')); 46 | $this->assertSame(0, $this->conn->fetchOne('SELECT COUNT(*) FROM debt')); 47 | 48 | // #1 message from user B 49 | 50 | $mockHttpClient->setResponseFactory(function (string $method, string $url, array $options = []): MockResponse { 51 | $this->assertSame('POST', $method); 52 | $this->assertSame('https://slack.com/api/chat.postMessage', $url); 53 | $this->assertSame('{"channel":"MY_CHANNEL_ID","text":"Fraud detected.","blocks":[{"type":"context","elements":[{"type":"mrkdwn","text":"Thanks \u003C@UMYK1MQ3E\u003E for the next breakfast! Reason: message posted."}]}]}', $options['body']); 54 | 55 | return new MockResponse('{"ok": true}'); 56 | }); 57 | $client->request('POST', '/message', content: $this->getFixtures('003_message_user_B')); 58 | 59 | $this->assertSame(200, $client->getResponse()->getStatusCode()); 60 | $this->assertSame(3, $this->conn->fetchOne('SELECT COUNT(*) FROM event')); 61 | $this->assertSame(1, $this->conn->fetchOne('SELECT COUNT(*) FROM debt')); 62 | 63 | // /monologue from user A 64 | 65 | $client->request('POST', '/command/list', parameters: [ 66 | 'user_id' => 'U0FLDV6UW', 67 | ]); 68 | 69 | $this->assertSame(200, $client->getResponse()->getStatusCode()); 70 | $responseDecoded = json_decode($client->getResponse()->getContent(), true); 71 | $this->assertCount(3, $responseDecoded['blocks']); 72 | $this->assertSame('*Pending debts*', $responseDecoded['blocks'][0]['text']['text']); 73 | $this->assertArrayHasKey('accessory', $responseDecoded['blocks'][2]); 74 | 75 | // /monologue from user B 76 | 77 | $client->request('POST', '/command/list', parameters: [ 78 | 'user_id' => 'UMYK1MQ3E', 79 | ]); 80 | 81 | $this->assertSame(200, $client->getResponse()->getStatusCode()); 82 | $responseDecoded = json_decode($client->getResponse()->getContent(), true); 83 | $this->assertCount(3, $responseDecoded['blocks']); 84 | $this->assertSame('*Pending debts*', $responseDecoded['blocks'][0]['text']['text']); 85 | $this->assertFalse(\array_key_exists('accessory', $responseDecoded['blocks'][2])); 86 | 87 | // user A marks debt for user B as paid 88 | 89 | $debId = $this->conn->fetchOne('select id from debt'); 90 | $mockHttpClient->setResponseFactory([ 91 | function (string $method, string $url, array $options = []): MockResponse { 92 | $this->assertSame('POST', $method); 93 | $this->assertSame('https://hooks.slack.com/actions/T0FLD8LEM/4375527081910/uouQFvOW3NHFQjJmyAv53ZZF', $url); 94 | $this->assertSame('{"channel":"MY_CHANNEL_ID","text":"Pending debts.","blocks":[{"type":"section","text":{"type":"mrkdwn","text":"*There are no more debts*"}}]}', $options['body']); 95 | 96 | return new MockResponse('{"ok": true}'); 97 | }, 98 | function (string $method, string $url, array $options = []): MockResponse { 99 | $this->assertSame('POST', $method); 100 | $this->assertSame('https://slack.com/api/chat.postMessage', $url); 101 | $this->assertSame('{"channel":"MY_CHANNEL_ID","text":"\u003C@UMYK1MQ3E\u003E\u0027s debt was marked as paid by \u003C@U0FLDV6UW\u003E !","blocks":[]}', $options['body']); 102 | 103 | return new MockResponse('{"ok": true}'); 104 | }, 105 | ]); 106 | 107 | $client->request('POST', '/action', parameters: [ 108 | 'payload' => str_replace('DEBT_ID', $debId, $this->getFixtures('004_mark_as_paid')), 109 | ]); 110 | 111 | $this->assertSame(200, $client->getResponse()->getStatusCode()); 112 | 113 | // /monologue from user B 114 | 115 | $client->request('POST', '/command/list', parameters: [ 116 | 'user_id' => 'UMYK1MQ3E', 117 | ]); 118 | 119 | $this->assertSame(200, $client->getResponse()->getStatusCode()); 120 | $responseDecoded = json_decode($client->getResponse()->getContent(), true); 121 | $this->assertCount(1, $responseDecoded['blocks']); 122 | $this->assertSame('*There are no more debts*', $responseDecoded['blocks'][0]['text']['text']); 123 | } 124 | 125 | public function testWithAmnesty() 126 | { 127 | $client = self::createClient(); 128 | // We want to be able to mock some response 129 | $client->disableReboot(); 130 | /** @var MockHttpClient */ 131 | $mockHttpClient = self::getContainer()->get('http_client.transport'); 132 | 133 | // #1 message from user A 134 | 135 | $client->request('POST', '/message', content: $this->getFixtures('001_message_user_A')); 136 | 137 | $this->assertSame(200, $client->getResponse()->getStatusCode()); 138 | 139 | // #1 message from user B 140 | 141 | $mockHttpClient->setResponseFactory(new MockResponse('{"ok": true}')); 142 | $client->request('POST', '/message', content: $this->getFixtures('003_message_user_B')); 143 | 144 | $this->assertSame(200, $client->getResponse()->getStatusCode()); 145 | 146 | // /amnesty from user A 147 | 148 | $client->request('POST', '/command/amnesty', parameters: [ 149 | 'user_id' => 'U0FLDV6UW', 150 | ]); 151 | 152 | $this->assertSame(200, $client->getResponse()->getStatusCode()); 153 | $this->assertSame('{"text":"More people need to ask for amnesty to complete it! (1\/2)"}', $client->getResponse()->getContent()); 154 | 155 | // /amnesty from user B 156 | 157 | $mockHttpClient->setResponseFactory(function (string $method, string $url, array $options = []): MockResponse { 158 | $this->assertSame('POST', $method); 159 | $this->assertSame('https://slack.com/api/chat.postMessage', $url); 160 | $this->assertSame('{"channel":"MY_CHANNEL_ID","text":"The amnesty has been redeemed. All debts have been wiped. \ud83c\udf86","blocks":[]}', $options['body']); 161 | 162 | return new MockResponse('{"ok": true}'); 163 | }); 164 | 165 | $client->request('POST', '/command/amnesty', parameters: [ 166 | 'user_id' => 'UMYK1MQ3E', 167 | ]); 168 | 169 | $this->assertSame(200, $client->getResponse()->getStatusCode()); 170 | $this->assertSame('', $client->getResponse()->getContent()); 171 | 172 | // /monologue from user B 173 | 174 | $client->request('POST', '/command/list', parameters: [ 175 | 'user_id' => 'UMYK1MQ3E', 176 | ]); 177 | 178 | $this->assertSame(200, $client->getResponse()->getStatusCode()); 179 | $responseDecoded = json_decode($client->getResponse()->getContent(), true); 180 | $this->assertCount(1, $responseDecoded['blocks']); 181 | $this->assertSame('*There are no more debts*', $responseDecoded['blocks'][0]['text']['text']); 182 | } 183 | 184 | private function getFixtures(string $name): string 185 | { 186 | return file_get_contents(__DIR__ . '/fixtures/' . $name . '.json'); 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /tests/Acceptance/fixtures/001_message_user_A.json: -------------------------------------------------------------------------------- 1 | { 2 | "token": "C2DjVO0acT24qkGx8mRhxYR0", 3 | "team_id": "T0FLD8LEM", 4 | "api_app_id": "A04BWSZM1A4", 5 | "event": { 6 | "client_msg_id": "6fc959c0-cb4b-43a0-a548-54a2a4b86c82", 7 | "type": "message", 8 | "text": "a", 9 | "user": "U0FLDV6UW", 10 | "ts": "1668614312.433749", 11 | "blocks": [ 12 | { 13 | "type": "rich_text", 14 | "block_id": "i6Q", 15 | "elements": [ 16 | { 17 | "type": "rich_text_section", 18 | "elements": [ 19 | { 20 | "type": "text", 21 | "text": "a" 22 | } 23 | ] 24 | } 25 | ] 26 | } 27 | ], 28 | "team": "T0FLD8LEM", 29 | "channel": "MY_CHANNEL_ID", 30 | "event_ts": "1668614312.433749", 31 | "channel_type": "channel" 32 | }, 33 | "type": "event_callback", 34 | "event_id": "Ev04B1306Q0N", 35 | "event_time": 1668614312, 36 | "authorizations": [ 37 | { 38 | "enterprise_id": null, 39 | "team_id": "T0FLD8LEM", 40 | "user_id": "U04B73R05B5", 41 | "is_bot": true, 42 | "is_enterprise_install": false 43 | } 44 | ], 45 | "is_ext_shared_channel": false, 46 | "event_context": "4-eyJldCI6Im1lc3NhZ2UiLCJ0aWQiOiJUMEZMRDhMRU0iLCJhaWQiOiJBMDRCV1NaTTFBNCIsImNpZCI6IkNNRk1aREJSUiJ9" 47 | } 48 | -------------------------------------------------------------------------------- /tests/Acceptance/fixtures/002_message_user_A.json: -------------------------------------------------------------------------------- 1 | { 2 | "token": "C2DjVO0acT24qkGx8mRhxYR0", 3 | "team_id": "T0FLD8LEM", 4 | "api_app_id": "A04BWSZM1A4", 5 | "event": { 6 | "client_msg_id": "53fc8b27-bb7d-42f0-a0b1-932fb67efeac", 7 | "type": "message", 8 | "text": "b", 9 | "user": "U0FLDV6UW", 10 | "ts": "1668615818.721679", 11 | "blocks": [ 12 | { 13 | "type": "rich_text", 14 | "block_id": "A=Un", 15 | "elements": [ 16 | { 17 | "type": "rich_text_section", 18 | "elements": [ 19 | { 20 | "type": "text", 21 | "text": "b" 22 | } 23 | ] 24 | } 25 | ] 26 | } 27 | ], 28 | "team": "T0FLD8LEM", 29 | "channel": "MY_CHANNEL_ID", 30 | "event_ts": "1668615818.721679", 31 | "channel_type": "channel" 32 | }, 33 | "type": "event_callback", 34 | "event_id": "Ev04ATA9FPEK", 35 | "event_time": 1668615818, 36 | "authorizations": [ 37 | { 38 | "enterprise_id": null, 39 | "team_id": "T0FLD8LEM", 40 | "user_id": "U04B73R05B5", 41 | "is_bot": true, 42 | "is_enterprise_install": false 43 | } 44 | ], 45 | "is_ext_shared_channel": false, 46 | "event_context": "4-eyJldCI6Im1lc3NhZ2UiLCJ0aWQiOiJUMEZMRDhMRU0iLCJhaWQiOiJBMDRCV1NaTTFBNCIsImNpZCI6IkNNRk1aREJSUiJ9" 47 | } 48 | -------------------------------------------------------------------------------- /tests/Acceptance/fixtures/003_message_user_B.json: -------------------------------------------------------------------------------- 1 | { 2 | "token": "C2DjVO0acT24qkGx8mRhxYR0", 3 | "team_id": "T0FLD8LEM", 4 | "api_app_id": "A04BWSZM1A4", 5 | "event": { 6 | "client_msg_id": "40081bcf-9db1-4394-9d3b-7b3c8f1dc543", 7 | "type": "message", 8 | "text": "c", 9 | "user": "UMYK1MQ3E", 10 | "ts": "1668615833.257779", 11 | "blocks": [ 12 | { 13 | "type": "rich_text", 14 | "block_id": "Jd1", 15 | "elements": [ 16 | { 17 | "type": "rich_text_section", 18 | "elements": [ 19 | { 20 | "type": "text", 21 | "text": "c" 22 | } 23 | ] 24 | } 25 | ] 26 | } 27 | ], 28 | "team": "T0FLD8LEM", 29 | "channel": "MY_CHANNEL_ID", 30 | "event_ts": "1668615833.257779", 31 | "channel_type": "channel" 32 | }, 33 | "type": "event_callback", 34 | "event_id": "Ev04B7P7HA8K", 35 | "event_time": 1668615833, 36 | "authorizations": [ 37 | { 38 | "enterprise_id": null, 39 | "team_id": "T0FLD8LEM", 40 | "user_id": "U04B73R05B5", 41 | "is_bot": true, 42 | "is_enterprise_install": false 43 | } 44 | ], 45 | "is_ext_shared_channel": false, 46 | "event_context": "4-eyJldCI6Im1lc3NhZ2UiLCJ0aWQiOiJUMEZMRDhMRU0iLCJhaWQiOiJBMDRCV1NaTTFBNCIsImNpZCI6IkNNRk1aREJSUiJ9" 47 | } 48 | -------------------------------------------------------------------------------- /tests/Acceptance/fixtures/004_mark_as_paid.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "block_actions", 3 | "user": { 4 | "id": "U0FLDV6UW", 5 | "username": "lyrixx", 6 | "name": "lyrixx", 7 | "team_id": "T0FLD8LEM" 8 | }, 9 | "api_app_id": "A04BWSZM1A4", 10 | "token": "C2DjVO0acT24qkGx8mRhxYR0", 11 | "container": { 12 | "type": "message", 13 | "message_ts": "1668617237.003200", 14 | "channel_id": "MY_CHANNEL_ID", 15 | "is_ephemeral": true 16 | }, 17 | "trigger_id": "4384586712068.15693292497.41642ac54062a9a546eb281366dec37c", 18 | "team": { 19 | "id": "T0FLD8LEM", 20 | "domain": "lyrixx" 21 | }, 22 | "enterprise": null, 23 | "is_enterprise_install": false, 24 | "channel": { 25 | "id": "MY_CHANNEL_ID", 26 | "name": "monolog" 27 | }, 28 | "state": { 29 | "values": {} 30 | }, 31 | "response_url": "https:\/\/hooks.slack.com\/actions\/T0FLD8LEM\/4375527081910\/uouQFvOW3NHFQjJmyAv53ZZF", 32 | "actions": [ 33 | { 34 | "action_id": "xFDNr", 35 | "block_id": "iyd", 36 | "text": { 37 | "type": "plain_text", 38 | "text": "Mark as paid", 39 | "emoji": true 40 | }, 41 | "value": "ack-DEBT_ID", 42 | "type": "button", 43 | "action_ts": "1668617768.535380" 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /tests/Integration/ControlTower/DebtCreatorTest.php: -------------------------------------------------------------------------------- 1 | bigBrowser = self::getContainer()->get(DebtCreator::class); 16 | 17 | self::getContainer() 18 | ->get('doctrine.dbal.default_connection') 19 | ->executeStatement('DELETE FROM event') 20 | ; 21 | } 22 | 23 | public function testControlFirstMessage() 24 | { 25 | // Author: foobar 26 | 27 | $payload = [ 28 | 'token' => '6X6M8tbvmeO3aOVAbjUopW2Y', 29 | 'team_id' => 'T0FLD8LEM', 30 | 'api_app_id' => 'AMV1PTJBH', 31 | 'event' => [ 32 | 'client_msg_id' => '86fe7235-8c10-4401-8f44-688d46039db2', 33 | 'type' => 'message', 34 | 'text' => 'FINAL', 35 | 'user' => 'foobar', 36 | 'ts' => '1567006789.003100', 37 | 'team' => 'T0FLD8LEM', 38 | 'channel' => 'MY_CHANNEL_ID', 39 | 'event_ts' => '1567006789.003100', 40 | 'channel_type' => 'channel', 41 | ], 42 | 'type' => 'event_callback', 43 | 'event_id' => 'EvMVBZR18E', 44 | 'event_time' => '1567006784', 45 | 'authed_users' => [ 46 | 'foobar', 47 | ], 48 | ]; 49 | 50 | $this->assertNull($this->bigBrowser->createDebtIfNeeded($payload)); 51 | 52 | // Author: foobar, a bit later 53 | 54 | $payload = [ 55 | 'token' => '6X6M8tbvmeO3aOVAbjUopW2Y', 56 | 'team_id' => 'T0FLD8LEM', 57 | 'api_app_id' => 'AMV1PTJBH', 58 | 'event' => [ 59 | 'client_msg_id' => '86fe7235-8c10-4401-8f44-688d46039db2', 60 | 'type' => 'message', 61 | 'text' => 'FINAL', 62 | 'user' => 'foobar', 63 | 'ts' => '1567006790.003100', 64 | 'team' => 'T0FLD8LEM', 65 | 'channel' => 'MY_CHANNEL_ID', 66 | 'event_ts' => '1567006790.003100', 67 | 'channel_type' => 'channel', 68 | ], 69 | 'type' => 'event_callback', 70 | 'event_id' => 'EvMVBZR18E', 71 | 'event_time' => '1567006784', 72 | 'authed_users' => [ 73 | 'foobar', 74 | ], 75 | ]; 76 | 77 | $this->assertNull($this->bigBrowser->createDebtIfNeeded($payload)); 78 | 79 | // Author: baz 80 | 81 | $payload = [ 82 | 'token' => '6X6M8tbvmeO3aOVAbjUopW2Y', 83 | 'team_id' => 'T0FLD8LEM', 84 | 'api_app_id' => 'AMV1PTJBH', 85 | 'event' => [ 86 | 'client_msg_id' => '86fe7235-8c10-4401-8f44-688d46039db2', 87 | 'type' => 'message', 88 | 'text' => 'FINAL', 89 | 'user' => 'baz', 90 | 'ts' => '1567006790.003100', 91 | 'team' => 'T0FLD8LEM', 92 | 'channel' => 'MY_CHANNEL_ID', 93 | 'event_ts' => '1567006790.003100', 94 | 'channel_type' => 'channel', 95 | ], 96 | 'type' => 'event_callback', 97 | 'event_id' => 'EvMVBZR18E', 98 | 'event_time' => '1567006784', 99 | 'authed_users' => [ 100 | 'baz', 101 | ], 102 | ]; 103 | 104 | $this->assertInstanceOf(Debt::class, $this->bigBrowser->createDebtIfNeeded($payload)); 105 | 106 | // Author: baz, a bit later 107 | 108 | $payload = [ 109 | 'token' => '6X6M8tbvmeO3aOVAbjUopW2Y', 110 | 'team_id' => 'T0FLD8LEM', 111 | 'api_app_id' => 'AMV1PTJBH', 112 | 'event' => [ 113 | 'client_msg_id' => '86fe7235-8c10-4401-8f44-688d46039db2', 114 | 'type' => 'message', 115 | 'text' => 'FINAL', 116 | 'user' => 'baz', 117 | 'ts' => '1567006791.003100', 118 | 'team' => 'T0FLD8LEM', 119 | 'channel' => 'MY_CHANNEL_ID', 120 | 'event_ts' => '1567006791.003100', 121 | 'channel_type' => 'channel', 122 | ], 123 | 'type' => 'event_callback', 124 | 'event_id' => 'EvMVBZR18E', 125 | 'event_time' => '1567006784', 126 | 'authed_users' => [ 127 | 'baz', 128 | ], 129 | ]; 130 | 131 | $this->assertNull($this->bigBrowser->createDebtIfNeeded($payload)); 132 | 133 | // Author: foobar, a bit later, again 134 | 135 | $payload = [ 136 | 'token' => '6X6M8tbvmeO3aOVAbjUopW2Y', 137 | 'team_id' => 'T0FLD8LEM', 138 | 'api_app_id' => 'AMV1PTJBH', 139 | 'event' => [ 140 | 'client_msg_id' => '86fe7235-8c10-4401-8f44-688d46039db2', 141 | 'type' => 'message', 142 | 'text' => 'FINAL', 143 | 'user' => 'foobar', 144 | 'ts' => '1567006791.603100', 145 | 'team' => 'T0FLD8LEM', 146 | 'channel' => 'MY_CHANNEL_ID', 147 | 'event_ts' => '1567006791.603100', 148 | 'channel_type' => 'channel', 149 | ], 150 | 'type' => 'event_callback', 151 | 'event_id' => 'EvMVBZR18E', 152 | 'event_time' => '1567006784', 153 | 'authed_users' => [ 154 | 'foobar', 155 | ], 156 | ]; 157 | 158 | $this->assertNull($this->bigBrowser->createDebtIfNeeded($payload)); 159 | 160 | // Author: foo, a bit later 161 | 162 | $payload = [ 163 | 'token' => '6X6M8tbvmeO3aOVAbjUopW2Y', 164 | 'team_id' => 'T0FLD8LEM', 165 | 'api_app_id' => 'AMV1PTJBH', 166 | 'event' => [ 167 | 'client_msg_id' => '86fe7235-8c10-4401-8f44-688d46039db2', 168 | 'type' => 'message', 169 | 'text' => 'FINAL', 170 | 'user' => 'foo', 171 | 'ts' => '1567006792.003100', 172 | 'team' => 'T0FLD8LEM', 173 | 'channel' => 'MY_CHANNEL_ID', 174 | 'event_ts' => '1567006792.003100', 175 | 'channel_type' => 'channel', 176 | ], 177 | 'type' => 'event_callback', 178 | 'event_id' => 'EvMVBZR18E', 179 | 'event_time' => '1567006784', 180 | 'authed_users' => [ 181 | 'foo', 182 | ], 183 | ]; 184 | 185 | $this->assertInstanceOf(Debt::class, $this->bigBrowser->createDebtIfNeeded($payload)); 186 | 187 | // Reaction, author Jean, later 188 | 189 | $payload = [ 190 | 'token' => '6X6M8tbvmeO3aOVAbjUopW2Y', 191 | 'team_id' => 'T0FLD8LEM', 192 | 'api_app_id' => 'AMV1PTJBH', 193 | 'event' => [ 194 | 'type' => 'reaction_added', 195 | 'user' => 'Jean', 196 | 'item' => [ 197 | 'type' => 'message', 198 | 'channel' => 'MY_CHANNEL_ID', 199 | 'ts' => '1567006791.003100', 200 | ], 201 | 'reaction' => 'slightly_smiling_face', 202 | 'event_ts' => '1567006791.003100', 203 | ], 204 | 'type' => 'event_callback', 205 | 'event_id' => 'EvMZ5HFMNC', 206 | 'event_time' => 1567525929, 207 | 'authed_users' => [ 208 | 0 => 'U0FLDV6UW', 209 | ], 210 | ]; 211 | 212 | $this->assertInstanceOf(Debt::class, $this->bigBrowser->createDebtIfNeeded($payload)); 213 | 214 | // Reaction, author Jean, later 215 | 216 | $payload = [ 217 | 'token' => '6X6M8tbvmeO3aOVAbjUopW2Y', 218 | 'team_id' => 'T0FLD8LEM', 219 | 'api_app_id' => 'AMV1PTJBH', 220 | 'event' => [ 221 | 'type' => 'reaction_added', 222 | 'user' => 'Jean', 223 | 'item' => [ 224 | 'type' => 'message', 225 | 'channel' => 'MY_CHANNEL_ID', 226 | 'ts' => '1567006792.003100', 227 | ], 228 | 'reaction' => 'slightly_smiling_face', 229 | 'event_ts' => '1567006792.003100', 230 | ], 231 | 'type' => 'event_callback', 232 | 'event_id' => 'EvMZ5HFMNC', 233 | 'event_time' => 1567525929, 234 | 'authed_users' => [ 235 | 0 => 'U0FLDV6UW', 236 | ], 237 | ]; 238 | 239 | $this->assertNull($this->bigBrowser->createDebtIfNeeded($payload)); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /tests/allowed.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "location": "App\\Tests\\Acceptance\\AcceptenceTest::testWithAck", 4 | "message": "The \"Monolog\\Logger\" class is considered final. It may change without further notice as of its next major version. You should not extend it from \"Symfony\\Bridge\\Monolog\\Logger\".", 5 | "count": 1 6 | } 7 | ] -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | bootEnv(dirname(__DIR__) . '/.env'); 11 | } 12 | -------------------------------------------------------------------------------- /tools/bin/php-cs-fixer: -------------------------------------------------------------------------------- 1 | ../php-cs-fixer/vendor/bin/php-cs-fixer -------------------------------------------------------------------------------- /tools/bin/phpstan: -------------------------------------------------------------------------------- 1 | ../phpstan/vendor/bin/phpstan -------------------------------------------------------------------------------- /tools/php-cs-fixer/.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | -------------------------------------------------------------------------------- /tools/php-cs-fixer/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "project", 3 | "require": { 4 | "friendsofphp/php-cs-fixer": "^3.64.0" 5 | }, 6 | "config": { 7 | "platform": { 8 | "php": "8.1" 9 | }, 10 | "bump-after-update": true, 11 | "sort-packages": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tools/phpstan/.gitignore: -------------------------------------------------------------------------------- 1 | /var/ 2 | /vendor/ 3 | -------------------------------------------------------------------------------- /tools/phpstan/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "project", 3 | "require": { 4 | "phpstan/extension-installer": "^1.4.3", 5 | "phpstan/phpstan": "^1.12.11", 6 | "phpstan/phpstan-symfony": "^1.4.12" 7 | }, 8 | "config": { 9 | "allow-plugins": { 10 | "phpstan/extension-installer": true 11 | }, 12 | "bump-after-update": true, 13 | "platform": { 14 | "php": "8.1" 15 | }, 16 | "sort-packages": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tools/phpstan/composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", 5 | "This file is @generated automatically" 6 | ], 7 | "content-hash": "b4c9d53f182a2429b4b8644af398a8fd", 8 | "packages": [ 9 | { 10 | "name": "phpstan/extension-installer", 11 | "version": "1.4.3", 12 | "source": { 13 | "type": "git", 14 | "url": "https://github.com/phpstan/extension-installer.git", 15 | "reference": "85e90b3942d06b2326fba0403ec24fe912372936" 16 | }, 17 | "dist": { 18 | "type": "zip", 19 | "url": "https://api.github.com/repos/phpstan/extension-installer/zipball/85e90b3942d06b2326fba0403ec24fe912372936", 20 | "reference": "85e90b3942d06b2326fba0403ec24fe912372936", 21 | "shasum": "" 22 | }, 23 | "require": { 24 | "composer-plugin-api": "^2.0", 25 | "php": "^7.2 || ^8.0", 26 | "phpstan/phpstan": "^1.9.0 || ^2.0" 27 | }, 28 | "require-dev": { 29 | "composer/composer": "^2.0", 30 | "php-parallel-lint/php-parallel-lint": "^1.2.0", 31 | "phpstan/phpstan-strict-rules": "^0.11 || ^0.12 || ^1.0" 32 | }, 33 | "type": "composer-plugin", 34 | "extra": { 35 | "class": "PHPStan\\ExtensionInstaller\\Plugin" 36 | }, 37 | "autoload": { 38 | "psr-4": { 39 | "PHPStan\\ExtensionInstaller\\": "src/" 40 | } 41 | }, 42 | "notification-url": "https://packagist.org/downloads/", 43 | "license": [ 44 | "MIT" 45 | ], 46 | "description": "Composer plugin for automatic installation of PHPStan extensions", 47 | "keywords": [ 48 | "dev", 49 | "static analysis" 50 | ], 51 | "support": { 52 | "issues": "https://github.com/phpstan/extension-installer/issues", 53 | "source": "https://github.com/phpstan/extension-installer/tree/1.4.3" 54 | }, 55 | "time": "2024-09-04T20:21:43+00:00" 56 | }, 57 | { 58 | "name": "phpstan/phpstan", 59 | "version": "1.12.11", 60 | "source": { 61 | "type": "git", 62 | "url": "https://github.com/phpstan/phpstan.git", 63 | "reference": "0d1fc20a962a91be578bcfe7cf939e6e1a2ff733" 64 | }, 65 | "dist": { 66 | "type": "zip", 67 | "url": "https://api.github.com/repos/phpstan/phpstan/zipball/0d1fc20a962a91be578bcfe7cf939e6e1a2ff733", 68 | "reference": "0d1fc20a962a91be578bcfe7cf939e6e1a2ff733", 69 | "shasum": "" 70 | }, 71 | "require": { 72 | "php": "^7.2|^8.0" 73 | }, 74 | "conflict": { 75 | "phpstan/phpstan-shim": "*" 76 | }, 77 | "bin": [ 78 | "phpstan", 79 | "phpstan.phar" 80 | ], 81 | "type": "library", 82 | "autoload": { 83 | "files": [ 84 | "bootstrap.php" 85 | ] 86 | }, 87 | "notification-url": "https://packagist.org/downloads/", 88 | "license": [ 89 | "MIT" 90 | ], 91 | "description": "PHPStan - PHP Static Analysis Tool", 92 | "keywords": [ 93 | "dev", 94 | "static analysis" 95 | ], 96 | "support": { 97 | "docs": "https://phpstan.org/user-guide/getting-started", 98 | "forum": "https://github.com/phpstan/phpstan/discussions", 99 | "issues": "https://github.com/phpstan/phpstan/issues", 100 | "security": "https://github.com/phpstan/phpstan/security/policy", 101 | "source": "https://github.com/phpstan/phpstan-src" 102 | }, 103 | "funding": [ 104 | { 105 | "url": "https://github.com/ondrejmirtes", 106 | "type": "github" 107 | }, 108 | { 109 | "url": "https://github.com/phpstan", 110 | "type": "github" 111 | } 112 | ], 113 | "time": "2024-11-17T14:08:01+00:00" 114 | }, 115 | { 116 | "name": "phpstan/phpstan-symfony", 117 | "version": "1.4.12", 118 | "source": { 119 | "type": "git", 120 | "url": "https://github.com/phpstan/phpstan-symfony.git", 121 | "reference": "c7b7e7f520893621558bfbfdb2694d4364565c1d" 122 | }, 123 | "dist": { 124 | "type": "zip", 125 | "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/c7b7e7f520893621558bfbfdb2694d4364565c1d", 126 | "reference": "c7b7e7f520893621558bfbfdb2694d4364565c1d", 127 | "shasum": "" 128 | }, 129 | "require": { 130 | "ext-simplexml": "*", 131 | "php": "^7.2 || ^8.0", 132 | "phpstan/phpstan": "^1.12" 133 | }, 134 | "conflict": { 135 | "symfony/framework-bundle": "<3.0" 136 | }, 137 | "require-dev": { 138 | "nikic/php-parser": "^4.13.0", 139 | "php-parallel-lint/php-parallel-lint": "^1.2", 140 | "phpstan/phpstan-phpunit": "^1.3.11", 141 | "phpstan/phpstan-strict-rules": "^1.5.1", 142 | "phpunit/phpunit": "^8.5.29 || ^9.5", 143 | "psr/container": "1.0 || 1.1.1", 144 | "symfony/config": "^5.4 || ^6.1", 145 | "symfony/console": "^5.4 || ^6.1", 146 | "symfony/dependency-injection": "^5.4 || ^6.1", 147 | "symfony/form": "^5.4 || ^6.1", 148 | "symfony/framework-bundle": "^5.4 || ^6.1", 149 | "symfony/http-foundation": "^5.4 || ^6.1", 150 | "symfony/messenger": "^5.4", 151 | "symfony/polyfill-php80": "^1.24", 152 | "symfony/serializer": "^5.4", 153 | "symfony/service-contracts": "^2.2.0" 154 | }, 155 | "type": "phpstan-extension", 156 | "extra": { 157 | "phpstan": { 158 | "includes": [ 159 | "extension.neon", 160 | "rules.neon" 161 | ] 162 | } 163 | }, 164 | "autoload": { 165 | "psr-4": { 166 | "PHPStan\\": "src/" 167 | } 168 | }, 169 | "notification-url": "https://packagist.org/downloads/", 170 | "license": [ 171 | "MIT" 172 | ], 173 | "authors": [ 174 | { 175 | "name": "Lukáš Unger", 176 | "email": "looky.msc@gmail.com", 177 | "homepage": "https://lookyman.net" 178 | } 179 | ], 180 | "description": "Symfony Framework extensions and rules for PHPStan", 181 | "support": { 182 | "issues": "https://github.com/phpstan/phpstan-symfony/issues", 183 | "source": "https://github.com/phpstan/phpstan-symfony/tree/1.4.12" 184 | }, 185 | "time": "2024-11-06T10:13:18+00:00" 186 | } 187 | ], 188 | "packages-dev": [], 189 | "aliases": [], 190 | "minimum-stability": "stable", 191 | "stability-flags": {}, 192 | "prefer-stable": false, 193 | "prefer-lowest": false, 194 | "platform": {}, 195 | "platform-dev": {}, 196 | "platform-overrides": { 197 | "php": "8.1" 198 | }, 199 | "plugin-api-version": "2.6.0" 200 | } 201 | --------------------------------------------------------------------------------