From 67cddbecae7255e3414efc0aa8d6e6d7dfbbd785 Mon Sep 17 00:00:00 2001 From: Bogdan Kharchenko Date: Thu, 22 May 2025 20:28:16 -0400 Subject: [PATCH 01/19] Port Slack Events Bot --- app-modules/slack-events-bot/.env.example | 5 + app-modules/slack-events-bot/README.md | 110 +++++++++ app-modules/slack-events-bot/composer.json | 24 ++ .../config/slack-events-bot.php | 60 +++++ ..._01_000001_create_slack_channels_table.php | 24 ++ ..._01_000002_create_slack_messages_table.php | 29 +++ ...01_000003_create_slack_cooldowns_table.php | 27 +++ app-modules/slack-events-bot/routes/web.php | 13 + .../src/Console/Commands/CheckApiCommand.php | 31 +++ .../Commands/DeleteOldMessagesCommand.php | 32 +++ .../UnsafeMessageSpilloverException.php | 10 + .../src/Http/Controllers/SlackController.php | 163 +++++++++++++ .../Http/Middleware/ValidateSlackRequest.php | 23 ++ .../src/Models/SlackChannel.php | 18 ++ .../src/Models/SlackCooldown.php | 18 ++ .../src/Models/SlackMessage.php | 26 ++ .../SlackEventsBotServiceProvider.php | 62 +++++ .../src/Services/AuthService.php | 48 ++++ .../src/Services/BotService.php | 223 ++++++++++++++++++ .../src/Services/DatabaseService.php | 133 +++++++++++ .../src/Services/EventService.php | 131 ++++++++++ .../src/Services/MessageBuilderService.php | 120 ++++++++++ .../tests/SlackEventsBotTest.php | 160 +++++++++++++ composer.json | 1 + composer.lock | 37 ++- 25 files changed, 1527 insertions(+), 1 deletion(-) create mode 100644 app-modules/slack-events-bot/.env.example create mode 100644 app-modules/slack-events-bot/README.md create mode 100644 app-modules/slack-events-bot/composer.json create mode 100644 app-modules/slack-events-bot/config/slack-events-bot.php create mode 100644 app-modules/slack-events-bot/database/migrations/2024_01_01_000001_create_slack_channels_table.php create mode 100644 app-modules/slack-events-bot/database/migrations/2024_01_01_000002_create_slack_messages_table.php create mode 100644 app-modules/slack-events-bot/database/migrations/2024_01_01_000003_create_slack_cooldowns_table.php create mode 100644 app-modules/slack-events-bot/routes/web.php create mode 100644 app-modules/slack-events-bot/src/Console/Commands/CheckApiCommand.php create mode 100644 app-modules/slack-events-bot/src/Console/Commands/DeleteOldMessagesCommand.php create mode 100644 app-modules/slack-events-bot/src/Exceptions/UnsafeMessageSpilloverException.php create mode 100644 app-modules/slack-events-bot/src/Http/Controllers/SlackController.php create mode 100644 app-modules/slack-events-bot/src/Http/Middleware/ValidateSlackRequest.php create mode 100644 app-modules/slack-events-bot/src/Models/SlackChannel.php create mode 100644 app-modules/slack-events-bot/src/Models/SlackCooldown.php create mode 100644 app-modules/slack-events-bot/src/Models/SlackMessage.php create mode 100644 app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php create mode 100644 app-modules/slack-events-bot/src/Services/AuthService.php create mode 100644 app-modules/slack-events-bot/src/Services/BotService.php create mode 100644 app-modules/slack-events-bot/src/Services/DatabaseService.php create mode 100644 app-modules/slack-events-bot/src/Services/EventService.php create mode 100644 app-modules/slack-events-bot/src/Services/MessageBuilderService.php create mode 100644 app-modules/slack-events-bot/tests/SlackEventsBotTest.php diff --git a/app-modules/slack-events-bot/.env.example b/app-modules/slack-events-bot/.env.example new file mode 100644 index 00000000..b19f50b3 --- /dev/null +++ b/app-modules/slack-events-bot/.env.example @@ -0,0 +1,5 @@ +# Slack Events Bot Configuration +SLACK_BOT_TOKEN=xoxb-your-bot-user-oauth-token +SLACK_SIGNING_SECRET=your-signing-secret-from-slack +SLACK_CLIENT_ID=your-slack-app-client-id +SLACK_CLIENT_SECRET=your-slack-app-client-secret diff --git a/app-modules/slack-events-bot/README.md b/app-modules/slack-events-bot/README.md new file mode 100644 index 00000000..3823d3cb --- /dev/null +++ b/app-modules/slack-events-bot/README.md @@ -0,0 +1,110 @@ +# Slack Events Bot Module + +A Laravel module that posts HackGreenville events from the database to configured Slack channels. + +## Installation + +This module is automatically loaded as it's in the `app-modules` directory. + +## Configuration + +Add the following environment variables to your `.env` file: + +```env +SLACK_BOT_TOKEN=xoxb-your-bot-token +SLACK_SIGNING_SECRET=your-signing-secret +SLACK_CLIENT_ID=your-client-id +SLACK_CLIENT_SECRET=your-client-secret +``` + +## Publishing Configuration + +To publish the configuration file: + +```bash +php artisan vendor:publish --tag=slack-events-bot-config +``` + +## Running Migrations + +The migrations will run automatically with: + +```bash +php artisan migrate +``` + +## Available Commands + +```bash +# Manually check for events and update Slack messages +php artisan slack:check-events + +# Delete old messages (default: 90 days) +php artisan slack:delete-old-messages +php artisan slack:delete-old-messages --days=60 +``` + +## Scheduled Tasks + +The module automatically schedules: +- Event check: Every hour +- Old message cleanup: Daily + +Make sure your Laravel scheduler is running: + +```bash +php artisan schedule:work +``` + +## Slack Commands + +The bot supports the following slash commands: + +- `/add_channel` - Add the current channel to receive event updates (admin only) +- `/remove_channel` - Remove the current channel from receiving updates (admin only) +- `/check_api` - Manually trigger an event check (rate limited to once per 15 minutes per workspace) + +## Routes + +- `GET /slack/install` - Display Slack installation button +- `GET /slack/auth` - OAuth callback for Slack +- `POST /slack/events` - Webhook endpoint for Slack events and commands + +## Features + +- Posts weekly event summaries to configured Slack channels +- Automatically updates messages when events change +- Handles message chunking for large event lists +- Rate limiting for manual checks +- Admin-only channel management +- OAuth installation flow +- Automatic cleanup of old messages +- Direct database integration (no API calls needed) + +## How It Works + +1. The bot queries the Event model directly every hour for new/updated events +2. Events are filtered to show published events from 1 day ago to 14 days ahead +3. Events are grouped by week (Sunday to Saturday) +4. Messages are posted/updated in configured Slack channels +5. If a week has many events, they're split across multiple messages +6. Messages for the current week and next week (5 days early) are maintained + +## Configuration Options + +The module can be configured in `config/slack-events-bot.php`: + +- `days_to_look_back` - How many days in the past to include events (default: 1) +- `days_to_look_ahead` - How many days in the future to include events (default: 14) +- `max_message_character_length` - Maximum characters per Slack message (default: 3000) +- `check_api_cooldown_minutes` - Cooldown period for manual checks (default: 15) +- `old_messages_retention_days` - Days to keep old messages (default: 90) + +## Migration from Python + +This module is a Laravel port of the original Python slack-events-bot, now refactored to use the Event model directly instead of making API calls. This provides: + +- Better performance (no HTTP overhead) +- Real-time data (no API caching delays) +- Tighter integration with the application +- Easier maintenance and debugging diff --git a/app-modules/slack-events-bot/composer.json b/app-modules/slack-events-bot/composer.json new file mode 100644 index 00000000..8dc2fbda --- /dev/null +++ b/app-modules/slack-events-bot/composer.json @@ -0,0 +1,24 @@ +{ + "name": "hack-greenville/slack-events-bot", + "description": "Slack bot that relays information from HackGreenville Labs' Events API to Slack channels", + "type": "library", + "license": "MIT", + "version": "1.0", + "autoload": { + "psr-4": { + "HackGreenville\\SlackEventsBot\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "HackGreenville\\SlackEventsBot\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "HackGreenville\\SlackEventsBot\\Providers\\SlackEventsBotServiceProvider" + ] + } + } +} diff --git a/app-modules/slack-events-bot/config/slack-events-bot.php b/app-modules/slack-events-bot/config/slack-events-bot.php new file mode 100644 index 00000000..f0520bcc --- /dev/null +++ b/app-modules/slack-events-bot/config/slack-events-bot.php @@ -0,0 +1,60 @@ + env('SLACK_BOT_TOKEN'), + 'signing_secret' => env('SLACK_SIGNING_SECRET'), + 'client_id' => env('SLACK_CLIENT_ID'), + 'client_secret' => env('SLACK_CLIENT_SECRET'), + + /* + |-------------------------------------------------------------------------- + | Scopes + |-------------------------------------------------------------------------- + */ + 'scopes' => [ + 'chat:write', + 'chat:write.public', + 'commands', + 'incoming-webhook', + 'users:read', + ], + + /* + |-------------------------------------------------------------------------- + | Message Configuration + |-------------------------------------------------------------------------- + */ + 'max_message_character_length' => 3000, + 'header_buffer_length' => 61, + + /* + |-------------------------------------------------------------------------- + | Cooldown Configuration + |-------------------------------------------------------------------------- + */ + 'check_api_cooldown_minutes' => 15, + + /* + |-------------------------------------------------------------------------- + | Database Configuration + |-------------------------------------------------------------------------- + */ + 'old_messages_retention_days' => 90, + + /* + |-------------------------------------------------------------------------- + | Event Configuration + |-------------------------------------------------------------------------- + */ + 'days_to_look_back' => 1, + 'days_to_look_ahead' => 14, +]; diff --git a/app-modules/slack-events-bot/database/migrations/2024_01_01_000001_create_slack_channels_table.php b/app-modules/slack-events-bot/database/migrations/2024_01_01_000001_create_slack_channels_table.php new file mode 100644 index 00000000..155cf7ed --- /dev/null +++ b/app-modules/slack-events-bot/database/migrations/2024_01_01_000001_create_slack_channels_table.php @@ -0,0 +1,24 @@ +id(); + $table->string('slack_channel_id')->unique(); + $table->timestamps(); + + $table->index('slack_channel_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('slack_channels'); + } +}; diff --git a/app-modules/slack-events-bot/database/migrations/2024_01_01_000002_create_slack_messages_table.php b/app-modules/slack-events-bot/database/migrations/2024_01_01_000002_create_slack_messages_table.php new file mode 100644 index 00000000..c382d72e --- /dev/null +++ b/app-modules/slack-events-bot/database/migrations/2024_01_01_000002_create_slack_messages_table.php @@ -0,0 +1,29 @@ +id(); + $table->dateTime('week'); + $table->string('message_timestamp'); + $table->text('message'); + $table->integer('sequence_position')->default(0); + $table->foreignId('channel_id')->constrained('slack_channels')->onDelete('cascade'); + $table->timestamps(); + + $table->index('week'); + $table->index(['channel_id', 'week']); + }); + } + + public function down(): void + { + Schema::dropIfExists('slack_messages'); + } +}; diff --git a/app-modules/slack-events-bot/database/migrations/2024_01_01_000003_create_slack_cooldowns_table.php b/app-modules/slack-events-bot/database/migrations/2024_01_01_000003_create_slack_cooldowns_table.php new file mode 100644 index 00000000..42813c27 --- /dev/null +++ b/app-modules/slack-events-bot/database/migrations/2024_01_01_000003_create_slack_cooldowns_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('accessor')->comment('Unique identifier from whomever is accessing the resource'); + $table->string('resource')->comment('Unique identifier for whatever is rate-limited'); + $table->dateTime('expires_at')->comment('When the accessor will be allowed to access the resource again'); + $table->timestamps(); + + $table->unique(['accessor', 'resource']); + $table->index(['accessor', 'resource']); + }); + } + + public function down(): void + { + Schema::dropIfExists('slack_cooldowns'); + } +}; diff --git a/app-modules/slack-events-bot/routes/web.php b/app-modules/slack-events-bot/routes/web.php new file mode 100644 index 00000000..a9a0c39b --- /dev/null +++ b/app-modules/slack-events-bot/routes/web.php @@ -0,0 +1,13 @@ +group(function () { + Route::get('/install', [SlackController::class, 'install'])->name('slack.install'); + Route::get('/auth', [SlackController::class, 'auth'])->name('slack.auth'); + Route::post('/events', [SlackController::class, 'events']) + ->middleware(ValidateSlackRequest::class) + ->name('slack.events'); +}); diff --git a/app-modules/slack-events-bot/src/Console/Commands/CheckApiCommand.php b/app-modules/slack-events-bot/src/Console/Commands/CheckApiCommand.php new file mode 100644 index 00000000..6576e3f1 --- /dev/null +++ b/app-modules/slack-events-bot/src/Console/Commands/CheckApiCommand.php @@ -0,0 +1,31 @@ +info('Checking for events...'); + + try { + $this->botService->checkApi(); // Method name kept for backward compatibility + $this->info('Event check completed successfully!'); + return self::SUCCESS; + } catch (\Exception $e) { + $this->error('Error checking events: ' . $e->getMessage()); + return self::FAILURE; + } + } +} diff --git a/app-modules/slack-events-bot/src/Console/Commands/DeleteOldMessagesCommand.php b/app-modules/slack-events-bot/src/Console/Commands/DeleteOldMessagesCommand.php new file mode 100644 index 00000000..98488b19 --- /dev/null +++ b/app-modules/slack-events-bot/src/Console/Commands/DeleteOldMessagesCommand.php @@ -0,0 +1,32 @@ +option('days'); + $this->info("Deleting messages older than {$days} days..."); + + try { + $this->databaseService->deleteOldMessages($days); + $this->info('Old messages deleted successfully!'); + return self::SUCCESS; + } catch (\Exception $e) { + $this->error('Error deleting old messages: ' . $e->getMessage()); + return self::FAILURE; + } + } +} diff --git a/app-modules/slack-events-bot/src/Exceptions/UnsafeMessageSpilloverException.php b/app-modules/slack-events-bot/src/Exceptions/UnsafeMessageSpilloverException.php new file mode 100644 index 00000000..30a241d3 --- /dev/null +++ b/app-modules/slack-events-bot/src/Exceptions/UnsafeMessageSpilloverException.php @@ -0,0 +1,10 @@ + $state]); + + $clientId = config('slack-events-bot.client_id'); + $scopes = implode(',', config('slack-events-bot.scopes')); + + $url = "https://slack.com/oauth/v2/authorize?" . http_build_query([ + 'client_id' => $clientId, + 'scope' => $scopes, + 'state' => $state, + ]); + + $html = << + Add to Slack + + HTML; + + return response($html); + } + + public function auth(Request $request): Response + { + $code = $request->get('code'); + $state = $request->get('state'); + $error = $request->get('error'); + + if ($error) { + return response("Something is wrong with the installation (error: {$error})", 400); + } + + if ($code && session('slack_oauth_state') === $state) { + session()->forget('slack_oauth_state'); + + $response = Http::post('https://slack.com/api/oauth.v2.access', [ + 'client_id' => config('slack-events-bot.client_id'), + 'client_secret' => config('slack-events-bot.client_secret'), + 'code' => $code, + ]); + + if ($response->successful()) { + return response('The HackGreenville API bot has been installed successfully!'); + } + } + + return response('Something is wrong with the installation', 400); + } + + public function events(Request $request): Response + { + $payload = $request->all(); + + // Handle URL verification challenge + if (isset($payload['type']) && $payload['type'] === 'url_verification') { + return response($payload['challenge']); + } + + // Handle slash commands + if (isset($payload['command'])) { + return $this->handleSlashCommand($payload); + } + + // Handle other events + if (isset($payload['event'])) { + // Process events asynchronously if needed + Log::info('Received Slack event', $payload); + } + + return response('', 200); + } + + private function handleSlashCommand(array $payload): Response + { + $command = $payload['command']; + $userId = $payload['user_id']; + $channelId = $payload['channel_id']; + $teamDomain = $payload['team_domain'] ?? null; + + switch ($command) { + case '/add_channel': + if (!$this->authService->isAdmin($userId)) { + return response('You must be a workspace admin in order to run `/add_channel`'); + } + + try { + $this->databaseService->addChannel($channelId); + return response('Added channel to slack events bot 👍'); + } catch (\Exception $e) { + return response('Slack events bot has already been activated for this channel'); + } + + case '/remove_channel': + if (!$this->authService->isAdmin($userId)) { + return response('You must be a workspace admin in order to run `/remove_channel`'); + } + + try { + $this->databaseService->removeChannel($channelId); + return response('Removed channel from slack events bot 👍'); + } catch (\Exception $e) { + return response('Slack events bot is not activated for this channel'); + } + + case '/check_api': + // Check cooldown + if ($teamDomain) { + $expiryTime = $this->databaseService->getCooldownExpiryTime($teamDomain, 'check_api'); + + if ($expiryTime && $expiryTime->isFuture()) { + return response( + 'This command has been run recently and is on a cooldown period. ' . + 'Please try again in a little while!' + ); + } + + // Set new cooldown + $this->databaseService->createCooldown( + $teamDomain, + 'check_api', + config('slack-events-bot.check_api_cooldown_minutes') + ); + } + + // Run API check asynchronously + dispatch(function () { + $this->botService->checkApi(); + })->afterResponse(); + + return response('Checking api for events 👍'); + + default: + return response('Unknown command', 400); + } + } +} diff --git a/app-modules/slack-events-bot/src/Http/Middleware/ValidateSlackRequest.php b/app-modules/slack-events-bot/src/Http/Middleware/ValidateSlackRequest.php new file mode 100644 index 00000000..0b4112ae --- /dev/null +++ b/app-modules/slack-events-bot/src/Http/Middleware/ValidateSlackRequest.php @@ -0,0 +1,23 @@ +authService->validateSlackRequest($request)) { + return response('Unauthorized', 401); + } + + return $next($request); + } +} diff --git a/app-modules/slack-events-bot/src/Models/SlackChannel.php b/app-modules/slack-events-bot/src/Models/SlackChannel.php new file mode 100644 index 00000000..9b2d0c8d --- /dev/null +++ b/app-modules/slack-events-bot/src/Models/SlackChannel.php @@ -0,0 +1,18 @@ +hasMany(SlackMessage::class, 'channel_id'); + } +} diff --git a/app-modules/slack-events-bot/src/Models/SlackCooldown.php b/app-modules/slack-events-bot/src/Models/SlackCooldown.php new file mode 100644 index 00000000..dfca6d62 --- /dev/null +++ b/app-modules/slack-events-bot/src/Models/SlackCooldown.php @@ -0,0 +1,18 @@ + 'datetime', + ]; +} diff --git a/app-modules/slack-events-bot/src/Models/SlackMessage.php b/app-modules/slack-events-bot/src/Models/SlackMessage.php new file mode 100644 index 00000000..8b570326 --- /dev/null +++ b/app-modules/slack-events-bot/src/Models/SlackMessage.php @@ -0,0 +1,26 @@ + 'datetime', + ]; + + public function channel(): BelongsTo + { + return $this->belongsTo(SlackChannel::class, 'channel_id'); + } +} diff --git a/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php b/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php new file mode 100644 index 00000000..c6881dc1 --- /dev/null +++ b/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php @@ -0,0 +1,62 @@ +mergeConfigFrom( + __DIR__ . '/../../config/slack-events-bot.php', + 'slack-events-bot' + ); + + // Register services + $this->app->singleton(AuthService::class); + $this->app->singleton(BotService::class); + $this->app->singleton(DatabaseService::class); + $this->app->singleton(EventService::class); + $this->app->singleton(MessageBuilderService::class); + } + + public function boot(): void + { + // Publish config + $this->publishes([ + __DIR__ . '/../../config/slack-events-bot.php' => config_path('slack-events-bot.php'), + ], 'slack-events-bot-config'); + + // Load migrations + $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); + + // Load routes + $this->loadRoutesFrom(__DIR__ . '/../../routes/web.php'); + + // Register commands + if ($this->app->runningInConsole()) { + $this->commands([ + CheckApiCommand::class, + DeleteOldMessagesCommand::class, + ]); + } + + // Schedule tasks + $this->callAfterResolving(Schedule::class, function (Schedule $schedule) { + // Check events every hour + $schedule->command('slack:check-events')->hourly(); + + // Delete old messages once daily + $schedule->command('slack:delete-old-messages')->daily(); + }); + } +} diff --git a/app-modules/slack-events-bot/src/Services/AuthService.php b/app-modules/slack-events-bot/src/Services/AuthService.php new file mode 100644 index 00000000..45be5f8d --- /dev/null +++ b/app-modules/slack-events-bot/src/Services/AuthService.php @@ -0,0 +1,48 @@ +get('https://slack.com/api/users.info', [ + 'user' => $userId, + ]); + + return $response->json(); + } + + public function isAdmin(string $userId): bool + { + $userInfo = $this->getUserInfo($userId); + + return $userInfo['user']['is_admin'] ?? false; + } + + public function validateSlackRequest(Request $request): bool + { + $timestamp = $request->header('X-Slack-Request-Timestamp'); + $signature = $request->header('X-Slack-Signature'); + + if (!$timestamp || !$signature) { + return false; + } + + // Check for possible replay attacks (5 minutes) + if (abs(time() - intval($timestamp)) > 60 * 5) { + return false; + } + + // Verify signature + $signingSecret = config('slack-events-bot.signing_secret'); + $sigBasestring = 'v0:' . $timestamp . ':' . $request->getContent(); + $mySignature = 'v0=' . hash_hmac('sha256', $sigBasestring, $signingSecret); + + return hash_equals($mySignature, $signature); + } +} diff --git a/app-modules/slack-events-bot/src/Services/BotService.php b/app-modules/slack-events-bot/src/Services/BotService.php new file mode 100644 index 00000000..0c3631bd --- /dev/null +++ b/app-modules/slack-events-bot/src/Services/BotService.php @@ -0,0 +1,223 @@ + $existingMessagesLength && $existingMessagesLength > 0) { + $latestMessage = $this->databaseService->getMostRecentMessageForChannel($slackChannelId); + + if (!$latestMessage) { + return false; + } + + $latestMessageWeek = Carbon::parse($latestMessage['week']); + + // If the latest message is for a more recent week then it is unsafe + // to add new messages. We cannot place new messages before older, existing ones. + return $latestMessageWeek->greaterThan($week); + } + + return false; + } + + private function postNewMessage(string $slackChannelId, array $msgBlocks, string $msgText): array + { + $response = Http::withToken(config('slack-events-bot.bot_token')) + ->post('https://slack.com/api/chat.postMessage', [ + 'channel' => $slackChannelId, + 'blocks' => $msgBlocks, + 'text' => $msgText, + 'unfurl_links' => false, + 'unfurl_media' => false, + ]); + + return $response->json(); + } + + public function postOrUpdateMessages(Carbon $week, array $messages): void + { + $channels = $this->databaseService->getSlackChannelIds(); + $existingMessages = $this->databaseService->getMessages($week); + + // Group existing messages by channel + $messageDetails = []; + foreach ($existingMessages as $existingMessage) { + $channelId = $existingMessage['slack_channel_id']; + if (!isset($messageDetails[$channelId])) { + $messageDetails[$channelId] = []; + } + $messageDetails[$channelId][] = [ + 'timestamp' => $existingMessage['message_timestamp'], + 'message' => $existingMessage['message'], + ]; + } + + $postedChannelsSet = array_keys($messageDetails); + + foreach ($channels as $slackChannelId) { + try { + foreach ($messages as $msgIdx => $msg) { + $msgText = $msg['text']; + $msgBlocks = $msg['blocks']; + + // If new events now warrant additional messages being posted + if ($msgIdx > count($messageDetails[$slackChannelId] ?? []) - 1) { + if ($this->isUnsafeToSpillover( + count($existingMessages), + count($messages), + $week, + $slackChannelId + )) { + throw new UnsafeMessageSpilloverException(); + } + + Log::info("Posting an additional message for week {$week->format('F j')} in {$slackChannelId}"); + + $slackResponse = $this->postNewMessage($slackChannelId, $msgBlocks, $msgText); + + $this->databaseService->createMessage( + $week, + $msgText, + $slackResponse['ts'], + $slackChannelId, + $msgIdx + ); + } elseif ( + in_array($slackChannelId, $postedChannelsSet) && + $msgText == $messageDetails[$slackChannelId][$msgIdx]['message'] + ) { + Log::info( + "Message " . ($msgIdx + 1) . " for week of {$week->format('F j')} " . + "in {$slackChannelId} hasn't changed, not updating" + ); + } elseif (in_array($slackChannelId, $postedChannelsSet)) { + if ($this->isUnsafeToSpillover( + count($existingMessages), + count($messages), + $week, + $slackChannelId + )) { + throw new UnsafeMessageSpilloverException(); + } + + Log::info( + "Updating message " . ($msgIdx + 1) . " for week {$week->format('F j')} " . + "in {$slackChannelId}" + ); + + $timestamp = $messageDetails[$slackChannelId][$msgIdx]['timestamp']; + + $response = Http::withToken(config('slack-events-bot.bot_token')) + ->post('https://slack.com/api/chat.update', [ + 'ts' => $timestamp, + 'channel' => $slackChannelId, + 'blocks' => $msgBlocks, + 'text' => $msgText, + ]); + + $this->databaseService->updateMessage($week, $msgText, $timestamp, $slackChannelId); + } else { + Log::info( + "Posting message " . ($msgIdx + 1) . " for week {$week->format('F j')} " . + "in {$slackChannelId}" + ); + + $slackResponse = $this->postNewMessage($slackChannelId, $msgBlocks, $msgText); + + $this->databaseService->createMessage( + $week, + $msgText, + $slackResponse['ts'], + $slackChannelId, + $msgIdx + ); + } + } + } catch (UnsafeMessageSpilloverException $e) { + Log::error( + "Cannot update messages for {$week->format('m/d/Y')} for channel {$slackChannelId}. " . + "New events have caused the number of messages needed to increase, " . + "but the next week's post has already been sent. Cannot resize. " . + "Existing message count: " . count($existingMessages) . " --- New message count: " . count($messages) + ); + continue; + } + } + } + + public function parseEventsForWeek(Carbon $probeDate, Collection $events): void + { + $weekStart = $probeDate->copy()->startOfWeek()->subDay(); // Sunday + $weekEnd = $weekStart->copy()->addDays(7); + + // Convert Event models to array format expected by MessageBuilderService + $eventsArray = $events->map(function (Event $event) { + return [ + 'event_name' => $event->event_name, + 'group_name' => $event->group_name, + 'description' => $event->description, + 'venue' => $event->venue ? [ + 'name' => $event->venue->name, + 'address' => $event->venue->address, + 'city' => $event->venue->city, + 'state' => $event->venue->state?->abbr, + 'zip' => $event->venue->zipcode, + 'lat' => $event->venue->lat, + 'lon' => $event->venue->lng, + ] : null, + 'time' => $event->active_at->toIso8601String(), + 'url' => $event->uri, + 'status' => $event->status, + 'uuid' => $event->event_uuid, + ]; + })->toArray(); + + $eventBlocks = $this->messageBuilderService->buildEventBlocks($eventsArray, $weekStart, $weekEnd); + $chunkedMessages = $this->messageBuilderService->chunkMessages($eventBlocks, $weekStart); + + $this->postOrUpdateMessages($weekStart, $chunkedMessages); + } + + public function checkApi(): void + { + // Get events directly from the database instead of API + $events = Event::query() + ->with(['venue.state', 'organization']) + ->published() + ->where('active_at', '>=', now()->subDays(config('slack-events-bot.days_to_look_back', 1))) + ->where('active_at', '<=', now()->addDays(config('slack-events-bot.days_to_look_ahead', 14))) + ->oldest('active_at') + ->get(); + + // Get timezone aware today + $today = Carbon::today(); + + // Keep current week's post up to date + $this->parseEventsForWeek($today, $events); + + // Potentially post next week 5 days early + $probeDate = $today->copy()->addDays(5); + $this->parseEventsForWeek($probeDate, $events); + } +} diff --git a/app-modules/slack-events-bot/src/Services/DatabaseService.php b/app-modules/slack-events-bot/src/Services/DatabaseService.php new file mode 100644 index 00000000..fea67148 --- /dev/null +++ b/app-modules/slack-events-bot/src/Services/DatabaseService.php @@ -0,0 +1,133 @@ +firstOrFail(); + + return SlackMessage::create([ + 'week' => $week, + 'message' => $message, + 'message_timestamp' => $messageTimestamp, + 'channel_id' => $channel->id, + 'sequence_position' => $sequencePosition, + ]); + } + + public function updateMessage( + Carbon $week, + string $message, + string $messageTimestamp, + string $slackChannelId + ): int { + $channel = SlackChannel::where('slack_channel_id', $slackChannelId)->firstOrFail(); + + return SlackMessage::where('week', $week) + ->where('message_timestamp', $messageTimestamp) + ->where('channel_id', $channel->id) + ->update(['message' => $message]); + } + + public function getMessages(Carbon $week): Collection + { + return SlackMessage::with('channel') + ->where('week', $week) + ->orderBy('sequence_position') + ->get() + ->map(function ($message) { + return [ + 'message' => $message->message, + 'message_timestamp' => $message->message_timestamp, + 'slack_channel_id' => $message->channel->slack_channel_id, + 'sequence_position' => $message->sequence_position, + ]; + }); + } + + public function getMostRecentMessageForChannel(string $slackChannelId): ?array + { + $channel = SlackChannel::where('slack_channel_id', $slackChannelId)->first(); + + if (!$channel) { + return null; + } + + $message = SlackMessage::where('channel_id', $channel->id) + ->orderBy('week', 'desc') + ->orderBy('message_timestamp', 'desc') + ->first(); + + if (!$message) { + return null; + } + + return [ + 'week' => $message->week->toIso8601String(), + 'message' => $message->message, + 'message_timestamp' => $message->message_timestamp, + ]; + } + + public function getSlackChannelIds(): Collection + { + return SlackChannel::pluck('slack_channel_id'); + } + + public function addChannel(string $slackChannelId): SlackChannel + { + return SlackChannel::create(['slack_channel_id' => $slackChannelId]); + } + + public function removeChannel(string $slackChannelId): int + { + return SlackChannel::where('slack_channel_id', $slackChannelId)->delete(); + } + + public function deleteOldMessages(int $daysBack = 90): void + { + $cutoffDate = Carbon::now()->subDays($daysBack); + + // Delete old messages + SlackMessage::whereRaw('CAST(message_timestamp AS DECIMAL) < ?', [$cutoffDate->timestamp]) + ->delete(); + + // Delete old cooldowns + SlackCooldown::where('expires_at', '<', $cutoffDate)->delete(); + } + + public function createCooldown(string $accessor, string $resource, int $cooldownMinutes): SlackCooldown + { + return SlackCooldown::updateOrCreate( + [ + 'accessor' => $accessor, + 'resource' => $resource, + ], + [ + 'expires_at' => Carbon::now()->addMinutes($cooldownMinutes), + ] + ); + } + + public function getCooldownExpiryTime(string $accessor, string $resource): ?Carbon + { + $cooldown = SlackCooldown::where('accessor', $accessor) + ->where('resource', $resource) + ->first(); + + return $cooldown?->expires_at; + } +} diff --git a/app-modules/slack-events-bot/src/Services/EventService.php b/app-modules/slack-events-bot/src/Services/EventService.php new file mode 100644 index 00000000..e5a09f86 --- /dev/null +++ b/app-modules/slack-events-bot/src/Services/EventService.php @@ -0,0 +1,131 @@ +"; + } + + private function printStatus(string $status): string + { + return match ($status) { + 'upcoming' => 'Upcoming ✅', + 'past' => 'Past ✔', + 'cancelled' => 'Cancelled ❌', + default => ucfirst($status), + }; + } + + private function printDateTime(Carbon $time): string + { + return $time->setTimezone(config('app.timezone'))->format('F j, Y g:i A T'); + } + + public function createEventFromJson(array $eventData): array + { + return [ + 'title' => $eventData['event_name'], + 'group_name' => $eventData['group_name'], + 'description' => $eventData['description'], + 'location' => $this->parseLocation($eventData), + 'time' => Carbon::parse($eventData['time']), + 'url' => $eventData['url'], + 'status' => $eventData['status'], + 'uuid' => $eventData['uuid'], + ]; + } + + public function generateBlocks(array $event): array + { + return [ + [ + 'type' => 'header', + 'text' => [ + 'type' => 'plain_text', + 'text' => $this->truncateString($event['title']), + ], + ], + [ + 'type' => 'section', + 'text' => [ + 'type' => 'plain_text', + 'text' => $this->truncateString($event['description']), + ], + 'fields' => [ + ['type' => 'mrkdwn', 'text' => '*' . $this->truncateString($event['group_name']) . '*'], + ['type' => 'mrkdwn', 'text' => '<' . $event['url'] . '|*Link* :link:>'], + ['type' => 'mrkdwn', 'text' => '*Status*'], + ['type' => 'mrkdwn', 'text' => $this->printStatus($event['status'])], + ['type' => 'mrkdwn', 'text' => '*Location*'], + ['type' => 'mrkdwn', 'text' => $this->getLocationUrl($event['location'])], + ['type' => 'mrkdwn', 'text' => '*Time*'], + ['type' => 'plain_text', 'text' => $this->printDateTime($event['time'])], + ], + ], + ]; + } + + public function generateText(array $event): string + { + return sprintf( + "%s\nDescription: %s\nLink: %s\nStatus: %s\nLocation: %s\nTime: %s", + $this->truncateString($event['title']), + $this->truncateString($event['description']), + $event['url'], + $this->printStatus($event['status']), + $event['location'] ?? 'No location', + $this->printDateTime($event['time']) + ); + } +} diff --git a/app-modules/slack-events-bot/src/Services/MessageBuilderService.php b/app-modules/slack-events-bot/src/Services/MessageBuilderService.php new file mode 100644 index 00000000..c9650d63 --- /dev/null +++ b/app-modules/slack-events-bot/src/Services/MessageBuilderService.php @@ -0,0 +1,120 @@ +format('F j'), + $index, + $total + ); + + return [ + 'blocks' => [ + [ + 'type' => 'header', + 'text' => [ + 'type' => 'plain_text', + 'text' => sprintf( + 'HackGreenville Events for the week of %s - %d of %d', + $weekStart->format('F j'), + $index, + $total + ), + ], + ], + ['type' => 'divider'], + ], + 'text' => $text, + 'text_length' => strlen($text), + ]; + } + + private function buildSingleEventBlock(array $eventData, Carbon $weekStart, Carbon $weekEnd): ?array + { + $event = $this->eventService->createEventFromJson($eventData); + + // Ignore event if it's not in the current week + if ($event['time']->lt($weekStart) || $event['time']->gt($weekEnd)) { + return null; + } + + // Ignore event if it has a non-supported status + if (!in_array($event['status'], ['cancelled', 'upcoming', 'past'])) { + logger()->warning("Couldn't parse event {$event['uuid']} with status: {$event['status']}"); + return null; + } + + $text = $this->eventService->generateText($event) . "\n\n"; + + return [ + 'blocks' => array_merge($this->eventService->generateBlocks($event), [['type' => 'divider']]), + 'text' => $text, + 'text_length' => strlen($text), + ]; + } + + public function buildEventBlocks(array $events, Carbon $weekStart, Carbon $weekEnd): Collection + { + return collect($events) + ->map(fn($event) => $this->buildSingleEventBlock($event, $weekStart, $weekEnd)) + ->filter() + ->values(); + } + + private function totalMessagesNeeded(Collection $eventBlocks): int + { + $maxLength = config('slack-events-bot.max_message_character_length'); + $headerBuffer = config('slack-events-bot.header_buffer_length'); + + $totalLength = $eventBlocks->sum('text_length'); + $messagesNeeded = (int) ceil($totalLength / ($maxLength - $headerBuffer)); + + // Ensure total count is at least 1 if we're going to post anything + return max(1, $messagesNeeded); + } + + public function chunkMessages(Collection $eventBlocks, Carbon $weekStart): array + { + $messagesNeeded = $this->totalMessagesNeeded($eventBlocks); + $maxLength = config('slack-events-bot.max_message_character_length'); + + $messages = []; + $initialHeader = $this->buildHeader($weekStart, 1, $messagesNeeded); + + $blocks = $initialHeader['blocks']; + $text = $initialHeader['text']; + + foreach ($eventBlocks as $event) { + // Event can be safely added to existing message + if ($event['text_length'] + strlen($text) < $maxLength) { + $blocks = array_merge($blocks, $event['blocks']); + $text .= $event['text']; + continue; + } + + // Save message and then start a new one + $messages[] = ['blocks' => $blocks, 'text' => $text]; + + $newHeader = $this->buildHeader($weekStart, count($messages) + 1, $messagesNeeded); + $blocks = array_merge($newHeader['blocks'], $event['blocks']); + $text = $newHeader['text'] . $event['text']; + } + + // Add whatever is left as a new message + $messages[] = ['blocks' => $blocks, 'text' => $text]; + + return $messages; + } +} diff --git a/app-modules/slack-events-bot/tests/SlackEventsBotTest.php b/app-modules/slack-events-bot/tests/SlackEventsBotTest.php new file mode 100644 index 00000000..75809fbc --- /dev/null +++ b/app-modules/slack-events-bot/tests/SlackEventsBotTest.php @@ -0,0 +1,160 @@ +databaseService = app(DatabaseService::class); + $this->eventService = app(EventService::class); + $this->messageBuilderService = app(MessageBuilderService::class); + } + + public function test_can_add_and_remove_channel() + { + $channelId = 'C1234567890'; + + // Add channel + $channel = $this->databaseService->addChannel($channelId); + $this->assertDatabaseHas('slack_channels', ['slack_channel_id' => $channelId]); + + // Get channels + $channels = $this->databaseService->getSlackChannelIds(); + $this->assertTrue($channels->contains($channelId)); + + // Remove channel + $this->databaseService->removeChannel($channelId); + $this->assertDatabaseMissing('slack_channels', ['slack_channel_id' => $channelId]); + } + + public function test_can_create_and_get_messages() + { + $channelId = 'C1234567890'; + $this->databaseService->addChannel($channelId); + + $week = Carbon::now()->startOfWeek(); + $message = 'Test message content'; + $timestamp = '1234567890.123456'; + + // Create message + $this->databaseService->createMessage($week, $message, $timestamp, $channelId, 0); + + // Get messages + $messages = $this->databaseService->getMessages($week); + $this->assertCount(1, $messages); + $this->assertEquals($message, $messages->first()['message']); + $this->assertEquals($channelId, $messages->first()['slack_channel_id']); + } + + public function test_event_parsing() + { + $eventData = [ + 'event_name' => 'Test Event', + 'group_name' => 'Test Group', + 'description' => 'This is a test event description', + 'venue' => [ + 'name' => 'Test Venue', + 'address' => '123 Main St', + 'city' => 'Greenville', + 'state' => 'SC', + 'zip' => '29601', + 'lat' => null, + 'lon' => null, + ], + 'time' => '2024-01-15T18:00:00-05:00', + 'url' => 'https://example.com/event', + 'status' => 'upcoming', + 'uuid' => 'test-uuid-123', + ]; + + $event = $this->eventService->createEventFromJson($eventData); + + $this->assertEquals('Test Event', $event['title']); + $this->assertEquals('Test Group', $event['group_name']); + $this->assertEquals('Test Venue at 123 Main St Greenville, SC 29601', $event['location']); + $this->assertInstanceOf(Carbon::class, $event['time']); + + // Test block generation + $blocks = $this->eventService->generateBlocks($event); + $this->assertIsArray($blocks); + $this->assertEquals('header', $blocks[0]['type']); + $this->assertEquals('section', $blocks[1]['type']); + + // Test text generation + $text = $this->eventService->generateText($event); + $this->assertStringContainsString('Test Event', $text); + $this->assertStringContainsString('Test Group', $text); + } + + public function test_cooldown_functionality() + { + $accessor = 'test-workspace'; + $resource = 'check_api'; + + // No cooldown initially + $expiry = $this->databaseService->getCooldownExpiryTime($accessor, $resource); + $this->assertNull($expiry); + + // Create cooldown + $this->databaseService->createCooldown($accessor, $resource, 15); + + // Check cooldown exists and is in future + $expiry = $this->databaseService->getCooldownExpiryTime($accessor, $resource); + $this->assertNotNull($expiry); + $this->assertTrue($expiry->isFuture()); + } + + public function test_message_chunking() + { + $weekStart = Carbon::now()->startOfWeek(); + $weekEnd = $weekStart->copy()->addDays(7); + + // Create multiple events + $events = []; + for ($i = 0; $i < 10; $i++) { + $events[] = [ + 'event_name' => "Event $i with a very long title that takes up space", + 'group_name' => "Group $i", + 'description' => str_repeat("This is a long description. ", 10), + 'venue' => ['name' => "Venue $i"], + 'time' => $weekStart->copy()->addDays($i % 7)->toIso8601String(), + 'url' => "https://example.com/event-$i", + 'status' => 'upcoming', + 'uuid' => "uuid-$i", + ]; + } + + $eventBlocks = $this->messageBuilderService->buildEventBlocks($events, $weekStart, $weekEnd); + $this->assertGreaterThan(0, $eventBlocks->count()); + + $chunkedMessages = $this->messageBuilderService->chunkMessages($eventBlocks, $weekStart); + $this->assertIsArray($chunkedMessages); + $this->assertGreaterThan(0, count($chunkedMessages)); + + // Verify each message has required structure + foreach ($chunkedMessages as $message) { + $this->assertArrayHasKey('blocks', $message); + $this->assertArrayHasKey('text', $message); + $this->assertLessThan( + config('slack-events-bot.max_message_character_length'), + strlen($message['text']) + ); + } + } +} diff --git a/composer.json b/composer.json index cd2850ec..f1572139 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "guzzlehttp/guzzle": "^7.0.1", "hack-greenville/api": "*", "hack-greenville/event-importer": "*", + "hack-greenville/slack-events-bot": "*", "internachi/modular": "^2.0", "knuckleswtf/scribe": "^5.2", "laravel/framework": "^10.0", diff --git a/composer.lock b/composer.lock index 704d3a2f..bf879558 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "74defb137e7f4e7bd9a1a4a97b6d7153", + "content-hash": "35577f87f88ec8269c29a0acb1f4ada5", "packages": [ { "name": "amphp/amp", @@ -4118,6 +4118,41 @@ "relative": true } }, + { + "name": "hack-greenville/slack-events-bot", + "version": "1.0", + "dist": { + "type": "path", + "url": "app-modules/slack-events-bot", + "reference": "533e449e7f78748d5fdebf266814961c02184ac1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "HackGreenville\\SlackEventsBot\\Providers\\SlackEventsBotServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "HackGreenville\\SlackEventsBot\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "HackGreenville\\SlackEventsBot\\Tests\\": "tests/" + } + }, + "license": [ + "MIT" + ], + "description": "Slack bot that relays information from HackGreenville Labs' Events API to Slack channels", + "transport-options": { + "symlink": true, + "relative": true + } + }, { "name": "halaxa/json-machine", "version": "1.1.4", From b2f59a3efaadf61b45fea380c70644a09174da47 Mon Sep 17 00:00:00 2001 From: Bogdan Kharchenko Date: Thu, 22 May 2025 20:28:25 -0400 Subject: [PATCH 02/19] style --- ..._01_000001_create_slack_channels_table.php | 3 +- ..._01_000002_create_slack_messages_table.php | 3 +- ...01_000003_create_slack_cooldowns_table.php | 3 +- .../src/Console/Commands/CheckApiCommand.php | 3 +- .../Commands/DeleteOldMessagesCommand.php | 5 +- .../src/Http/Controllers/SlackController.php | 10 +- .../Http/Middleware/ValidateSlackRequest.php | 2 +- .../src/Services/AuthService.php | 8 +- .../src/Services/BotService.php | 120 ++++++++--------- .../src/Services/DatabaseService.php | 24 ++-- .../src/Services/EventService.php | 127 +++++++++--------- .../src/Services/MessageBuilderService.php | 90 ++++++------- .../tests/SlackEventsBotTest.php | 46 +++---- 13 files changed, 219 insertions(+), 225 deletions(-) diff --git a/app-modules/slack-events-bot/database/migrations/2024_01_01_000001_create_slack_channels_table.php b/app-modules/slack-events-bot/database/migrations/2024_01_01_000001_create_slack_channels_table.php index 155cf7ed..5cdecece 100644 --- a/app-modules/slack-events-bot/database/migrations/2024_01_01_000001_create_slack_channels_table.php +++ b/app-modules/slack-events-bot/database/migrations/2024_01_01_000001_create_slack_channels_table.php @@ -4,8 +4,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration -{ +return new class extends Migration { public function up(): void { Schema::create('slack_channels', function (Blueprint $table) { diff --git a/app-modules/slack-events-bot/database/migrations/2024_01_01_000002_create_slack_messages_table.php b/app-modules/slack-events-bot/database/migrations/2024_01_01_000002_create_slack_messages_table.php index c382d72e..ae6faa16 100644 --- a/app-modules/slack-events-bot/database/migrations/2024_01_01_000002_create_slack_messages_table.php +++ b/app-modules/slack-events-bot/database/migrations/2024_01_01_000002_create_slack_messages_table.php @@ -4,8 +4,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration -{ +return new class extends Migration { public function up(): void { Schema::create('slack_messages', function (Blueprint $table) { diff --git a/app-modules/slack-events-bot/database/migrations/2024_01_01_000003_create_slack_cooldowns_table.php b/app-modules/slack-events-bot/database/migrations/2024_01_01_000003_create_slack_cooldowns_table.php index 42813c27..25267b3c 100644 --- a/app-modules/slack-events-bot/database/migrations/2024_01_01_000003_create_slack_cooldowns_table.php +++ b/app-modules/slack-events-bot/database/migrations/2024_01_01_000003_create_slack_cooldowns_table.php @@ -4,8 +4,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration -{ +return new class extends Migration { public function up(): void { Schema::create('slack_cooldowns', function (Blueprint $table) { diff --git a/app-modules/slack-events-bot/src/Console/Commands/CheckApiCommand.php b/app-modules/slack-events-bot/src/Console/Commands/CheckApiCommand.php index 6576e3f1..49cc9a2d 100644 --- a/app-modules/slack-events-bot/src/Console/Commands/CheckApiCommand.php +++ b/app-modules/slack-events-bot/src/Console/Commands/CheckApiCommand.php @@ -2,6 +2,7 @@ namespace HackGreenville\SlackEventsBot\Console\Commands; +use Exception; use HackGreenville\SlackEventsBot\Services\BotService; use Illuminate\Console\Command; @@ -23,7 +24,7 @@ public function handle(): int $this->botService->checkApi(); // Method name kept for backward compatibility $this->info('Event check completed successfully!'); return self::SUCCESS; - } catch (\Exception $e) { + } catch (Exception $e) { $this->error('Error checking events: ' . $e->getMessage()); return self::FAILURE; } diff --git a/app-modules/slack-events-bot/src/Console/Commands/DeleteOldMessagesCommand.php b/app-modules/slack-events-bot/src/Console/Commands/DeleteOldMessagesCommand.php index 98488b19..48a884eb 100644 --- a/app-modules/slack-events-bot/src/Console/Commands/DeleteOldMessagesCommand.php +++ b/app-modules/slack-events-bot/src/Console/Commands/DeleteOldMessagesCommand.php @@ -2,6 +2,7 @@ namespace HackGreenville\SlackEventsBot\Console\Commands; +use Exception; use HackGreenville\SlackEventsBot\Services\DatabaseService; use Illuminate\Console\Command; @@ -19,12 +20,12 @@ public function handle(): int { $days = (int) $this->option('days'); $this->info("Deleting messages older than {$days} days..."); - + try { $this->databaseService->deleteOldMessages($days); $this->info('Old messages deleted successfully!'); return self::SUCCESS; - } catch (\Exception $e) { + } catch (Exception $e) { $this->error('Error deleting old messages: ' . $e->getMessage()); return self::FAILURE; } diff --git a/app-modules/slack-events-bot/src/Http/Controllers/SlackController.php b/app-modules/slack-events-bot/src/Http/Controllers/SlackController.php index d1e8ea2f..3e78b926 100644 --- a/app-modules/slack-events-bot/src/Http/Controllers/SlackController.php +++ b/app-modules/slack-events-bot/src/Http/Controllers/SlackController.php @@ -2,7 +2,7 @@ namespace HackGreenville\SlackEventsBot\Http\Controllers; -use Carbon\Carbon; +use Exception; use HackGreenville\SlackEventsBot\Services\AuthService; use HackGreenville\SlackEventsBot\Services\BotService; use HackGreenville\SlackEventsBot\Services\DatabaseService; @@ -106,26 +106,26 @@ private function handleSlashCommand(array $payload): Response switch ($command) { case '/add_channel': - if (!$this->authService->isAdmin($userId)) { + if ( ! $this->authService->isAdmin($userId)) { return response('You must be a workspace admin in order to run `/add_channel`'); } try { $this->databaseService->addChannel($channelId); return response('Added channel to slack events bot 👍'); - } catch (\Exception $e) { + } catch (Exception $e) { return response('Slack events bot has already been activated for this channel'); } case '/remove_channel': - if (!$this->authService->isAdmin($userId)) { + if ( ! $this->authService->isAdmin($userId)) { return response('You must be a workspace admin in order to run `/remove_channel`'); } try { $this->databaseService->removeChannel($channelId); return response('Removed channel from slack events bot 👍'); - } catch (\Exception $e) { + } catch (Exception $e) { return response('Slack events bot is not activated for this channel'); } diff --git a/app-modules/slack-events-bot/src/Http/Middleware/ValidateSlackRequest.php b/app-modules/slack-events-bot/src/Http/Middleware/ValidateSlackRequest.php index 0b4112ae..9438fa48 100644 --- a/app-modules/slack-events-bot/src/Http/Middleware/ValidateSlackRequest.php +++ b/app-modules/slack-events-bot/src/Http/Middleware/ValidateSlackRequest.php @@ -14,7 +14,7 @@ public function __construct(private AuthService $authService) public function handle(Request $request, Closure $next) { - if (!$this->authService->validateSlackRequest($request)) { + if ( ! $this->authService->validateSlackRequest($request)) { return response('Unauthorized', 401); } diff --git a/app-modules/slack-events-bot/src/Services/AuthService.php b/app-modules/slack-events-bot/src/Services/AuthService.php index 45be5f8d..79ec5730 100644 --- a/app-modules/slack-events-bot/src/Services/AuthService.php +++ b/app-modules/slack-events-bot/src/Services/AuthService.php @@ -20,7 +20,7 @@ public function getUserInfo(string $userId): array public function isAdmin(string $userId): bool { $userInfo = $this->getUserInfo($userId); - + return $userInfo['user']['is_admin'] ?? false; } @@ -28,13 +28,13 @@ public function validateSlackRequest(Request $request): bool { $timestamp = $request->header('X-Slack-Request-Timestamp'); $signature = $request->header('X-Slack-Signature'); - - if (!$timestamp || !$signature) { + + if ( ! $timestamp || ! $signature) { return false; } // Check for possible replay attacks (5 minutes) - if (abs(time() - intval($timestamp)) > 60 * 5) { + if (abs(time() - (int) $timestamp) > 60 * 5) { return false; } diff --git a/app-modules/slack-events-bot/src/Services/BotService.php b/app-modules/slack-events-bot/src/Services/BotService.php index 0c3631bd..b5457eaa 100644 --- a/app-modules/slack-events-bot/src/Services/BotService.php +++ b/app-modules/slack-events-bot/src/Services/BotService.php @@ -18,43 +18,6 @@ public function __construct( ) { } - private function isUnsafeToSpillover( - int $existingMessagesLength, - int $newMessagesLength, - Carbon $week, - string $slackChannelId - ): bool { - if ($newMessagesLength > $existingMessagesLength && $existingMessagesLength > 0) { - $latestMessage = $this->databaseService->getMostRecentMessageForChannel($slackChannelId); - - if (!$latestMessage) { - return false; - } - - $latestMessageWeek = Carbon::parse($latestMessage['week']); - - // If the latest message is for a more recent week then it is unsafe - // to add new messages. We cannot place new messages before older, existing ones. - return $latestMessageWeek->greaterThan($week); - } - - return false; - } - - private function postNewMessage(string $slackChannelId, array $msgBlocks, string $msgText): array - { - $response = Http::withToken(config('slack-events-bot.bot_token')) - ->post('https://slack.com/api/chat.postMessage', [ - 'channel' => $slackChannelId, - 'blocks' => $msgBlocks, - 'text' => $msgText, - 'unfurl_links' => false, - 'unfurl_media' => false, - ]); - - return $response->json(); - } - public function postOrUpdateMessages(Carbon $week, array $messages): void { $channels = $this->databaseService->getSlackChannelIds(); @@ -64,7 +27,7 @@ public function postOrUpdateMessages(Carbon $week, array $messages): void $messageDetails = []; foreach ($existingMessages as $existingMessage) { $channelId = $existingMessage['slack_channel_id']; - if (!isset($messageDetails[$channelId])) { + if ( ! isset($messageDetails[$channelId])) { $messageDetails[$channelId] = []; } $messageDetails[$channelId][] = [ @@ -89,7 +52,7 @@ public function postOrUpdateMessages(Carbon $week, array $messages): void $week, $slackChannelId )) { - throw new UnsafeMessageSpilloverException(); + throw new UnsafeMessageSpilloverException; } Log::info("Posting an additional message for week {$week->format('F j')} in {$slackChannelId}"); @@ -105,7 +68,7 @@ public function postOrUpdateMessages(Carbon $week, array $messages): void ); } elseif ( in_array($slackChannelId, $postedChannelsSet) && - $msgText == $messageDetails[$slackChannelId][$msgIdx]['message'] + $msgText === $messageDetails[$slackChannelId][$msgIdx]['message'] ) { Log::info( "Message " . ($msgIdx + 1) . " for week of {$week->format('F j')} " . @@ -118,7 +81,7 @@ public function postOrUpdateMessages(Carbon $week, array $messages): void $week, $slackChannelId )) { - throw new UnsafeMessageSpilloverException(); + throw new UnsafeMessageSpilloverException; } Log::info( @@ -172,26 +135,24 @@ public function parseEventsForWeek(Carbon $probeDate, Collection $events): void $weekEnd = $weekStart->copy()->addDays(7); // Convert Event models to array format expected by MessageBuilderService - $eventsArray = $events->map(function (Event $event) { - return [ - 'event_name' => $event->event_name, - 'group_name' => $event->group_name, - 'description' => $event->description, - 'venue' => $event->venue ? [ - 'name' => $event->venue->name, - 'address' => $event->venue->address, - 'city' => $event->venue->city, - 'state' => $event->venue->state?->abbr, - 'zip' => $event->venue->zipcode, - 'lat' => $event->venue->lat, - 'lon' => $event->venue->lng, - ] : null, - 'time' => $event->active_at->toIso8601String(), - 'url' => $event->uri, - 'status' => $event->status, - 'uuid' => $event->event_uuid, - ]; - })->toArray(); + $eventsArray = $events->map(fn (Event $event) => [ + 'event_name' => $event->event_name, + 'group_name' => $event->group_name, + 'description' => $event->description, + 'venue' => $event->venue ? [ + 'name' => $event->venue->name, + 'address' => $event->venue->address, + 'city' => $event->venue->city, + 'state' => $event->venue->state?->abbr, + 'zip' => $event->venue->zipcode, + 'lat' => $event->venue->lat, + 'lon' => $event->venue->lng, + ] : null, + 'time' => $event->active_at->toIso8601String(), + 'url' => $event->uri, + 'status' => $event->status, + 'uuid' => $event->event_uuid, + ])->toArray(); $eventBlocks = $this->messageBuilderService->buildEventBlocks($eventsArray, $weekStart, $weekEnd); $chunkedMessages = $this->messageBuilderService->chunkMessages($eventBlocks, $weekStart); @@ -220,4 +181,41 @@ public function checkApi(): void $probeDate = $today->copy()->addDays(5); $this->parseEventsForWeek($probeDate, $events); } + + private function isUnsafeToSpillover( + int $existingMessagesLength, + int $newMessagesLength, + Carbon $week, + string $slackChannelId + ): bool { + if ($newMessagesLength > $existingMessagesLength && $existingMessagesLength > 0) { + $latestMessage = $this->databaseService->getMostRecentMessageForChannel($slackChannelId); + + if ( ! $latestMessage) { + return false; + } + + $latestMessageWeek = Carbon::parse($latestMessage['week']); + + // If the latest message is for a more recent week then it is unsafe + // to add new messages. We cannot place new messages before older, existing ones. + return $latestMessageWeek->greaterThan($week); + } + + return false; + } + + private function postNewMessage(string $slackChannelId, array $msgBlocks, string $msgText): array + { + $response = Http::withToken(config('slack-events-bot.bot_token')) + ->post('https://slack.com/api/chat.postMessage', [ + 'channel' => $slackChannelId, + 'blocks' => $msgBlocks, + 'text' => $msgText, + 'unfurl_links' => false, + 'unfurl_media' => false, + ]); + + return $response->json(); + } } diff --git a/app-modules/slack-events-bot/src/Services/DatabaseService.php b/app-modules/slack-events-bot/src/Services/DatabaseService.php index fea67148..d875dae8 100644 --- a/app-modules/slack-events-bot/src/Services/DatabaseService.php +++ b/app-modules/slack-events-bot/src/Services/DatabaseService.php @@ -48,21 +48,19 @@ public function getMessages(Carbon $week): Collection ->where('week', $week) ->orderBy('sequence_position') ->get() - ->map(function ($message) { - return [ - 'message' => $message->message, - 'message_timestamp' => $message->message_timestamp, - 'slack_channel_id' => $message->channel->slack_channel_id, - 'sequence_position' => $message->sequence_position, - ]; - }); + ->map(fn ($message) => [ + 'message' => $message->message, + 'message_timestamp' => $message->message_timestamp, + 'slack_channel_id' => $message->channel->slack_channel_id, + 'sequence_position' => $message->sequence_position, + ]); } public function getMostRecentMessageForChannel(string $slackChannelId): ?array { $channel = SlackChannel::where('slack_channel_id', $slackChannelId)->first(); - - if (!$channel) { + + if ( ! $channel) { return null; } @@ -71,7 +69,7 @@ public function getMostRecentMessageForChannel(string $slackChannelId): ?array ->orderBy('message_timestamp', 'desc') ->first(); - if (!$message) { + if ( ! $message) { return null; } @@ -100,11 +98,11 @@ public function removeChannel(string $slackChannelId): int public function deleteOldMessages(int $daysBack = 90): void { $cutoffDate = Carbon::now()->subDays($daysBack); - + // Delete old messages SlackMessage::whereRaw('CAST(message_timestamp AS DECIMAL) < ?', [$cutoffDate->timestamp]) ->delete(); - + // Delete old cooldowns SlackCooldown::where('expires_at', '<', $cutoffDate)->delete(); } diff --git a/app-modules/slack-events-bot/src/Services/EventService.php b/app-modules/slack-events-bot/src/Services/EventService.php index e5a09f86..f0249714 100644 --- a/app-modules/slack-events-bot/src/Services/EventService.php +++ b/app-modules/slack-events-bot/src/Services/EventService.php @@ -6,18 +6,74 @@ class EventService { + public function createEventFromJson(array $eventData): array + { + return [ + 'title' => $eventData['event_name'], + 'group_name' => $eventData['group_name'], + 'description' => $eventData['description'], + 'location' => $this->parseLocation($eventData), + 'time' => Carbon::parse($eventData['time']), + 'url' => $eventData['url'], + 'status' => $eventData['status'], + 'uuid' => $eventData['uuid'], + ]; + } + + public function generateBlocks(array $event): array + { + return [ + [ + 'type' => 'header', + 'text' => [ + 'type' => 'plain_text', + 'text' => $this->truncateString($event['title']), + ], + ], + [ + 'type' => 'section', + 'text' => [ + 'type' => 'plain_text', + 'text' => $this->truncateString($event['description']), + ], + 'fields' => [ + ['type' => 'mrkdwn', 'text' => '*' . $this->truncateString($event['group_name']) . '*'], + ['type' => 'mrkdwn', 'text' => '<' . $event['url'] . '|*Link* :link:>'], + ['type' => 'mrkdwn', 'text' => '*Status*'], + ['type' => 'mrkdwn', 'text' => $this->printStatus($event['status'])], + ['type' => 'mrkdwn', 'text' => '*Location*'], + ['type' => 'mrkdwn', 'text' => $this->getLocationUrl($event['location'])], + ['type' => 'mrkdwn', 'text' => '*Time*'], + ['type' => 'plain_text', 'text' => $this->printDateTime($event['time'])], + ], + ], + ]; + } + + public function generateText(array $event): string + { + return sprintf( + "%s\nDescription: %s\nLink: %s\nStatus: %s\nLocation: %s\nTime: %s", + $this->truncateString($event['title']), + $this->truncateString($event['description']), + $event['url'], + $this->printStatus($event['status']), + $event['location'] ?? 'No location', + $this->printDateTime($event['time']) + ); + } private function parseLocation(array $eventData): ?string { $venue = $eventData['venue'] ?? null; - - if (!$venue) { + + if ( ! $venue) { return null; } // Full address available if ( isset($venue['name'], $venue['address'], $venue['city'], $venue['state'], $venue['zip']) && - !in_array(null, [$venue['name'], $venue['address'], $venue['city'], $venue['state'], $venue['zip']], true) + ! in_array(null, [$venue['name'], $venue['address'], $venue['city'], $venue['state'], $venue['zip']], true) ) { return sprintf( '%s at %s %s, %s %s', @@ -40,16 +96,16 @@ private function parseLocation(array $eventData): ?string private function truncateString(string $string, int $length = 250): string { - if (strlen($string) <= $length) { + if (mb_strlen($string) <= $length) { return $string; } - - return substr($string, 0, $length) . '...'; + + return mb_substr($string, 0, $length) . '...'; } private function getLocationUrl(?string $location): string { - if (!$location) { + if ( ! $location) { return 'No location'; } @@ -71,61 +127,4 @@ private function printDateTime(Carbon $time): string { return $time->setTimezone(config('app.timezone'))->format('F j, Y g:i A T'); } - - public function createEventFromJson(array $eventData): array - { - return [ - 'title' => $eventData['event_name'], - 'group_name' => $eventData['group_name'], - 'description' => $eventData['description'], - 'location' => $this->parseLocation($eventData), - 'time' => Carbon::parse($eventData['time']), - 'url' => $eventData['url'], - 'status' => $eventData['status'], - 'uuid' => $eventData['uuid'], - ]; - } - - public function generateBlocks(array $event): array - { - return [ - [ - 'type' => 'header', - 'text' => [ - 'type' => 'plain_text', - 'text' => $this->truncateString($event['title']), - ], - ], - [ - 'type' => 'section', - 'text' => [ - 'type' => 'plain_text', - 'text' => $this->truncateString($event['description']), - ], - 'fields' => [ - ['type' => 'mrkdwn', 'text' => '*' . $this->truncateString($event['group_name']) . '*'], - ['type' => 'mrkdwn', 'text' => '<' . $event['url'] . '|*Link* :link:>'], - ['type' => 'mrkdwn', 'text' => '*Status*'], - ['type' => 'mrkdwn', 'text' => $this->printStatus($event['status'])], - ['type' => 'mrkdwn', 'text' => '*Location*'], - ['type' => 'mrkdwn', 'text' => $this->getLocationUrl($event['location'])], - ['type' => 'mrkdwn', 'text' => '*Time*'], - ['type' => 'plain_text', 'text' => $this->printDateTime($event['time'])], - ], - ], - ]; - } - - public function generateText(array $event): string - { - return sprintf( - "%s\nDescription: %s\nLink: %s\nStatus: %s\nLocation: %s\nTime: %s", - $this->truncateString($event['title']), - $this->truncateString($event['description']), - $event['url'], - $this->printStatus($event['status']), - $event['location'] ?? 'No location', - $this->printDateTime($event['time']) - ); - } } diff --git a/app-modules/slack-events-bot/src/Services/MessageBuilderService.php b/app-modules/slack-events-bot/src/Services/MessageBuilderService.php index c9650d63..359b4916 100644 --- a/app-modules/slack-events-bot/src/Services/MessageBuilderService.php +++ b/app-modules/slack-events-bot/src/Services/MessageBuilderService.php @@ -11,6 +11,47 @@ public function __construct(private EventService $eventService) { } + public function buildEventBlocks(array $events, Carbon $weekStart, Carbon $weekEnd): Collection + { + return collect($events) + ->map(fn ($event) => $this->buildSingleEventBlock($event, $weekStart, $weekEnd)) + ->filter() + ->values(); + } + + public function chunkMessages(Collection $eventBlocks, Carbon $weekStart): array + { + $messagesNeeded = $this->totalMessagesNeeded($eventBlocks); + $maxLength = config('slack-events-bot.max_message_character_length'); + + $messages = []; + $initialHeader = $this->buildHeader($weekStart, 1, $messagesNeeded); + + $blocks = $initialHeader['blocks']; + $text = $initialHeader['text']; + + foreach ($eventBlocks as $event) { + // Event can be safely added to existing message + if ($event['text_length'] + mb_strlen($text) < $maxLength) { + $blocks = array_merge($blocks, $event['blocks']); + $text .= $event['text']; + continue; + } + + // Save message and then start a new one + $messages[] = ['blocks' => $blocks, 'text' => $text]; + + $newHeader = $this->buildHeader($weekStart, count($messages) + 1, $messagesNeeded); + $blocks = array_merge($newHeader['blocks'], $event['blocks']); + $text = $newHeader['text'] . $event['text']; + } + + // Add whatever is left as a new message + $messages[] = ['blocks' => $blocks, 'text' => $text]; + + return $messages; + } + private function buildHeader(Carbon $weekStart, int $index, int $total): array { $text = sprintf( @@ -37,7 +78,7 @@ private function buildHeader(Carbon $weekStart, int $index, int $total): array ['type' => 'divider'], ], 'text' => $text, - 'text_length' => strlen($text), + 'text_length' => mb_strlen($text), ]; } @@ -51,7 +92,7 @@ private function buildSingleEventBlock(array $eventData, Carbon $weekStart, Carb } // Ignore event if it has a non-supported status - if (!in_array($event['status'], ['cancelled', 'upcoming', 'past'])) { + if ( ! in_array($event['status'], ['cancelled', 'upcoming', 'past'])) { logger()->warning("Couldn't parse event {$event['uuid']} with status: {$event['status']}"); return null; } @@ -61,60 +102,19 @@ private function buildSingleEventBlock(array $eventData, Carbon $weekStart, Carb return [ 'blocks' => array_merge($this->eventService->generateBlocks($event), [['type' => 'divider']]), 'text' => $text, - 'text_length' => strlen($text), + 'text_length' => mb_strlen($text), ]; } - public function buildEventBlocks(array $events, Carbon $weekStart, Carbon $weekEnd): Collection - { - return collect($events) - ->map(fn($event) => $this->buildSingleEventBlock($event, $weekStart, $weekEnd)) - ->filter() - ->values(); - } - private function totalMessagesNeeded(Collection $eventBlocks): int { $maxLength = config('slack-events-bot.max_message_character_length'); $headerBuffer = config('slack-events-bot.header_buffer_length'); - + $totalLength = $eventBlocks->sum('text_length'); $messagesNeeded = (int) ceil($totalLength / ($maxLength - $headerBuffer)); // Ensure total count is at least 1 if we're going to post anything return max(1, $messagesNeeded); } - - public function chunkMessages(Collection $eventBlocks, Carbon $weekStart): array - { - $messagesNeeded = $this->totalMessagesNeeded($eventBlocks); - $maxLength = config('slack-events-bot.max_message_character_length'); - - $messages = []; - $initialHeader = $this->buildHeader($weekStart, 1, $messagesNeeded); - - $blocks = $initialHeader['blocks']; - $text = $initialHeader['text']; - - foreach ($eventBlocks as $event) { - // Event can be safely added to existing message - if ($event['text_length'] + strlen($text) < $maxLength) { - $blocks = array_merge($blocks, $event['blocks']); - $text .= $event['text']; - continue; - } - - // Save message and then start a new one - $messages[] = ['blocks' => $blocks, 'text' => $text]; - - $newHeader = $this->buildHeader($weekStart, count($messages) + 1, $messagesNeeded); - $blocks = array_merge($newHeader['blocks'], $event['blocks']); - $text = $newHeader['text'] . $event['text']; - } - - // Add whatever is left as a new message - $messages[] = ['blocks' => $blocks, 'text' => $text]; - - return $messages; - } } diff --git a/app-modules/slack-events-bot/tests/SlackEventsBotTest.php b/app-modules/slack-events-bot/tests/SlackEventsBotTest.php index 75809fbc..e37acad9 100644 --- a/app-modules/slack-events-bot/tests/SlackEventsBotTest.php +++ b/app-modules/slack-events-bot/tests/SlackEventsBotTest.php @@ -20,7 +20,7 @@ class SlackEventsBotTest extends TestCase protected function setUp(): void { parent::setUp(); - + $this->databaseService = app(DatabaseService::class); $this->eventService = app(EventService::class); $this->messageBuilderService = app(MessageBuilderService::class); @@ -29,15 +29,15 @@ protected function setUp(): void public function test_can_add_and_remove_channel() { $channelId = 'C1234567890'; - + // Add channel $channel = $this->databaseService->addChannel($channelId); $this->assertDatabaseHas('slack_channels', ['slack_channel_id' => $channelId]); - + // Get channels $channels = $this->databaseService->getSlackChannelIds(); $this->assertTrue($channels->contains($channelId)); - + // Remove channel $this->databaseService->removeChannel($channelId); $this->assertDatabaseMissing('slack_channels', ['slack_channel_id' => $channelId]); @@ -47,14 +47,14 @@ public function test_can_create_and_get_messages() { $channelId = 'C1234567890'; $this->databaseService->addChannel($channelId); - + $week = Carbon::now()->startOfWeek(); $message = 'Test message content'; $timestamp = '1234567890.123456'; - + // Create message $this->databaseService->createMessage($week, $message, $timestamp, $channelId, 0); - + // Get messages $messages = $this->databaseService->getMessages($week); $this->assertCount(1, $messages); @@ -84,18 +84,18 @@ public function test_event_parsing() ]; $event = $this->eventService->createEventFromJson($eventData); - + $this->assertEquals('Test Event', $event['title']); $this->assertEquals('Test Group', $event['group_name']); $this->assertEquals('Test Venue at 123 Main St Greenville, SC 29601', $event['location']); $this->assertInstanceOf(Carbon::class, $event['time']); - + // Test block generation $blocks = $this->eventService->generateBlocks($event); $this->assertIsArray($blocks); $this->assertEquals('header', $blocks[0]['type']); $this->assertEquals('section', $blocks[1]['type']); - + // Test text generation $text = $this->eventService->generateText($event); $this->assertStringContainsString('Test Event', $text); @@ -106,14 +106,14 @@ public function test_cooldown_functionality() { $accessor = 'test-workspace'; $resource = 'check_api'; - + // No cooldown initially $expiry = $this->databaseService->getCooldownExpiryTime($accessor, $resource); $this->assertNull($expiry); - + // Create cooldown $this->databaseService->createCooldown($accessor, $resource, 15); - + // Check cooldown exists and is in future $expiry = $this->databaseService->getCooldownExpiryTime($accessor, $resource); $this->assertNotNull($expiry); @@ -124,36 +124,36 @@ public function test_message_chunking() { $weekStart = Carbon::now()->startOfWeek(); $weekEnd = $weekStart->copy()->addDays(7); - + // Create multiple events $events = []; for ($i = 0; $i < 10; $i++) { $events[] = [ - 'event_name' => "Event $i with a very long title that takes up space", - 'group_name' => "Group $i", + 'event_name' => "Event {$i} with a very long title that takes up space", + 'group_name' => "Group {$i}", 'description' => str_repeat("This is a long description. ", 10), - 'venue' => ['name' => "Venue $i"], + 'venue' => ['name' => "Venue {$i}"], 'time' => $weekStart->copy()->addDays($i % 7)->toIso8601String(), - 'url' => "https://example.com/event-$i", + 'url' => "https://example.com/event-{$i}", 'status' => 'upcoming', - 'uuid' => "uuid-$i", + 'uuid' => "uuid-{$i}", ]; } - + $eventBlocks = $this->messageBuilderService->buildEventBlocks($events, $weekStart, $weekEnd); $this->assertGreaterThan(0, $eventBlocks->count()); - + $chunkedMessages = $this->messageBuilderService->chunkMessages($eventBlocks, $weekStart); $this->assertIsArray($chunkedMessages); $this->assertGreaterThan(0, count($chunkedMessages)); - + // Verify each message has required structure foreach ($chunkedMessages as $message) { $this->assertArrayHasKey('blocks', $message); $this->assertArrayHasKey('text', $message); $this->assertLessThan( config('slack-events-bot.max_message_character_length'), - strlen($message['text']) + mb_strlen($message['text']) ); } } From 126203ccf99c79ac687427871f71908a8ab7dfa5 Mon Sep 17 00:00:00 2001 From: Bogdan Kharchenko Date: Thu, 22 May 2025 21:32:03 -0400 Subject: [PATCH 03/19] Simplify code --- .../src/Console/Commands/CheckApiCommand.php | 32 ------ .../src/Http/Controllers/SlackController.php | 2 +- .../SlackEventsBotServiceProvider.php | 15 +-- .../src/Services/BotService.php | 100 +++++++----------- .../src/Services/EventService.php | 100 +++--------------- .../src/Services/MessageBuilderService.php | 20 +--- 6 files changed, 62 insertions(+), 207 deletions(-) delete mode 100644 app-modules/slack-events-bot/src/Console/Commands/CheckApiCommand.php diff --git a/app-modules/slack-events-bot/src/Console/Commands/CheckApiCommand.php b/app-modules/slack-events-bot/src/Console/Commands/CheckApiCommand.php deleted file mode 100644 index 49cc9a2d..00000000 --- a/app-modules/slack-events-bot/src/Console/Commands/CheckApiCommand.php +++ /dev/null @@ -1,32 +0,0 @@ -info('Checking for events...'); - - try { - $this->botService->checkApi(); // Method name kept for backward compatibility - $this->info('Event check completed successfully!'); - return self::SUCCESS; - } catch (Exception $e) { - $this->error('Error checking events: ' . $e->getMessage()); - return self::FAILURE; - } - } -} diff --git a/app-modules/slack-events-bot/src/Http/Controllers/SlackController.php b/app-modules/slack-events-bot/src/Http/Controllers/SlackController.php index 3e78b926..5db08e21 100644 --- a/app-modules/slack-events-bot/src/Http/Controllers/SlackController.php +++ b/app-modules/slack-events-bot/src/Http/Controllers/SlackController.php @@ -151,7 +151,7 @@ private function handleSlashCommand(array $payload): Response // Run API check asynchronously dispatch(function () { - $this->botService->checkApi(); + $this->botService->handlePostingToSlack(); })->afterResponse(); return response('Checking api for events 👍'); diff --git a/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php b/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php index c6881dc1..260bfd14 100644 --- a/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php +++ b/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php @@ -2,7 +2,6 @@ namespace HackGreenville\SlackEventsBot\Providers; -use HackGreenville\SlackEventsBot\Console\Commands\CheckApiCommand; use HackGreenville\SlackEventsBot\Console\Commands\DeleteOldMessagesCommand; use HackGreenville\SlackEventsBot\Services\AuthService; use HackGreenville\SlackEventsBot\Services\BotService; @@ -17,7 +16,7 @@ class SlackEventsBotServiceProvider extends ServiceProvider public function register(): void { $this->mergeConfigFrom( - __DIR__ . '/../../config/slack-events-bot.php', + __DIR__.'/../../config/slack-events-bot.php', 'slack-events-bot' ); @@ -33,28 +32,24 @@ public function boot(): void { // Publish config $this->publishes([ - __DIR__ . '/../../config/slack-events-bot.php' => config_path('slack-events-bot.php'), + __DIR__.'/../../config/slack-events-bot.php' => config_path('slack-events-bot.php'), ], 'slack-events-bot-config'); // Load migrations - $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); + $this->loadMigrationsFrom(__DIR__.'/../../database/migrations'); // Load routes - $this->loadRoutesFrom(__DIR__ . '/../../routes/web.php'); + $this->loadRoutesFrom(__DIR__.'/../../routes/web.php'); // Register commands if ($this->app->runningInConsole()) { $this->commands([ - CheckApiCommand::class, DeleteOldMessagesCommand::class, ]); } // Schedule tasks - $this->callAfterResolving(Schedule::class, function (Schedule $schedule) { - // Check events every hour - $schedule->command('slack:check-events')->hourly(); - + $this->callAfterResolving(Schedule::class, function(Schedule $schedule) { // Delete old messages once daily $schedule->command('slack:delete-old-messages')->daily(); }); diff --git a/app-modules/slack-events-bot/src/Services/BotService.php b/app-modules/slack-events-bot/src/Services/BotService.php index b5457eaa..ce4040f3 100644 --- a/app-modules/slack-events-bot/src/Services/BotService.php +++ b/app-modules/slack-events-bot/src/Services/BotService.php @@ -12,10 +12,10 @@ class BotService { public function __construct( - private DatabaseService $databaseService, + private DatabaseService $databaseService, private MessageBuilderService $messageBuilderService, - private EventService $eventService - ) { + ) + { } public function postOrUpdateMessages(Carbon $week, array $messages): void @@ -27,7 +27,7 @@ public function postOrUpdateMessages(Carbon $week, array $messages): void $messageDetails = []; foreach ($existingMessages as $existingMessage) { $channelId = $existingMessage['slack_channel_id']; - if ( ! isset($messageDetails[$channelId])) { + if (!isset($messageDetails[$channelId])) { $messageDetails[$channelId] = []; } $messageDetails[$channelId][] = [ @@ -52,7 +52,7 @@ public function postOrUpdateMessages(Carbon $week, array $messages): void $week, $slackChannelId )) { - throw new UnsafeMessageSpilloverException; + throw new UnsafeMessageSpilloverException(); } Log::info("Posting an additional message for week {$week->format('F j')} in {$slackChannelId}"); @@ -66,26 +66,26 @@ public function postOrUpdateMessages(Carbon $week, array $messages): void $slackChannelId, $msgIdx ); - } elseif ( - in_array($slackChannelId, $postedChannelsSet) && - $msgText === $messageDetails[$slackChannelId][$msgIdx]['message'] + } else if ( + in_array($slackChannelId, $postedChannelsSet) + && $msgText === $messageDetails[$slackChannelId][$msgIdx]['message'] ) { Log::info( - "Message " . ($msgIdx + 1) . " for week of {$week->format('F j')} " . + "Message ".($msgIdx + 1)." for week of {$week->format('F j')} ". "in {$slackChannelId} hasn't changed, not updating" ); - } elseif (in_array($slackChannelId, $postedChannelsSet)) { + } else if (in_array($slackChannelId, $postedChannelsSet)) { if ($this->isUnsafeToSpillover( count($existingMessages), count($messages), $week, $slackChannelId )) { - throw new UnsafeMessageSpilloverException; + throw new UnsafeMessageSpilloverException(); } Log::info( - "Updating message " . ($msgIdx + 1) . " for week {$week->format('F j')} " . + "Updating message ".($msgIdx + 1)." for week {$week->format('F j')} ". "in {$slackChannelId}" ); @@ -102,7 +102,7 @@ public function postOrUpdateMessages(Carbon $week, array $messages): void $this->databaseService->updateMessage($week, $msgText, $timestamp, $slackChannelId); } else { Log::info( - "Posting message " . ($msgIdx + 1) . " for week {$week->format('F j')} " . + "Posting message ".($msgIdx + 1)." for week {$week->format('F j')} ". "in {$slackChannelId}" ); @@ -119,79 +119,51 @@ public function postOrUpdateMessages(Carbon $week, array $messages): void } } catch (UnsafeMessageSpilloverException $e) { Log::error( - "Cannot update messages for {$week->format('m/d/Y')} for channel {$slackChannelId}. " . - "New events have caused the number of messages needed to increase, " . - "but the next week's post has already been sent. Cannot resize. " . - "Existing message count: " . count($existingMessages) . " --- New message count: " . count($messages) + "Cannot update messages for {$week->format('m/d/Y')} for channel {$slackChannelId}. ". + "New events have caused the number of messages needed to increase, ". + "but the next week's post has already been sent. Cannot resize. ". + "Existing message count: ".count($existingMessages)." --- New message count: ".count($messages) ); continue; } } } - public function parseEventsForWeek(Carbon $probeDate, Collection $events): void + public function parseEventsForWeek(Collection $events): void { - $weekStart = $probeDate->copy()->startOfWeek()->subDay(); // Sunday - $weekEnd = $weekStart->copy()->addDays(7); - - // Convert Event models to array format expected by MessageBuilderService - $eventsArray = $events->map(fn (Event $event) => [ - 'event_name' => $event->event_name, - 'group_name' => $event->group_name, - 'description' => $event->description, - 'venue' => $event->venue ? [ - 'name' => $event->venue->name, - 'address' => $event->venue->address, - 'city' => $event->venue->city, - 'state' => $event->venue->state?->abbr, - 'zip' => $event->venue->zipcode, - 'lat' => $event->venue->lat, - 'lon' => $event->venue->lng, - ] : null, - 'time' => $event->active_at->toIso8601String(), - 'url' => $event->uri, - 'status' => $event->status, - 'uuid' => $event->event_uuid, - ])->toArray(); - - $eventBlocks = $this->messageBuilderService->buildEventBlocks($eventsArray, $weekStart, $weekEnd); + $eventBlocks = $this->messageBuilderService->buildEventBlocks($events); $chunkedMessages = $this->messageBuilderService->chunkMessages($eventBlocks, $weekStart); $this->postOrUpdateMessages($weekStart, $chunkedMessages); } - public function checkApi(): void + public function handlePostingToSlack(): void { - // Get events directly from the database instead of API - $events = Event::query() - ->with(['venue.state', 'organization']) - ->published() - ->where('active_at', '>=', now()->subDays(config('slack-events-bot.days_to_look_back', 1))) - ->where('active_at', '<=', now()->addDays(config('slack-events-bot.days_to_look_ahead', 14))) - ->oldest('active_at') - ->get(); - - // Get timezone aware today - $today = Carbon::today(); - // Keep current week's post up to date - $this->parseEventsForWeek($today, $events); - - // Potentially post next week 5 days early - $probeDate = $today->copy()->addDays(5); - $this->parseEventsForWeek($probeDate, $events); + $this->parseEventsForWeek( + events: Event::query() + ->with(['venue.state', 'organization']) + ->published() + ->whereBetween('active_at', [ + now()->copy()->startOfWeek(), + now()->copy()->addDays(7), + ]) + ->oldest('active_at') + ->get() + ); } private function isUnsafeToSpillover( - int $existingMessagesLength, - int $newMessagesLength, + int $existingMessagesLength, + int $newMessagesLength, Carbon $week, string $slackChannelId - ): bool { + ): bool + { if ($newMessagesLength > $existingMessagesLength && $existingMessagesLength > 0) { $latestMessage = $this->databaseService->getMostRecentMessageForChannel($slackChannelId); - if ( ! $latestMessage) { + if (!$latestMessage) { return false; } diff --git a/app-modules/slack-events-bot/src/Services/EventService.php b/app-modules/slack-events-bot/src/Services/EventService.php index f0249714..4c3e15cf 100644 --- a/app-modules/slack-events-bot/src/Services/EventService.php +++ b/app-modules/slack-events-bot/src/Services/EventService.php @@ -2,116 +2,53 @@ namespace HackGreenville\SlackEventsBot\Services; -use Carbon\Carbon; +use App\Models\Event; +use Illuminate\Support\Str; class EventService { - public function createEventFromJson(array $eventData): array - { - return [ - 'title' => $eventData['event_name'], - 'group_name' => $eventData['group_name'], - 'description' => $eventData['description'], - 'location' => $this->parseLocation($eventData), - 'time' => Carbon::parse($eventData['time']), - 'url' => $eventData['url'], - 'status' => $eventData['status'], - 'uuid' => $eventData['uuid'], - ]; - } - - public function generateBlocks(array $event): array + public function generateBlocks(Event $event): array { return [ [ 'type' => 'header', 'text' => [ 'type' => 'plain_text', - 'text' => $this->truncateString($event['title']), + 'text' => Str::limit($event->title, 250), ], ], [ 'type' => 'section', 'text' => [ 'type' => 'plain_text', - 'text' => $this->truncateString($event['description']), + 'text' => Str::limit($event->description, 250), ], 'fields' => [ - ['type' => 'mrkdwn', 'text' => '*' . $this->truncateString($event['group_name']) . '*'], - ['type' => 'mrkdwn', 'text' => '<' . $event['url'] . '|*Link* :link:>'], + ['type' => 'mrkdwn', 'text' => '*'.Str::limit($event->organization->title).'*', 250], + ['type' => 'mrkdwn', 'text' => '<'.$event->url.'|*Link* :link:>'], ['type' => 'mrkdwn', 'text' => '*Status*'], - ['type' => 'mrkdwn', 'text' => $this->printStatus($event['status'])], + ['type' => 'mrkdwn', 'text' => $this->printStatus($event->status)], ['type' => 'mrkdwn', 'text' => '*Location*'], - ['type' => 'mrkdwn', 'text' => $this->getLocationUrl($event['location'])], + ['type' => 'mrkdwn', 'text' => $event->venue ? $event->venue->fullAddress() : 'No location'], ['type' => 'mrkdwn', 'text' => '*Time*'], - ['type' => 'plain_text', 'text' => $this->printDateTime($event['time'])], + ['type' => 'plain_text', 'text' => $event->active_at->format('F j, Y g:i A T')], ], ], ]; } - public function generateText(array $event): string + public function generateText(Event $event): string { return sprintf( "%s\nDescription: %s\nLink: %s\nStatus: %s\nLocation: %s\nTime: %s", - $this->truncateString($event['title']), - $this->truncateString($event['description']), + Str::limit($event->title, 250), + Str::limit($event->description, 250), $event['url'], - $this->printStatus($event['status']), - $event['location'] ?? 'No location', - $this->printDateTime($event['time']) + $this->printStatus($event->status), + $event->venue?->name ?? 'No location', + $event->active_at->format('F j, Y g:i A T') ); } - private function parseLocation(array $eventData): ?string - { - $venue = $eventData['venue'] ?? null; - - if ( ! $venue) { - return null; - } - - // Full address available - if ( - isset($venue['name'], $venue['address'], $venue['city'], $venue['state'], $venue['zip']) && - ! in_array(null, [$venue['name'], $venue['address'], $venue['city'], $venue['state'], $venue['zip']], true) - ) { - return sprintf( - '%s at %s %s, %s %s', - $venue['name'], - $venue['address'], - $venue['city'], - $venue['state'], - $venue['zip'] - ); - } - - // Lat/long available - if (isset($venue['lat'], $venue['lon']) && $venue['lat'] !== null && $venue['lon'] !== null) { - return sprintf('lat/long: %s, %s', $venue['lat'], $venue['lon']); - } - - // Just the name - return $venue['name'] ?? null; - } - - private function truncateString(string $string, int $length = 250): string - { - if (mb_strlen($string) <= $length) { - return $string; - } - - return mb_substr($string, 0, $length) . '...'; - } - - private function getLocationUrl(?string $location): string - { - if ( ! $location) { - return 'No location'; - } - - $encodedLocation = urlencode($location); - return ""; - } private function printStatus(string $status): string { @@ -122,9 +59,4 @@ private function printStatus(string $status): string default => ucfirst($status), }; } - - private function printDateTime(Carbon $time): string - { - return $time->setTimezone(config('app.timezone'))->format('F j, Y g:i A T'); - } } diff --git a/app-modules/slack-events-bot/src/Services/MessageBuilderService.php b/app-modules/slack-events-bot/src/Services/MessageBuilderService.php index 359b4916..0ae69312 100644 --- a/app-modules/slack-events-bot/src/Services/MessageBuilderService.php +++ b/app-modules/slack-events-bot/src/Services/MessageBuilderService.php @@ -2,6 +2,7 @@ namespace HackGreenville\SlackEventsBot\Services; +use App\Models\Event; use Carbon\Carbon; use Illuminate\Support\Collection; @@ -11,10 +12,10 @@ public function __construct(private EventService $eventService) { } - public function buildEventBlocks(array $events, Carbon $weekStart, Carbon $weekEnd): Collection + public function buildEventBlocks(Collection $events): Collection { return collect($events) - ->map(fn ($event) => $this->buildSingleEventBlock($event, $weekStart, $weekEnd)) + ->map(fn (Event $event) => $this->buildSingleEventBlock($event)) ->filter() ->values(); } @@ -82,21 +83,8 @@ private function buildHeader(Carbon $weekStart, int $index, int $total): array ]; } - private function buildSingleEventBlock(array $eventData, Carbon $weekStart, Carbon $weekEnd): ?array + private function buildSingleEventBlock(Event $event): ?array { - $event = $this->eventService->createEventFromJson($eventData); - - // Ignore event if it's not in the current week - if ($event['time']->lt($weekStart) || $event['time']->gt($weekEnd)) { - return null; - } - - // Ignore event if it has a non-supported status - if ( ! in_array($event['status'], ['cancelled', 'upcoming', 'past'])) { - logger()->warning("Couldn't parse event {$event['uuid']} with status: {$event['status']}"); - return null; - } - $text = $this->eventService->generateText($event) . "\n\n"; return [ From eaa08f3fd685655c68a33bbd3f7fb60c9a8b8d5f Mon Sep 17 00:00:00 2001 From: Bogdan Kharchenko Date: Thu, 22 May 2025 21:32:10 -0400 Subject: [PATCH 04/19] pint --- .../SlackEventsBotServiceProvider.php | 10 +++--- .../src/Services/BotService.php | 32 +++++++++---------- .../src/Services/EventService.php | 4 +-- 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php b/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php index 260bfd14..ac3367e1 100644 --- a/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php +++ b/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php @@ -16,7 +16,7 @@ class SlackEventsBotServiceProvider extends ServiceProvider public function register(): void { $this->mergeConfigFrom( - __DIR__.'/../../config/slack-events-bot.php', + __DIR__ . '/../../config/slack-events-bot.php', 'slack-events-bot' ); @@ -32,14 +32,14 @@ public function boot(): void { // Publish config $this->publishes([ - __DIR__.'/../../config/slack-events-bot.php' => config_path('slack-events-bot.php'), + __DIR__ . '/../../config/slack-events-bot.php' => config_path('slack-events-bot.php'), ], 'slack-events-bot-config'); // Load migrations - $this->loadMigrationsFrom(__DIR__.'/../../database/migrations'); + $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); // Load routes - $this->loadRoutesFrom(__DIR__.'/../../routes/web.php'); + $this->loadRoutesFrom(__DIR__ . '/../../routes/web.php'); // Register commands if ($this->app->runningInConsole()) { @@ -49,7 +49,7 @@ public function boot(): void } // Schedule tasks - $this->callAfterResolving(Schedule::class, function(Schedule $schedule) { + $this->callAfterResolving(Schedule::class, function (Schedule $schedule) { // Delete old messages once daily $schedule->command('slack:delete-old-messages')->daily(); }); diff --git a/app-modules/slack-events-bot/src/Services/BotService.php b/app-modules/slack-events-bot/src/Services/BotService.php index ce4040f3..705a5b4c 100644 --- a/app-modules/slack-events-bot/src/Services/BotService.php +++ b/app-modules/slack-events-bot/src/Services/BotService.php @@ -14,8 +14,7 @@ class BotService public function __construct( private DatabaseService $databaseService, private MessageBuilderService $messageBuilderService, - ) - { + ) { } public function postOrUpdateMessages(Carbon $week, array $messages): void @@ -27,7 +26,7 @@ public function postOrUpdateMessages(Carbon $week, array $messages): void $messageDetails = []; foreach ($existingMessages as $existingMessage) { $channelId = $existingMessage['slack_channel_id']; - if (!isset($messageDetails[$channelId])) { + if ( ! isset($messageDetails[$channelId])) { $messageDetails[$channelId] = []; } $messageDetails[$channelId][] = [ @@ -52,7 +51,7 @@ public function postOrUpdateMessages(Carbon $week, array $messages): void $week, $slackChannelId )) { - throw new UnsafeMessageSpilloverException(); + throw new UnsafeMessageSpilloverException; } Log::info("Posting an additional message for week {$week->format('F j')} in {$slackChannelId}"); @@ -66,26 +65,26 @@ public function postOrUpdateMessages(Carbon $week, array $messages): void $slackChannelId, $msgIdx ); - } else if ( + } elseif ( in_array($slackChannelId, $postedChannelsSet) && $msgText === $messageDetails[$slackChannelId][$msgIdx]['message'] ) { Log::info( - "Message ".($msgIdx + 1)." for week of {$week->format('F j')} ". + "Message " . ($msgIdx + 1) . " for week of {$week->format('F j')} " . "in {$slackChannelId} hasn't changed, not updating" ); - } else if (in_array($slackChannelId, $postedChannelsSet)) { + } elseif (in_array($slackChannelId, $postedChannelsSet)) { if ($this->isUnsafeToSpillover( count($existingMessages), count($messages), $week, $slackChannelId )) { - throw new UnsafeMessageSpilloverException(); + throw new UnsafeMessageSpilloverException; } Log::info( - "Updating message ".($msgIdx + 1)." for week {$week->format('F j')} ". + "Updating message " . ($msgIdx + 1) . " for week {$week->format('F j')} " . "in {$slackChannelId}" ); @@ -102,7 +101,7 @@ public function postOrUpdateMessages(Carbon $week, array $messages): void $this->databaseService->updateMessage($week, $msgText, $timestamp, $slackChannelId); } else { Log::info( - "Posting message ".($msgIdx + 1)." for week {$week->format('F j')} ". + "Posting message " . ($msgIdx + 1) . " for week {$week->format('F j')} " . "in {$slackChannelId}" ); @@ -119,10 +118,10 @@ public function postOrUpdateMessages(Carbon $week, array $messages): void } } catch (UnsafeMessageSpilloverException $e) { Log::error( - "Cannot update messages for {$week->format('m/d/Y')} for channel {$slackChannelId}. ". - "New events have caused the number of messages needed to increase, ". - "but the next week's post has already been sent. Cannot resize. ". - "Existing message count: ".count($existingMessages)." --- New message count: ".count($messages) + "Cannot update messages for {$week->format('m/d/Y')} for channel {$slackChannelId}. " . + "New events have caused the number of messages needed to increase, " . + "but the next week's post has already been sent. Cannot resize. " . + "Existing message count: " . count($existingMessages) . " --- New message count: " . count($messages) ); continue; } @@ -158,12 +157,11 @@ private function isUnsafeToSpillover( int $newMessagesLength, Carbon $week, string $slackChannelId - ): bool - { + ): bool { if ($newMessagesLength > $existingMessagesLength && $existingMessagesLength > 0) { $latestMessage = $this->databaseService->getMostRecentMessageForChannel($slackChannelId); - if (!$latestMessage) { + if ( ! $latestMessage) { return false; } diff --git a/app-modules/slack-events-bot/src/Services/EventService.php b/app-modules/slack-events-bot/src/Services/EventService.php index 4c3e15cf..27e95610 100644 --- a/app-modules/slack-events-bot/src/Services/EventService.php +++ b/app-modules/slack-events-bot/src/Services/EventService.php @@ -24,8 +24,8 @@ public function generateBlocks(Event $event): array 'text' => Str::limit($event->description, 250), ], 'fields' => [ - ['type' => 'mrkdwn', 'text' => '*'.Str::limit($event->organization->title).'*', 250], - ['type' => 'mrkdwn', 'text' => '<'.$event->url.'|*Link* :link:>'], + ['type' => 'mrkdwn', 'text' => '*' . Str::limit($event->organization->title) . '*', 250], + ['type' => 'mrkdwn', 'text' => '<' . $event->url . '|*Link* :link:>'], ['type' => 'mrkdwn', 'text' => '*Status*'], ['type' => 'mrkdwn', 'text' => $this->printStatus($event->status)], ['type' => 'mrkdwn', 'text' => '*Location*'], From 2b6b56bb841e99f5e4b6119acf49227e346d6752 Mon Sep 17 00:00:00 2001 From: Bogdan Kharchenko Date: Thu, 22 May 2025 21:41:13 -0400 Subject: [PATCH 05/19] Delete .env.example --- app-modules/slack-events-bot/.env.example | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 app-modules/slack-events-bot/.env.example diff --git a/app-modules/slack-events-bot/.env.example b/app-modules/slack-events-bot/.env.example deleted file mode 100644 index b19f50b3..00000000 --- a/app-modules/slack-events-bot/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -# Slack Events Bot Configuration -SLACK_BOT_TOKEN=xoxb-your-bot-user-oauth-token -SLACK_SIGNING_SECRET=your-signing-secret-from-slack -SLACK_CLIENT_ID=your-slack-app-client-id -SLACK_CLIENT_SECRET=your-slack-app-client-secret From 9da7f5fc7d3198d8986242a34cfbcfe6e251fc29 Mon Sep 17 00:00:00 2001 From: oliviasculley Date: Sun, 6 Jul 2025 15:38:26 +0000 Subject: [PATCH 06/19] add slack bot manifest --- slackbot-manifest.json | 58 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 slackbot-manifest.json diff --git a/slackbot-manifest.json b/slackbot-manifest.json new file mode 100644 index 00000000..82a7b584 --- /dev/null +++ b/slackbot-manifest.json @@ -0,0 +1,58 @@ +{ + "display_information": { + "name": "HackGreenvilleAPIBot", + "description": "Allow Greenville APIs and tools to interact with the HackGreenville Slack.", + "background_color": "#006341", + "long_description": "Posts invite requests, contact form, and events from the HackGreenville site and local API / tools to HG Slack channels. See https://github.com/hackgvl and the slack-events-bot repository for more details." + }, + "features": { + "app_home": { + "home_tab_enabled": true, + "messages_tab_enabled": true, + "messages_tab_read_only_enabled": false + }, + "bot_user": { + "display_name": "HackGreenville APIs", + "always_online": true + }, + "slash_commands": [ + { + "command": "/add_channel", + "url": "https://hackgreenville.com/slack/events", + "description": "Posts the app in the Slack channel", + "should_escape": false + }, + { + "command": "/remove_channel", + "url": "https://hackgreenville.com/slack/events", + "description": "Removes the app from this slack channel", + "should_escape": false + }, + { + "command": "/check_api", + "url": "https://hackgreenville.com/slack/events", + "description": "Manually checks OpenData API for events", + "should_escape": false + } + ] + }, + "oauth_config": { + "redirect_urls": [ + "https://hackgreenville.com/slack/auth" + ], + "scopes": { + "bot": [ + "chat:write", + "chat:write.public", + "commands", + "incoming-webhook", + "users:read" + ] + } + }, + "settings": { + "org_deploy_enabled": false, + "socket_mode_enabled": false, + "token_rotation_enabled": false + } +} From 5d11872f73a0dfa02f40684aa2944a8616a269c7 Mon Sep 17 00:00:00 2001 From: oliviasculley Date: Sun, 6 Jul 2025 16:56:50 +0000 Subject: [PATCH 07/19] update composer --- composer.lock | 2875 ++++++++++++++++++------------------------------- 1 file changed, 1042 insertions(+), 1833 deletions(-) diff --git a/composer.lock b/composer.lock index bf879558..03689b40 100644 --- a/composer.lock +++ b/composer.lock @@ -6,877 +6,18 @@ ], "content-hash": "35577f87f88ec8269c29a0acb1f4ada5", "packages": [ - { - "name": "amphp/amp", - "version": "v3.0.2", - "source": { - "type": "git", - "url": "https://github.com/amphp/amp.git", - "reference": "138801fb68cfc9c329da8a7b39d01ce7291ee4b0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/amp/zipball/138801fb68cfc9c329da8a7b39d01ce7291ee4b0", - "reference": "138801fb68cfc9c329da8a7b39d01ce7291ee4b0", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "phpunit/phpunit": "^9", - "psalm/phar": "5.23.1" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php", - "src/Future/functions.php", - "src/Internal/functions.php" - ], - "psr-4": { - "Amp\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Bob Weinand", - "email": "bobwei9@hotmail.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - }, - { - "name": "Daniel Lowrey", - "email": "rdlowrey@php.net" - } - ], - "description": "A non-blocking concurrency framework for PHP applications.", - "homepage": "https://amphp.org/amp", - "keywords": [ - "async", - "asynchronous", - "awaitable", - "concurrency", - "event", - "event-loop", - "future", - "non-blocking", - "promise" - ], - "support": { - "issues": "https://github.com/amphp/amp/issues", - "source": "https://github.com/amphp/amp/tree/v3.0.2" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-05-10T21:37:46+00:00" - }, - { - "name": "amphp/byte-stream", - "version": "v2.1.1", - "source": { - "type": "git", - "url": "https://github.com/amphp/byte-stream.git", - "reference": "daa00f2efdbd71565bf64ffefa89e37542addf93" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/byte-stream/zipball/daa00f2efdbd71565bf64ffefa89e37542addf93", - "reference": "daa00f2efdbd71565bf64ffefa89e37542addf93", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/parser": "^1.1", - "amphp/pipeline": "^1", - "amphp/serialization": "^1", - "amphp/sync": "^2", - "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2.3" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "phpunit/phpunit": "^9", - "psalm/phar": "5.22.1" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php", - "src/Internal/functions.php" - ], - "psr-4": { - "Amp\\ByteStream\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "A stream abstraction to make working with non-blocking I/O simple.", - "homepage": "https://amphp.org/byte-stream", - "keywords": [ - "amp", - "amphp", - "async", - "io", - "non-blocking", - "stream" - ], - "support": { - "issues": "https://github.com/amphp/byte-stream/issues", - "source": "https://github.com/amphp/byte-stream/tree/v2.1.1" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-02-17T04:49:38+00:00" - }, - { - "name": "amphp/cache", - "version": "v2.0.1", - "source": { - "type": "git", - "url": "https://github.com/amphp/cache.git", - "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c", - "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/serialization": "^1", - "amphp/sync": "^2", - "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "phpunit/phpunit": "^9", - "psalm/phar": "^5.4" - }, - "type": "library", - "autoload": { - "psr-4": { - "Amp\\Cache\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - }, - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Daniel Lowrey", - "email": "rdlowrey@php.net" - } - ], - "description": "A fiber-aware cache API based on Amp and Revolt.", - "homepage": "https://amphp.org/cache", - "support": { - "issues": "https://github.com/amphp/cache/issues", - "source": "https://github.com/amphp/cache/tree/v2.0.1" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-04-19T03:38:06+00:00" - }, - { - "name": "amphp/dns", - "version": "v2.2.0", - "source": { - "type": "git", - "url": "https://github.com/amphp/dns.git", - "reference": "758266b0ea7470e2e42cd098493bc6d6c7100cf7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/dns/zipball/758266b0ea7470e2e42cd098493bc6d6c7100cf7", - "reference": "758266b0ea7470e2e42cd098493bc6d6c7100cf7", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/byte-stream": "^2", - "amphp/cache": "^2", - "amphp/parser": "^1", - "amphp/windows-registry": "^1.0.1", - "daverandom/libdns": "^2.0.2", - "ext-filter": "*", - "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "phpunit/phpunit": "^9", - "psalm/phar": "5.20" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Amp\\Dns\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Chris Wright", - "email": "addr@daverandom.com" - }, - { - "name": "Daniel Lowrey", - "email": "rdlowrey@php.net" - }, - { - "name": "Bob Weinand", - "email": "bobwei9@hotmail.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - }, - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - } - ], - "description": "Async DNS resolution for Amp.", - "homepage": "https://github.com/amphp/dns", - "keywords": [ - "amp", - "amphp", - "async", - "client", - "dns", - "resolve" - ], - "support": { - "issues": "https://github.com/amphp/dns/issues", - "source": "https://github.com/amphp/dns/tree/v2.2.0" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-06-02T19:54:12+00:00" - }, - { - "name": "amphp/parallel", - "version": "v2.3.0", - "source": { - "type": "git", - "url": "https://github.com/amphp/parallel.git", - "reference": "9777db1460d1535bc2a843840684fb1205225b87" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/parallel/zipball/9777db1460d1535bc2a843840684fb1205225b87", - "reference": "9777db1460d1535bc2a843840684fb1205225b87", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/byte-stream": "^2", - "amphp/cache": "^2", - "amphp/parser": "^1", - "amphp/pipeline": "^1", - "amphp/process": "^2", - "amphp/serialization": "^1", - "amphp/socket": "^2", - "amphp/sync": "^2", - "php": ">=8.1", - "revolt/event-loop": "^1" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "phpunit/phpunit": "^9", - "psalm/phar": "^5.18" - }, - "type": "library", - "autoload": { - "files": [ - "src/Context/functions.php", - "src/Context/Internal/functions.php", - "src/Ipc/functions.php", - "src/Worker/functions.php" - ], - "psr-4": { - "Amp\\Parallel\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - }, - { - "name": "Stephen Coakley", - "email": "me@stephencoakley.com" - } - ], - "description": "Parallel processing component for Amp.", - "homepage": "https://github.com/amphp/parallel", - "keywords": [ - "async", - "asynchronous", - "concurrent", - "multi-processing", - "multi-threading" - ], - "support": { - "issues": "https://github.com/amphp/parallel/issues", - "source": "https://github.com/amphp/parallel/tree/v2.3.0" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-09-14T19:16:14+00:00" - }, - { - "name": "amphp/parser", - "version": "v1.1.1", - "source": { - "type": "git", - "url": "https://github.com/amphp/parser.git", - "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7", - "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7", - "shasum": "" - }, - "require": { - "php": ">=7.4" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "phpunit/phpunit": "^9", - "psalm/phar": "^5.4" - }, - "type": "library", - "autoload": { - "psr-4": { - "Amp\\Parser\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "A generator parser to make streaming parsers simple.", - "homepage": "https://github.com/amphp/parser", - "keywords": [ - "async", - "non-blocking", - "parser", - "stream" - ], - "support": { - "issues": "https://github.com/amphp/parser/issues", - "source": "https://github.com/amphp/parser/tree/v1.1.1" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-03-21T19:16:53+00:00" - }, - { - "name": "amphp/pipeline", - "version": "v1.2.1", - "source": { - "type": "git", - "url": "https://github.com/amphp/pipeline.git", - "reference": "66c095673aa5b6e689e63b52d19e577459129ab3" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/pipeline/zipball/66c095673aa5b6e689e63b52d19e577459129ab3", - "reference": "66c095673aa5b6e689e63b52d19e577459129ab3", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "php": ">=8.1", - "revolt/event-loop": "^1" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "phpunit/phpunit": "^9", - "psalm/phar": "^5.18" - }, - "type": "library", - "autoload": { - "psr-4": { - "Amp\\Pipeline\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "Asynchronous iterators and operators.", - "homepage": "https://amphp.org/pipeline", - "keywords": [ - "amp", - "amphp", - "async", - "io", - "iterator", - "non-blocking" - ], - "support": { - "issues": "https://github.com/amphp/pipeline/issues", - "source": "https://github.com/amphp/pipeline/tree/v1.2.1" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-07-04T00:56:47+00:00" - }, - { - "name": "amphp/process", - "version": "v2.0.3", - "source": { - "type": "git", - "url": "https://github.com/amphp/process.git", - "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/process/zipball/52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", - "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/byte-stream": "^2", - "amphp/sync": "^2", - "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "phpunit/phpunit": "^9", - "psalm/phar": "^5.4" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Amp\\Process\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bob Weinand", - "email": "bobwei9@hotmail.com" - }, - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "A fiber-aware process manager based on Amp and Revolt.", - "homepage": "https://amphp.org/process", - "support": { - "issues": "https://github.com/amphp/process/issues", - "source": "https://github.com/amphp/process/tree/v2.0.3" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-04-19T03:13:44+00:00" - }, - { - "name": "amphp/serialization", - "version": "v1.0.0", - "source": { - "type": "git", - "url": "https://github.com/amphp/serialization.git", - "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/serialization/zipball/693e77b2fb0b266c3c7d622317f881de44ae94a1", - "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "dev-master", - "phpunit/phpunit": "^9 || ^8 || ^7" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Amp\\Serialization\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "Serialization tools for IPC and data storage in PHP.", - "homepage": "https://github.com/amphp/serialization", - "keywords": [ - "async", - "asynchronous", - "serialization", - "serialize" - ], - "support": { - "issues": "https://github.com/amphp/serialization/issues", - "source": "https://github.com/amphp/serialization/tree/master" - }, - "time": "2020-03-25T21:39:07+00:00" - }, - { - "name": "amphp/socket", - "version": "v2.3.1", - "source": { - "type": "git", - "url": "https://github.com/amphp/socket.git", - "reference": "58e0422221825b79681b72c50c47a930be7bf1e1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/socket/zipball/58e0422221825b79681b72c50c47a930be7bf1e1", - "reference": "58e0422221825b79681b72c50c47a930be7bf1e1", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/byte-stream": "^2", - "amphp/dns": "^2", - "ext-openssl": "*", - "kelunik/certificate": "^1.1", - "league/uri": "^6.5 | ^7", - "league/uri-interfaces": "^2.3 | ^7", - "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "amphp/process": "^2", - "phpunit/phpunit": "^9", - "psalm/phar": "5.20" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php", - "src/Internal/functions.php", - "src/SocketAddress/functions.php" - ], - "psr-4": { - "Amp\\Socket\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Daniel Lowrey", - "email": "rdlowrey@gmail.com" - }, - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.", - "homepage": "https://github.com/amphp/socket", - "keywords": [ - "amp", - "async", - "encryption", - "non-blocking", - "sockets", - "tcp", - "tls" - ], - "support": { - "issues": "https://github.com/amphp/socket/issues", - "source": "https://github.com/amphp/socket/tree/v2.3.1" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-04-21T14:33:03+00:00" - }, - { - "name": "amphp/sync", - "version": "v2.3.0", - "source": { - "type": "git", - "url": "https://github.com/amphp/sync.git", - "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1", - "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/pipeline": "^1", - "amphp/serialization": "^1", - "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "phpunit/phpunit": "^9", - "psalm/phar": "5.23" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Amp\\Sync\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - }, - { - "name": "Stephen Coakley", - "email": "me@stephencoakley.com" - } - ], - "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.", - "homepage": "https://github.com/amphp/sync", - "keywords": [ - "async", - "asynchronous", - "mutex", - "semaphore", - "synchronization" - ], - "support": { - "issues": "https://github.com/amphp/sync/issues", - "source": "https://github.com/amphp/sync/tree/v2.3.0" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-08-03T19:31:26+00:00" - }, - { - "name": "amphp/windows-registry", - "version": "v1.0.1", - "source": { - "type": "git", - "url": "https://github.com/amphp/windows-registry.git", - "reference": "0d569e8f256cca974e3842b6e78b4e434bf98306" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/windows-registry/zipball/0d569e8f256cca974e3842b6e78b4e434bf98306", - "reference": "0d569e8f256cca974e3842b6e78b4e434bf98306", - "shasum": "" - }, - "require": { - "amphp/byte-stream": "^2", - "amphp/process": "^2", - "php": ">=8.1" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "psalm/phar": "^5.4" - }, - "type": "library", - "autoload": { - "psr-4": { - "Amp\\WindowsRegistry\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "Windows Registry Reader.", - "support": { - "issues": "https://github.com/amphp/windows-registry/issues", - "source": "https://github.com/amphp/windows-registry/tree/v1.0.1" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-01-30T23:01:51+00:00" - }, { "name": "anourvalar/eloquent-serialize", - "version": "1.3.3", + "version": "1.3.4", "source": { "type": "git", "url": "https://github.com/AnourValar/eloquent-serialize.git", - "reference": "2f05023f1e465a91dc4f08483e6710325641a444" + "reference": "0934a98866e02b73e38696961a9d7984b834c9d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/AnourValar/eloquent-serialize/zipball/2f05023f1e465a91dc4f08483e6710325641a444", - "reference": "2f05023f1e465a91dc4f08483e6710325641a444", + "url": "https://api.github.com/repos/AnourValar/eloquent-serialize/zipball/0934a98866e02b73e38696961a9d7984b834c9d9", + "reference": "0934a98866e02b73e38696961a9d7984b834c9d9", "shasum": "" }, "require": { @@ -927,9 +68,9 @@ ], "support": { "issues": "https://github.com/AnourValar/eloquent-serialize/issues", - "source": "https://github.com/AnourValar/eloquent-serialize/tree/1.3.3" + "source": "https://github.com/AnourValar/eloquent-serialize/tree/1.3.4" }, - "time": "2025-05-28T17:07:28+00:00" + "time": "2025-07-30T15:45:57+00:00" }, { "name": "blade-ui-kit/blade-heroicons", @@ -1212,16 +353,16 @@ }, { "name": "composer/ca-bundle", - "version": "1.5.3", + "version": "1.5.8", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "3b1fc3f0be055baa7c6258b1467849c3e8204eb2" + "reference": "719026bb30813accb68271fee7e39552a58e9f65" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/3b1fc3f0be055baa7c6258b1467849c3e8204eb2", - "reference": "3b1fc3f0be055baa7c6258b1467849c3e8204eb2", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/719026bb30813accb68271fee7e39552a58e9f65", + "reference": "719026bb30813accb68271fee7e39552a58e9f65", "shasum": "" }, "require": { @@ -1268,7 +409,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.5.3" + "source": "https://github.com/composer/ca-bundle/tree/1.5.8" }, "funding": [ { @@ -1278,26 +419,22 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2024-11-04T10:15:26+00:00" + "time": "2025-08-20T18:49:47+00:00" }, { "name": "composer/class-map-generator", - "version": "1.4.0", + "version": "1.6.2", "source": { "type": "git", "url": "https://github.com/composer/class-map-generator.git", - "reference": "98bbf6780e56e0fd2404fe4b82eb665a0f93b783" + "reference": "ba9f089655d4cdd64e762a6044f411ccdaec0076" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/class-map-generator/zipball/98bbf6780e56e0fd2404fe4b82eb665a0f93b783", - "reference": "98bbf6780e56e0fd2404fe4b82eb665a0f93b783", + "url": "https://api.github.com/repos/composer/class-map-generator/zipball/ba9f089655d4cdd64e762a6044f411ccdaec0076", + "reference": "ba9f089655d4cdd64e762a6044f411ccdaec0076", "shasum": "" }, "require": { @@ -1306,10 +443,10 @@ "symfony/finder": "^4.4 || ^5.3 || ^6 || ^7" }, "require-dev": { - "phpstan/phpstan": "^1.6", - "phpstan/phpstan-deprecation-rules": "^1", - "phpstan/phpstan-phpunit": "^1", - "phpstan/phpstan-strict-rules": "^1.1", + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-deprecation-rules": "^1 || ^2", + "phpstan/phpstan-phpunit": "^1 || ^2", + "phpstan/phpstan-strict-rules": "^1.1 || ^2", "phpunit/phpunit": "^8", "symfony/filesystem": "^5.4 || ^6" }, @@ -1341,7 +478,7 @@ ], "support": { "issues": "https://github.com/composer/class-map-generator/issues", - "source": "https://github.com/composer/class-map-generator/tree/1.4.0" + "source": "https://github.com/composer/class-map-generator/tree/1.6.2" }, "funding": [ { @@ -1351,26 +488,22 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2024-10-03T18:14:00+00:00" + "time": "2025-08-20T18:52:43+00:00" }, { "name": "composer/composer", - "version": "2.8.2", + "version": "2.8.12", "source": { "type": "git", "url": "https://github.com/composer/composer.git", - "reference": "6e543d03187c882ea1c6ba43add2467754427803" + "reference": "3e38919bc9a2c3c026f2151b5e56d04084ce8f0b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/6e543d03187c882ea1c6ba43add2467754427803", - "reference": "6e543d03187c882ea1c6ba43add2467754427803", + "url": "https://api.github.com/repos/composer/composer/zipball/3e38919bc9a2c3c026f2151b5e56d04084ce8f0b", + "reference": "3e38919bc9a2c3c026f2151b5e56d04084ce8f0b", "shasum": "" }, "require": { @@ -1381,20 +514,20 @@ "composer/semver": "^3.3", "composer/spdx-licenses": "^1.5.7", "composer/xdebug-handler": "^2.0.2 || ^3.0.3", - "justinrainbow/json-schema": "^5.3", + "justinrainbow/json-schema": "^6.5.1", "php": "^7.2.5 || ^8.0", "psr/log": "^1.0 || ^2.0 || ^3.0", - "react/promise": "^3.2", + "react/promise": "^3.3", "seld/jsonlint": "^1.4", "seld/phar-utils": "^1.2", "seld/signal-handler": "^2.0", - "symfony/console": "^5.4.35 || ^6.3.12 || ^7.0.3", - "symfony/filesystem": "^5.4.35 || ^6.3.12 || ^7.0.3", - "symfony/finder": "^5.4.35 || ^6.3.12 || ^7.0.3", + "symfony/console": "^5.4.47 || ^6.4.25 || ^7.1.10", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.1.10", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.1.10", "symfony/polyfill-php73": "^1.24", "symfony/polyfill-php80": "^1.24", "symfony/polyfill-php81": "^1.24", - "symfony/process": "^5.4.35 || ^6.3.12 || ^7.0.3" + "symfony/process": "^5.4.47 || ^6.4.25 || ^7.1.10" }, "require-dev": { "phpstan/phpstan": "^1.11.8", @@ -1402,7 +535,7 @@ "phpstan/phpstan-phpunit": "^1.4.0", "phpstan/phpstan-strict-rules": "^1.6.0", "phpstan/phpstan-symfony": "^1.4.0", - "symfony/phpunit-bridge": "^6.4.3 || ^7.0.1" + "symfony/phpunit-bridge": "^6.4.25 || ^7.3.3" }, "suggest": { "ext-openssl": "Enabling the openssl extension allows you to access https URLs for repositories and packages", @@ -1414,13 +547,13 @@ ], "type": "library", "extra": { - "branch-alias": { - "dev-main": "2.8-dev" - }, "phpstan": { "includes": [ "phpstan/rules.neon" ] + }, + "branch-alias": { + "dev-main": "2.8-dev" } }, "autoload": { @@ -1455,7 +588,7 @@ "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/composer/issues", "security": "https://github.com/composer/composer/security/policy", - "source": "https://github.com/composer/composer/tree/2.8.2" + "source": "https://github.com/composer/composer/tree/2.8.12" }, "funding": [ { @@ -1465,13 +598,9 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2024-10-29T15:12:11+00:00" + "time": "2025-09-19T11:41:59+00:00" }, { "name": "composer/metadata-minifier", @@ -1544,16 +673,16 @@ }, { "name": "composer/pcre", - "version": "3.3.1", + "version": "3.3.2", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "63aaeac21d7e775ff9bc9d45021e1745c97521c4" + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/63aaeac21d7e775ff9bc9d45021e1745c97521c4", - "reference": "63aaeac21d7e775ff9bc9d45021e1745c97521c4", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", "shasum": "" }, "require": { @@ -1563,19 +692,19 @@ "phpstan/phpstan": "<1.11.10" }, "require-dev": { - "phpstan/phpstan": "^1.11.10", - "phpstan/phpstan-strict-rules": "^1.1", + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", "phpunit/phpunit": "^8 || ^9" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.x-dev" - }, "phpstan": { "includes": [ "extension.neon" ] + }, + "branch-alias": { + "dev-main": "3.x-dev" } }, "autoload": { @@ -1603,7 +732,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.1" + "source": "https://github.com/composer/pcre/tree/3.3.2" }, "funding": [ { @@ -1619,20 +748,20 @@ "type": "tidelift" } ], - "time": "2024-08-27T18:44:43+00:00" + "time": "2024-11-12T16:29:46+00:00" }, { "name": "composer/semver", - "version": "3.4.3", + "version": "3.4.4", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12" + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", - "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", "shasum": "" }, "require": { @@ -1684,7 +813,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.3" + "source": "https://github.com/composer/semver/tree/3.4.4" }, "funding": [ { @@ -1694,34 +823,30 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2024-09-19T14:15:21+00:00" + "time": "2025-08-20T19:15:30+00:00" }, { "name": "composer/spdx-licenses", - "version": "1.5.8", + "version": "1.5.9", "source": { "type": "git", "url": "https://github.com/composer/spdx-licenses.git", - "reference": "560bdcf8deb88ae5d611c80a2de8ea9d0358cc0a" + "reference": "edf364cefe8c43501e21e88110aac10b284c3c9f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/560bdcf8deb88ae5d611c80a2de8ea9d0358cc0a", - "reference": "560bdcf8deb88ae5d611c80a2de8ea9d0358cc0a", + "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/edf364cefe8c43501e21e88110aac10b284c3c9f", + "reference": "edf364cefe8c43501e21e88110aac10b284c3c9f", "shasum": "" }, "require": { "php": "^5.3.2 || ^7.0 || ^8.0" }, "require-dev": { - "phpstan/phpstan": "^0.12.55", - "symfony/phpunit-bridge": "^4.2 || ^5" + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" }, "type": "library", "extra": { @@ -1764,7 +889,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/spdx-licenses/issues", - "source": "https://github.com/composer/spdx-licenses/tree/1.5.8" + "source": "https://github.com/composer/spdx-licenses/tree/1.5.9" }, "funding": [ { @@ -1780,7 +905,7 @@ "type": "tidelift" } ], - "time": "2023-11-20T07:44:33+00:00" + "time": "2025-05-12T21:07:07+00:00" }, { "name": "composer/xdebug-handler", @@ -1917,85 +1042,41 @@ "illuminate/support": "^9.0|^10.0|^11.0|^12.0", "php": "^8.0" }, - "require-dev": { - "livewire/livewire": "^3.0", - "livewire/volt": "^1.3", - "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", - "phpunit/phpunit": "^9.0|^10.0|^11.5.3" - }, - "type": "library", - "autoload": { - "psr-4": { - "DanHarrin\\LivewireRateLimiting\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Dan Harrin", - "email": "dan@danharrin.com" - } - ], - "description": "Apply rate limiters to Laravel Livewire actions.", - "homepage": "https://github.com/danharrin/livewire-rate-limiting", - "support": { - "issues": "https://github.com/danharrin/livewire-rate-limiting/issues", - "source": "https://github.com/danharrin/livewire-rate-limiting" - }, - "funding": [ - { - "url": "https://github.com/danharrin", - "type": "github" - } - ], - "time": "2025-02-21T08:52:11+00:00" - }, - { - "name": "daverandom/libdns", - "version": "v2.1.0", - "source": { - "type": "git", - "url": "https://github.com/DaveRandom/LibDNS.git", - "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", - "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "php": ">=7.1" - }, - "suggest": { - "ext-intl": "Required for IDN support" + "require-dev": { + "livewire/livewire": "^3.0", + "livewire/volt": "^1.3", + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", + "phpunit/phpunit": "^9.0|^10.0|^11.5.3" }, "type": "library", "autoload": { - "files": [ - "src/functions.php" - ], "psr-4": { - "LibDNS\\": "src/" + "DanHarrin\\LivewireRateLimiting\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "DNS protocol implementation written in pure PHP", - "keywords": [ - "dns" + "authors": [ + { + "name": "Dan Harrin", + "email": "dan@danharrin.com" + } ], + "description": "Apply rate limiters to Laravel Livewire actions.", + "homepage": "https://github.com/danharrin/livewire-rate-limiting", "support": { - "issues": "https://github.com/DaveRandom/LibDNS/issues", - "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" + "issues": "https://github.com/danharrin/livewire-rate-limiting/issues", + "source": "https://github.com/danharrin/livewire-rate-limiting" }, - "time": "2024-04-12T12:12:48+00:00" + "funding": [ + { + "url": "https://github.com/danharrin", + "type": "github" + } + ], + "time": "2025-02-21T08:52:11+00:00" }, { "name": "dflydev/dot-access-data", @@ -2074,16 +1155,16 @@ }, { "name": "doctrine/dbal", - "version": "3.10.0", + "version": "3.10.3", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "1cf840d696373ea0d58ad0a8875c0fadcfc67214" + "reference": "65edaca19a752730f290ec2fb89d593cb40afb43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/1cf840d696373ea0d58ad0a8875c0fadcfc67214", - "reference": "1cf840d696373ea0d58ad0a8875c0fadcfc67214", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/65edaca19a752730f290ec2fb89d593cb40afb43", + "reference": "65edaca19a752730f290ec2fb89d593cb40afb43", "shasum": "" }, "require": { @@ -2099,14 +1180,14 @@ }, "require-dev": { "doctrine/cache": "^1.11|^2.0", - "doctrine/coding-standard": "13.0.0", + "doctrine/coding-standard": "14.0.0", "fig/log-test": "^1", "jetbrains/phpstorm-stubs": "2023.1", - "phpstan/phpstan": "2.1.17", + "phpstan/phpstan": "2.1.30", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "9.6.23", - "slevomat/coding-standard": "8.16.2", - "squizlabs/php_codesniffer": "3.13.1", + "phpunit/phpunit": "9.6.29", + "slevomat/coding-standard": "8.24.0", + "squizlabs/php_codesniffer": "4.0.0", "symfony/cache": "^5.4|^6.0|^7.0", "symfony/console": "^4.4|^5.4|^6.0|^7.0" }, @@ -2168,7 +1249,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.10.0" + "source": "https://github.com/doctrine/dbal/tree/3.10.3" }, "funding": [ { @@ -2184,7 +1265,7 @@ "type": "tidelift" } ], - "time": "2025-07-10T21:11:04+00:00" + "time": "2025-10-09T09:05:12+00:00" }, { "name": "doctrine/deprecations", @@ -2327,33 +1408,32 @@ }, { "name": "doctrine/inflector", - "version": "2.0.10", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/inflector.git", - "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc" + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/inflector/zipball/5817d0659c5b50c9b950feb9af7b9668e2c436bc", - "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", "shasum": "" }, "require": { "php": "^7.2 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^11.0", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.3", - "phpunit/phpunit": "^8.5 || ^9.5", - "vimeo/psalm": "^4.25 || ^5.4" + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" }, "type": "library", "autoload": { "psr-4": { - "Doctrine\\Inflector\\": "lib/Doctrine/Inflector" + "Doctrine\\Inflector\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -2398,7 +1478,7 @@ ], "support": { "issues": "https://github.com/doctrine/inflector/issues", - "source": "https://github.com/doctrine/inflector/tree/2.0.10" + "source": "https://github.com/doctrine/inflector/tree/2.1.0" }, "funding": [ { @@ -2414,7 +1494,7 @@ "type": "tidelift" } ], - "time": "2024-02-18T20:23:39+00:00" + "time": "2025-08-10T19:31:58+00:00" }, { "name": "doctrine/lexer", @@ -2740,16 +1820,16 @@ }, { "name": "filament/actions", - "version": "v3.3.33", + "version": "v3.3.43", "source": { "type": "git", "url": "https://github.com/filamentphp/actions.git", - "reference": "9eaddc610d9adc00d738b8b116cea1be35a88f85" + "reference": "4582f2da9ed0660685b8e0849d32f106bc8a4b2d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/actions/zipball/9eaddc610d9adc00d738b8b116cea1be35a88f85", - "reference": "9eaddc610d9adc00d738b8b116cea1be35a88f85", + "url": "https://api.github.com/repos/filamentphp/actions/zipball/4582f2da9ed0660685b8e0849d32f106bc8a4b2d", + "reference": "4582f2da9ed0660685b8e0849d32f106bc8a4b2d", "shasum": "" }, "require": { @@ -2789,20 +1869,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-07-16T08:51:11+00:00" + "time": "2025-09-28T22:06:00+00:00" }, { "name": "filament/filament", - "version": "v3.3.33", + "version": "v3.3.43", "source": { "type": "git", "url": "https://github.com/filamentphp/panels.git", - "reference": "8e6618036c9235d968740d43bb8afb58fe705e5b" + "reference": "f61544ea879633e42e2ae8a2eafe034aecdad2b2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/panels/zipball/8e6618036c9235d968740d43bb8afb58fe705e5b", - "reference": "8e6618036c9235d968740d43bb8afb58fe705e5b", + "url": "https://api.github.com/repos/filamentphp/panels/zipball/f61544ea879633e42e2ae8a2eafe034aecdad2b2", + "reference": "f61544ea879633e42e2ae8a2eafe034aecdad2b2", "shasum": "" }, "require": { @@ -2854,20 +1934,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-07-21T10:08:08+00:00" + "time": "2025-09-28T22:06:09+00:00" }, { "name": "filament/forms", - "version": "v3.3.33", + "version": "v3.3.43", "source": { "type": "git", "url": "https://github.com/filamentphp/forms.git", - "reference": "72ec2ede65d8e9fa979a066bce78812458793dde" + "reference": "da5401bf3684b6abc6cf1d8e152f01b25d815319" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/forms/zipball/72ec2ede65d8e9fa979a066bce78812458793dde", - "reference": "72ec2ede65d8e9fa979a066bce78812458793dde", + "url": "https://api.github.com/repos/filamentphp/forms/zipball/da5401bf3684b6abc6cf1d8e152f01b25d815319", + "reference": "da5401bf3684b6abc6cf1d8e152f01b25d815319", "shasum": "" }, "require": { @@ -2910,20 +1990,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-07-21T10:07:59+00:00" + "time": "2025-10-06T21:42:10+00:00" }, { "name": "filament/infolists", - "version": "v3.3.33", + "version": "v3.3.43", "source": { "type": "git", "url": "https://github.com/filamentphp/infolists.git", - "reference": "89a3f1f236863e2035be3d7b0c68987508dd06fa" + "reference": "4533c2ccb6ef06ab7f27d81e27be0cdd4f5e72de" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/infolists/zipball/89a3f1f236863e2035be3d7b0c68987508dd06fa", - "reference": "89a3f1f236863e2035be3d7b0c68987508dd06fa", + "url": "https://api.github.com/repos/filamentphp/infolists/zipball/4533c2ccb6ef06ab7f27d81e27be0cdd4f5e72de", + "reference": "4533c2ccb6ef06ab7f27d81e27be0cdd4f5e72de", "shasum": "" }, "require": { @@ -2961,11 +2041,11 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-06-23T10:46:53+00:00" + "time": "2025-08-12T13:15:27+00:00" }, { "name": "filament/notifications", - "version": "v3.3.33", + "version": "v3.3.43", "source": { "type": "git", "url": "https://github.com/filamentphp/notifications.git", @@ -3017,16 +2097,16 @@ }, { "name": "filament/support", - "version": "v3.3.33", + "version": "v3.3.43", "source": { "type": "git", "url": "https://github.com/filamentphp/support.git", - "reference": "0bf4856840e160624ba79f43aaaa3ec396140f1d" + "reference": "afafd5e7a2f8cf052f70f989b52d82d0a1df5c78" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/support/zipball/0bf4856840e160624ba79f43aaaa3ec396140f1d", - "reference": "0bf4856840e160624ba79f43aaaa3ec396140f1d", + "url": "https://api.github.com/repos/filamentphp/support/zipball/afafd5e7a2f8cf052f70f989b52d82d0a1df5c78", + "reference": "afafd5e7a2f8cf052f70f989b52d82d0a1df5c78", "shasum": "" }, "require": { @@ -3072,20 +2152,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-07-16T08:51:22+00:00" + "time": "2025-08-12T13:15:44+00:00" }, { "name": "filament/tables", - "version": "v3.3.33", + "version": "v3.3.43", "source": { "type": "git", "url": "https://github.com/filamentphp/tables.git", - "reference": "3f0d827c960f1ee4a67ab71c416ad67e24747dc4" + "reference": "2e1e3aeeeccd6b74e5d038325af52635d1108e4c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/tables/zipball/3f0d827c960f1ee4a67ab71c416ad67e24747dc4", - "reference": "3f0d827c960f1ee4a67ab71c416ad67e24747dc4", + "url": "https://api.github.com/repos/filamentphp/tables/zipball/2e1e3aeeeccd6b74e5d038325af52635d1108e4c", + "reference": "2e1e3aeeeccd6b74e5d038325af52635d1108e4c", "shasum": "" }, "require": { @@ -3124,11 +2204,11 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2025-07-08T20:42:18+00:00" + "time": "2025-09-17T10:47:13+00:00" }, { "name": "filament/widgets", - "version": "v3.3.33", + "version": "v3.3.43", "source": { "type": "git", "url": "https://github.com/filamentphp/widgets.git", @@ -3172,16 +2252,16 @@ }, { "name": "filp/whoops", - "version": "2.16.0", + "version": "2.18.4", "source": { "type": "git", "url": "https://github.com/filp/whoops.git", - "reference": "befcdc0e5dce67252aa6322d82424be928214fa2" + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/befcdc0e5dce67252aa6322d82424be928214fa2", - "reference": "befcdc0e5dce67252aa6322d82424be928214fa2", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", "shasum": "" }, "require": { @@ -3231,7 +2311,7 @@ ], "support": { "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.16.0" + "source": "https://github.com/filp/whoops/tree/2.18.4" }, "funding": [ { @@ -3239,20 +2319,20 @@ "type": "github" } ], - "time": "2024-09-25T12:00:00+00:00" + "time": "2025-08-08T12:00:00+00:00" }, { "name": "firebase/php-jwt", - "version": "v6.10.2", + "version": "v6.11.1", "source": { "type": "git", "url": "https://github.com/firebase/php-jwt.git", - "reference": "30c19ed0f3264cb660ea496895cfb6ef7ee3653b" + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/30c19ed0f3264cb660ea496895cfb6ef7ee3653b", - "reference": "30c19ed0f3264cb660ea496895cfb6ef7ee3653b", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", "shasum": "" }, "require": { @@ -3300,9 +2380,9 @@ ], "support": { "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v6.10.2" + "source": "https://github.com/firebase/php-jwt/tree/v6.11.1" }, - "time": "2024-11-24T11:22:49+00:00" + "time": "2025-04-09T20:32:01+00:00" }, { "name": "fruitcake/php-cors", @@ -3377,32 +2457,32 @@ }, { "name": "glhd/aire", - "version": "2.13.0", + "version": "2.14.0", "source": { "type": "git", "url": "https://github.com/glhd/aire.git", - "reference": "49627f7ed8fde11d88fa4f4a81a8ef80bea97677" + "reference": "f2807b9c4acd66618ff5ed31021a2f81f954026b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/glhd/aire/zipball/49627f7ed8fde11d88fa4f4a81a8ef80bea97677", - "reference": "49627f7ed8fde11d88fa4f4a81a8ef80bea97677", + "url": "https://api.github.com/repos/glhd/aire/zipball/f2807b9c4acd66618ff5ed31021a2f81f954026b", + "reference": "f2807b9c4acd66618ff5ed31021a2f81f954026b", "shasum": "" }, "require": { "ext-json": "*", - "illuminate/events": ">=5.8.28 <10.48.0 || >10.48.1 <12.0.0", - "illuminate/support": ">=5.8.28 <10.48.0 || >10.48.1 <12.0.0", - "illuminate/view": ">=5.8.28 <10.48.0 || >10.48.1 <12.0.0" + "illuminate/events": ">=5.8.28 <10.48.0 || >10.48.1 <13.0.0", + "illuminate/support": ">=5.8.28 <10.48.0 || >10.48.1 <13.0.0", + "illuminate/view": ">=5.8.28 <10.48.0 || >10.48.1 <13.0.0" }, "require-dev": { "barryvdh/reflection-docblock": "^2.0", "friendsofphp/php-cs-fixer": "^3.5", "guzzlehttp/guzzle": "~6.0|~7.0", "mockery/mockery": "^1.4", - "orchestra/testbench": "^6.24|^7.10|^8|^9|9.x-dev|10.x-dev|dev-master", + "orchestra/testbench": "^6.24|^7.10|^8|^9|^10|11.x-dev|dev-master|dev-main", "php-coveralls/php-coveralls": "^2.1", - "phpunit/phpunit": "^9|^10.5", + "phpunit/phpunit": "^9|^10.5|^11.5", "symfony/css-selector": "^5.4", "symfony/dom-crawler": "^5.4" }, @@ -3412,12 +2492,12 @@ "type": "library", "extra": { "laravel": { - "providers": [ - "Galahad\\Aire\\Support\\AireServiceProvider" - ], "aliases": { "Aire": "Galahad\\Aire\\Support\\Facades\\Aire" - } + }, + "providers": [ + "Galahad\\Aire\\Support\\AireServiceProvider" + ] } }, "autoload": { @@ -3444,9 +2524,9 @@ ], "support": { "issues": "https://github.com/glhd/aire/issues", - "source": "https://github.com/glhd/aire/tree/2.13.0" + "source": "https://github.com/glhd/aire/tree/2.14.0" }, - "time": "2024-09-18T15:17:14+00:00" + "time": "2025-03-04T14:40:38+00:00" }, { "name": "glhd/aire-bootstrap", @@ -3513,26 +2593,26 @@ }, { "name": "glhd/conveyor-belt", - "version": "2.1.0", + "version": "2.2.0", "source": { "type": "git", "url": "https://github.com/glhd/conveyor-belt.git", - "reference": "471c97b359fbff00259ba88ad4de98ae9ce364ee" + "reference": "76f959546b0e4ca18ee46fc171157cf955f20817" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/glhd/conveyor-belt/zipball/471c97b359fbff00259ba88ad4de98ae9ce364ee", - "reference": "471c97b359fbff00259ba88ad4de98ae9ce364ee", + "url": "https://api.github.com/repos/glhd/conveyor-belt/zipball/76f959546b0e4ca18ee46fc171157cf955f20817", + "reference": "76f959546b0e4ca18ee46fc171157cf955f20817", "shasum": "" }, "require": { "ext-json": "*", "guzzlehttp/guzzle": "^7.0", "halaxa/json-machine": "^1.0", - "illuminate/collections": "^8|^9|^10|^11|12.x-dev|dev-master", - "illuminate/console": "^8|^9|^10|^11|12.x-dev|dev-master", - "illuminate/http": "^8|^9|^10|^11|12.x-dev|dev-master", - "illuminate/support": "^8|^9|^10|^11|12.x-dev|dev-master", + "illuminate/collections": "^8|^9|^10|^11|^12|13.x-dev|dev-master|dev-main", + "illuminate/console": "^8|^9|^10|^11|^12|13.x-dev|dev-master|dev-main", + "illuminate/http": "^8|^9|^10|^11|^12|13.x-dev|dev-master|dev-main", + "illuminate/support": "^8|^9|^10|^11|^12|13.x-dev|dev-master|dev-main", "jdorn/sql-formatter": "^1.2", "openspout/openspout": "^4.0", "php": ">= 8.0", @@ -3540,10 +2620,9 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.0", - "glhd/laravel-dumper": "^1.0", "mockery/mockery": "^1.3", - "orchestra/testbench": "^6.24|^7.10|^8|^9|9.x-dev|10.x-dev|dev-master", - "phpunit/phpunit": "^9.5|^10.5" + "orchestra/testbench": "^6.24|^7.10|^8.33|^9.11|^10.0|11.x-dev|dev-master|dev-main", + "phpunit/phpunit": "^10.5|^11.5" }, "type": "library", "extra": { @@ -3573,9 +2652,9 @@ ], "support": { "issues": "https://github.com/glhd/conveyor-belt/issues", - "source": "https://github.com/glhd/conveyor-belt/tree/2.1.0" + "source": "https://github.com/glhd/conveyor-belt/tree/2.2.0" }, - "time": "2024-03-12T20:00:23+00:00" + "time": "2025-03-04T19:00:43+00:00" }, { "name": "graham-campbell/result-type", @@ -3641,22 +2720,22 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.9.3", + "version": "7.10.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77" + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", - "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^1.5.3 || ^2.0.3", - "guzzlehttp/psr7": "^2.7.0", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.2 || ^3.0" @@ -3747,7 +2826,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.9.3" + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" }, "funding": [ { @@ -3763,20 +2842,20 @@ "type": "tidelift" } ], - "time": "2025-03-27T13:37:11+00:00" + "time": "2025-08-23T22:36:01+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.2.0", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c" + "reference": "481557b130ef3790cf82b713667b43030dc9c957" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/7c69f28996b0a6920945dd20b3857e499d9ca96c", - "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", "shasum": "" }, "require": { @@ -3784,7 +2863,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.39 || ^9.6.20" + "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "type": "library", "extra": { @@ -3830,7 +2909,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.2.0" + "source": "https://github.com/guzzle/promises/tree/2.3.0" }, "funding": [ { @@ -3846,20 +2925,20 @@ "type": "tidelift" } ], - "time": "2025-03-27T13:27:01+00:00" + "time": "2025-08-22T14:34:08+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.7.1", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16" + "reference": "21dc724a0583619cd1652f673303492272778051" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16", - "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", "shasum": "" }, "require": { @@ -3875,7 +2954,7 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "0.9.0", - "phpunit/phpunit": "^8.5.39 || ^9.6.20" + "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -3946,7 +3025,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.7.1" + "source": "https://github.com/guzzle/psr7/tree/2.8.0" }, "funding": [ { @@ -3962,20 +3041,20 @@ "type": "tidelift" } ], - "time": "2025-03-27T12:30:47+00:00" + "time": "2025-08-23T21:21:41+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.4", + "version": "v1.0.5", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "30e286560c137526eccd4ce21b2de477ab0676d2" + "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/30e286560c137526eccd4ce21b2de477ab0676d2", - "reference": "30e286560c137526eccd4ce21b2de477ab0676d2", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/4f4bbd4e7172148801e76e3decc1e559bdee34e1", + "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1", "shasum": "" }, "require": { @@ -3984,7 +3063,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.36 || ^9.6.15", + "phpunit/phpunit": "^8.5.44 || ^9.6.25", "uri-template/tests": "1.0.0" }, "type": "library", @@ -4032,7 +3111,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.4" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.5" }, "funding": [ { @@ -4048,7 +3127,7 @@ "type": "tidelift" } ], - "time": "2025-02-03T10:55:03+00:00" + "time": "2025-08-22T14:27:06+00:00" }, { "name": "hack-greenville/api", @@ -4155,20 +3234,20 @@ }, { "name": "halaxa/json-machine", - "version": "1.1.4", + "version": "1.2.5", "source": { "type": "git", "url": "https://github.com/halaxa/json-machine.git", - "reference": "5147f38f74d7ab3e27733e3f3bdabbd2fd28e3fa" + "reference": "d0f84abf79ac98145d478b66d2bcf363d706477c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/halaxa/json-machine/zipball/5147f38f74d7ab3e27733e3f3bdabbd2fd28e3fa", - "reference": "5147f38f74d7ab3e27733e3f3bdabbd2fd28e3fa", + "url": "https://api.github.com/repos/halaxa/json-machine/zipball/d0f84abf79ac98145d478b66d2bcf363d706477c", + "reference": "d0f84abf79ac98145d478b66d2bcf363d706477c", "shasum": "" }, "require": { - "php": "7.0 - 8.3" + "php": "7.2 - 8.4" }, "require-dev": { "ext-json": "*", @@ -4182,6 +3261,9 @@ }, "type": "library", "autoload": { + "files": [ + "src/functions.php" + ], "psr-4": { "JsonMachine\\": "src/" }, @@ -4202,7 +3284,7 @@ "description": "Efficient, easy-to-use and fast JSON pull parser", "support": { "issues": "https://github.com/halaxa/json-machine/issues", - "source": "https://github.com/halaxa/json-machine/tree/1.1.4" + "source": "https://github.com/halaxa/json-machine/tree/1.2.5" }, "funding": [ { @@ -4210,27 +3292,27 @@ "type": "other" } ], - "time": "2023-11-28T21:12:40+00:00" + "time": "2025-07-07T13:38:34+00:00" }, { "name": "internachi/modular", - "version": "2.2.0", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/InterNACHI/modular.git", - "reference": "d50168908d1500f995b595ea56da8f010c53c5e0" + "reference": "e7ff4074001d3df50d9ce877385c55d88e254484" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/InterNACHI/modular/zipball/d50168908d1500f995b595ea56da8f010c53c5e0", - "reference": "d50168908d1500f995b595ea56da8f010c53c5e0", + "url": "https://api.github.com/repos/InterNACHI/modular/zipball/e7ff4074001d3df50d9ce877385c55d88e254484", + "reference": "e7ff4074001d3df50d9ce877385c55d88e254484", "shasum": "" }, "require": { "composer/composer": "^2.1", "ext-dom": "*", "ext-simplexml": "*", - "illuminate/support": "^9|^10|^11|12.x-dev|dev-master", + "illuminate/support": "^9|^10|^11|^12|13.x-dev|dev-master|dev-main", "php": ">=8.0" }, "require-dev": { @@ -4238,20 +3320,20 @@ "friendsofphp/php-cs-fixer": "^3.14", "livewire/livewire": "^2.5|^3.0", "mockery/mockery": "^1.5", - "orchestra/testbench": ">=7.10|dev-master", - "phpunit/phpunit": "^9.5|^10.5" + "orchestra/testbench": "^7.52|^8.33|^9.11|^10.0|dev-master|dev-main", + "phpunit/phpunit": "^9.5|^10.5|^11.5" }, "type": "library", "extra": { "laravel": { + "aliases": { + "Modules": "InterNACHI\\Modular\\Support\\Facades\\Modules" + }, "providers": [ "InterNACHI\\Modular\\Support\\ModularServiceProvider", "InterNACHI\\Modular\\Support\\ModularizedCommandsServiceProvider", "InterNACHI\\Modular\\Support\\ModularEventServiceProvider" - ], - "aliases": { - "Modules": "InterNACHI\\Modular\\Support\\Facades\\Modules" - } + ] } }, "autoload": { @@ -4278,9 +3360,9 @@ ], "support": { "issues": "https://github.com/InterNACHI/modular/issues", - "source": "https://github.com/InterNACHI/modular/tree/2.2.0" + "source": "https://github.com/InterNACHI/modular/tree/2.3.0" }, - "time": "2024-04-06T00:59:47+00:00" + "time": "2025-02-28T22:15:39+00:00" }, { "name": "jdorn/sql-formatter", @@ -4338,30 +3420,40 @@ }, { "name": "justinrainbow/json-schema", - "version": "5.3.0", + "version": "6.6.0", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "feb2ca6dd1cebdaf1ed60a4c8de2e53ce11c4fd8" + "reference": "68ba7677532803cc0c5900dd5a4d730537f2b2f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/feb2ca6dd1cebdaf1ed60a4c8de2e53ce11c4fd8", - "reference": "feb2ca6dd1cebdaf1ed60a4c8de2e53ce11c4fd8", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/68ba7677532803cc0c5900dd5a4d730537f2b2f3", + "reference": "68ba7677532803cc0c5900dd5a4d730537f2b2f3", "shasum": "" }, "require": { - "php": ">=7.1" + "ext-json": "*", + "marc-mabe/php-enum": "^4.0", + "php": "^7.2 || ^8.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1", - "json-schema/json-schema-test-suite": "1.2.0", - "phpunit/phpunit": "^4.8.35" + "friendsofphp/php-cs-fixer": "3.3.0", + "json-schema/json-schema-test-suite": "^23.2", + "marc-mabe/php-enum-phpstan": "^2.0", + "phpspec/prophecy": "^1.19", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^8.5" }, "bin": [ "bin/validate-json" ], "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.x-dev" + } + }, "autoload": { "psr-4": { "JsonSchema\\": "src/JsonSchema/" @@ -4390,74 +3482,16 @@ } ], "description": "A library to validate a json schema.", - "homepage": "https://github.com/justinrainbow/json-schema", + "homepage": "https://github.com/jsonrainbow/json-schema", "keywords": [ "json", "schema" ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/5.3.0" - }, - "time": "2024-07-06T21:00:26+00:00" - }, - { - "name": "kelunik/certificate", - "version": "v1.1.3", - "source": { - "type": "git", - "url": "https://github.com/kelunik/certificate.git", - "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e", - "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e", - "shasum": "" - }, - "require": { - "ext-openssl": "*", - "php": ">=7.0" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "phpunit/phpunit": "^6 | 7 | ^8 | ^9" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Kelunik\\Certificate\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "Access certificate details and transform between different formats.", - "keywords": [ - "DER", - "certificate", - "certificates", - "openssl", - "pem", - "x509" - ], - "support": { - "issues": "https://github.com/kelunik/certificate/issues", - "source": "https://github.com/kelunik/certificate/tree/v1.1.3" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.6.0" }, - "time": "2023-02-03T21:26:53+00:00" + "time": "2025-10-10T11:34:09+00:00" }, { "name": "kirschbaum-development/eloquent-power-joins", @@ -4524,16 +3558,16 @@ }, { "name": "knuckleswtf/scribe", - "version": "5.2.1", + "version": "5.3.0", "source": { "type": "git", "url": "https://github.com/knuckleswtf/scribe.git", - "reference": "7d1866bfccc96559b753466afdc1f70ed6c6125e" + "reference": "380f9cd6de6d65d2e92930a8613d8a682e83e8c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/knuckleswtf/scribe/zipball/7d1866bfccc96559b753466afdc1f70ed6c6125e", - "reference": "7d1866bfccc96559b753466afdc1f70ed6c6125e", + "url": "https://api.github.com/repos/knuckleswtf/scribe/zipball/380f9cd6de6d65d2e92930a8613d8a682e83e8c7", + "reference": "380f9cd6de6d65d2e92930a8613d8a682e83e8c7", "shasum": "" }, "require": { @@ -4606,7 +3640,7 @@ ], "support": { "issues": "https://github.com/knuckleswtf/scribe/issues", - "source": "https://github.com/knuckleswtf/scribe/tree/5.2.1" + "source": "https://github.com/knuckleswtf/scribe/tree/5.3.0" }, "funding": [ { @@ -4614,20 +3648,20 @@ "type": "patreon" } ], - "time": "2025-05-01T01:14:54+00:00" + "time": "2025-07-28T22:27:25+00:00" }, { "name": "laravel/framework", - "version": "v10.48.29", + "version": "v10.49.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "8f7f9247cb8aad1a769d6b9815a6623d89b46b47" + "reference": "f857267b80789327cd3e6b077bcf6df5846cf71b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/8f7f9247cb8aad1a769d6b9815a6623d89b46b47", - "reference": "8f7f9247cb8aad1a769d6b9815a6623d89b46b47", + "url": "https://api.github.com/repos/laravel/framework/zipball/f857267b80789327cd3e6b077bcf6df5846cf71b", + "reference": "f857267b80789327cd3e6b077bcf6df5846cf71b", "shasum": "" }, "require": { @@ -4786,6 +3820,7 @@ }, "autoload": { "files": [ + "src/Illuminate/Collections/functions.php", "src/Illuminate/Collections/helpers.php", "src/Illuminate/Events/functions.php", "src/Illuminate/Filesystem/functions.php", @@ -4821,24 +3856,24 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-03-12T14:42:01+00:00" + "time": "2025-09-30T14:56:54+00:00" }, { "name": "laravel/helpers", - "version": "v1.7.0", + "version": "v1.8.1", "source": { "type": "git", "url": "https://github.com/laravel/helpers.git", - "reference": "6caaa242a23bc39b4e3cf57304b5409260a7a346" + "reference": "d0094b4bc4364560c8ee3a9e956596d760d4afab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/helpers/zipball/6caaa242a23bc39b4e3cf57304b5409260a7a346", - "reference": "6caaa242a23bc39b4e3cf57304b5409260a7a346", + "url": "https://api.github.com/repos/laravel/helpers/zipball/d0094b4bc4364560c8ee3a9e956596d760d4afab", + "reference": "d0094b4bc4364560c8ee3a9e956596d760d4afab", "shasum": "" }, "require": { - "illuminate/support": "~5.8.0|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", + "illuminate/support": "~5.8.0|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", "php": "^7.2.0|^8.0" }, "require-dev": { @@ -4876,9 +3911,9 @@ "laravel" ], "support": { - "source": "https://github.com/laravel/helpers/tree/v1.7.0" + "source": "https://github.com/laravel/helpers/tree/v1.8.1" }, - "time": "2023-11-30T14:09:05+00:00" + "time": "2025-09-02T15:31:25+00:00" }, { "name": "laravel/prompts", @@ -5024,13 +4059,13 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "2.x-dev" - }, "laravel": { "providers": [ "Illuminate\\Notifications\\SlackChannelServiceProvider" ] + }, + "branch-alias": { + "dev-master": "2.x-dev" } }, "autoload": { @@ -5062,22 +4097,22 @@ }, { "name": "laravel/tinker", - "version": "v2.10.0", + "version": "v2.10.1", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "ba4d51eb56de7711b3a37d63aa0643e99a339ae5" + "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/ba4d51eb56de7711b3a37d63aa0643e99a339ae5", - "reference": "ba4d51eb56de7711b3a37d63aa0643e99a339ae5", + "url": "https://api.github.com/repos/laravel/tinker/zipball/22177cc71807d38f2810c6204d8f7183d88a57d3", + "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3", "shasum": "" }, "require": { - "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", - "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", - "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", + "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", "php": "^7.2.5|^8.0", "psy/psysh": "^0.11.1|^0.12.0", "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0" @@ -5085,10 +4120,10 @@ "require-dev": { "mockery/mockery": "~1.3.3|^1.4.2", "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^8.5.8|^9.3.3" + "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" }, "suggest": { - "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0)." + "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." }, "type": "library", "extra": { @@ -5122,9 +4157,9 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.10.0" + "source": "https://github.com/laravel/tinker/tree/v2.10.1" }, - "time": "2024-09-23T13:32:56+00:00" + "time": "2025-01-27T14:24:01+00:00" }, { "name": "league/commonmark", @@ -5317,16 +4352,16 @@ }, { "name": "league/csv", - "version": "9.24.1", + "version": "9.26.0", "source": { "type": "git", "url": "https://github.com/thephpleague/csv.git", - "reference": "e0221a3f16aa2a823047d59fab5809d552e29bc8" + "reference": "7fce732754d043f3938899e5183e2d0f3d31b571" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/csv/zipball/e0221a3f16aa2a823047d59fab5809d552e29bc8", - "reference": "e0221a3f16aa2a823047d59fab5809d552e29bc8", + "url": "https://api.github.com/repos/thephpleague/csv/zipball/7fce732754d043f3938899e5183e2d0f3d31b571", + "reference": "7fce732754d043f3938899e5183e2d0f3d31b571", "shasum": "" }, "require": { @@ -5342,7 +4377,7 @@ "phpstan/phpstan-deprecation-rules": "^1.2.1", "phpstan/phpstan-phpunit": "^1.4.2", "phpstan/phpstan-strict-rules": "^1.6.2", - "phpunit/phpunit": "^10.5.16 || ^11.5.22", + "phpunit/phpunit": "^10.5.16 || ^11.5.22 || ^12.3.6", "symfony/var-dumper": "^6.4.8 || ^7.3.0" }, "suggest": { @@ -5404,7 +4439,7 @@ "type": "github" } ], - "time": "2025-06-25T14:53:51+00:00" + "time": "2025-10-01T11:24:54+00:00" }, { "name": "league/flysystem", @@ -5867,12 +4902,12 @@ "type": "library", "extra": { "laravel": { - "providers": [ - "Malzariey\\FilamentDaterangepickerFilter\\FilamentDaterangepickerFilterServiceProvider" - ], "aliases": { "FilamentDaterangepickerFilter": "Malzariey\\FilamentDaterangepickerFilter\\Facades\\FilamentDaterangepickerFilter" - } + }, + "providers": [ + "Malzariey\\FilamentDaterangepickerFilter\\FilamentDaterangepickerFilterServiceProvider" + ] } }, "autoload": { @@ -5911,18 +4946,91 @@ ], "time": "2024-07-01T22:18:22+00:00" }, + { + "name": "marc-mabe/php-enum", + "version": "v4.7.2", + "source": { + "type": "git", + "url": "https://github.com/marc-mabe/php-enum.git", + "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/marc-mabe/php-enum/zipball/bb426fcdd65c60fb3638ef741e8782508fda7eef", + "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef", + "shasum": "" + }, + "require": { + "ext-reflection": "*", + "php": "^7.1 | ^8.0" + }, + "require-dev": { + "phpbench/phpbench": "^0.16.10 || ^1.0.4", + "phpstan/phpstan": "^1.3.1", + "phpunit/phpunit": "^7.5.20 | ^8.5.22 | ^9.5.11", + "vimeo/psalm": "^4.17.0 | ^5.26.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-3.x": "3.2-dev", + "dev-master": "4.7-dev" + } + }, + "autoload": { + "psr-4": { + "MabeEnum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Marc Bennewitz", + "email": "dev@mabe.berlin", + "homepage": "https://mabe.berlin/", + "role": "Lead" + } + ], + "description": "Simple and fast implementation of enumerations with native PHP", + "homepage": "https://github.com/marc-mabe/php-enum", + "keywords": [ + "enum", + "enum-map", + "enum-set", + "enumeration", + "enumerator", + "enummap", + "enumset", + "map", + "set", + "type", + "type-hint", + "typehint" + ], + "support": { + "issues": "https://github.com/marc-mabe/php-enum/issues", + "source": "https://github.com/marc-mabe/php-enum/tree/v4.7.2" + }, + "time": "2025-09-14T11:18:39+00:00" + }, { "name": "masterminds/html5", - "version": "2.9.0", + "version": "2.10.0", "source": { "type": "git", "url": "https://github.com/Masterminds/html5-php.git", - "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6" + "reference": "fcf91eb64359852f00d921887b219479b4f21251" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", - "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", "shasum": "" }, "require": { @@ -5974,9 +5082,9 @@ ], "support": { "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.9.0" + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" }, - "time": "2024-03-31T07:05:07+00:00" + "time": "2025-07-25T09:04:22+00:00" }, { "name": "monolog/monolog", @@ -6305,29 +5413,29 @@ }, { "name": "nette/utils", - "version": "v4.0.7", + "version": "v4.0.8", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "e67c4061eb40b9c113b218214e42cb5a0dda28f2" + "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/e67c4061eb40b9c113b218214e42cb5a0dda28f2", - "reference": "e67c4061eb40b9c113b218214e42cb5a0dda28f2", + "url": "https://api.github.com/repos/nette/utils/zipball/c930ca4e3cf4f17dcfb03037703679d2396d2ede", + "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede", "shasum": "" }, "require": { - "php": "8.0 - 8.4" + "php": "8.0 - 8.5" }, "conflict": { "nette/finder": "<3", "nette/schema": "<1.2.2" }, "require-dev": { - "jetbrains/phpstorm-attributes": "dev-master", + "jetbrains/phpstorm-attributes": "^1.2", "nette/tester": "^2.5", - "phpstan/phpstan": "^1.0", + "phpstan/phpstan-nette": "^2.0@stable", "tracy/tracy": "^2.9" }, "suggest": { @@ -6345,6 +5453,9 @@ } }, "autoload": { + "psr-4": { + "Nette\\": "src" + }, "classmap": [ "src/" ] @@ -6385,22 +5496,22 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.0.7" + "source": "https://github.com/nette/utils/tree/v4.0.8" }, - "time": "2025-06-03T04:55:08+00:00" + "time": "2025-08-06T21:43:34+00:00" }, { "name": "nikic/php-parser", - "version": "v5.3.1", + "version": "v5.6.1", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b" + "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", + "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", "shasum": "" }, "require": { @@ -6419,7 +5530,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -6443,46 +5554,46 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.1" }, - "time": "2024-10-08T18:51:32+00:00" + "time": "2025-08-13T20:13:15+00:00" }, { "name": "nunomaduro/collision", - "version": "v7.11.0", + "version": "v7.12.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "994ea93df5d4132f69d3f1bd74730509df6e8a05" + "reference": "995245421d3d7593a6960822063bdba4f5d7cf1a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/994ea93df5d4132f69d3f1bd74730509df6e8a05", - "reference": "994ea93df5d4132f69d3f1bd74730509df6e8a05", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/995245421d3d7593a6960822063bdba4f5d7cf1a", + "reference": "995245421d3d7593a6960822063bdba4f5d7cf1a", "shasum": "" }, "require": { - "filp/whoops": "^2.16.0", - "nunomaduro/termwind": "^1.15.1", + "filp/whoops": "^2.17.0", + "nunomaduro/termwind": "^1.17.0", "php": "^8.1.0", - "symfony/console": "^6.4.12" + "symfony/console": "^6.4.17" }, "conflict": { "laravel/framework": ">=11.0.0" }, "require-dev": { - "brianium/paratest": "^7.3.1", - "laravel/framework": "^10.48.22", - "laravel/pint": "^1.18.1", - "laravel/sail": "^1.36.0", + "brianium/paratest": "^7.4.8", + "laravel/framework": "^10.48.29", + "laravel/pint": "^1.21.2", + "laravel/sail": "^1.41.0", "laravel/sanctum": "^3.3.3", - "laravel/tinker": "^2.10.0", - "nunomaduro/larastan": "^2.9.8", - "orchestra/testbench-core": "^8.28.3", - "pestphp/pest": "^2.35.1", + "laravel/tinker": "^2.10.1", + "nunomaduro/larastan": "^2.10.0", + "orchestra/testbench-core": "^8.35.0", + "pestphp/pest": "^2.36.0", "phpunit/phpunit": "^10.5.36", "sebastian/environment": "^6.1.0", - "spatie/laravel-ignition": "^2.8.0" + "spatie/laravel-ignition": "^2.9.1" }, "type": "library", "extra": { @@ -6541,7 +5652,7 @@ "type": "patreon" } ], - "time": "2024-10-15T15:12:40+00:00" + "time": "2025-03-14T22:35:49+00:00" }, { "name": "nunomaduro/termwind", @@ -6723,21 +5834,22 @@ }, { "name": "phpdocumentor/reflection", - "version": "6.0.0", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/Reflection.git", - "reference": "61e2f1fe7683e9647b9ed8d9e53d08699385267d" + "reference": "d91b3270832785602adcc24ae2d0974ba99a8ff8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/Reflection/zipball/61e2f1fe7683e9647b9ed8d9e53d08699385267d", - "reference": "61e2f1fe7683e9647b9ed8d9e53d08699385267d", + "url": "https://api.github.com/repos/phpDocumentor/Reflection/zipball/d91b3270832785602adcc24ae2d0974ba99a8ff8", + "reference": "d91b3270832785602adcc24ae2d0974ba99a8ff8", "shasum": "" }, "require": { + "composer-runtime-api": "^2", "nikic/php-parser": "~4.18 || ^5.0", - "php": "8.1.*|8.2.*|8.3.*", + "php": "8.1.*|8.2.*|8.3.*|8.4.*", "phpdocumentor/reflection-common": "^2.1", "phpdocumentor/reflection-docblock": "^5", "phpdocumentor/type-resolver": "^1.2", @@ -6746,7 +5858,8 @@ }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^1.0", - "doctrine/coding-standard": "^12.0", + "doctrine/coding-standard": "^13.0", + "eliashaeussler/phpunit-attributes": "^1.7", "mikey179/vfsstream": "~1.2", "mockery/mockery": "~1.6.0", "phpspec/prophecy-phpunit": "^2.0", @@ -6754,7 +5867,7 @@ "phpstan/phpstan": "^1.8", "phpstan/phpstan-webmozart-assert": "^1.2", "phpunit/phpunit": "^10.0", - "psalm/phar": "^5.24", + "psalm/phar": "^6.0", "rector/rector": "^1.0.0", "squizlabs/php_codesniffer": "^3.8" }, @@ -6766,6 +5879,9 @@ } }, "autoload": { + "files": [ + "src/php-parser/Modifiers.php" + ], "psr-4": { "phpDocumentor\\": "src/phpDocumentor" } @@ -6784,9 +5900,9 @@ ], "support": { "issues": "https://github.com/phpDocumentor/Reflection/issues", - "source": "https://github.com/phpDocumentor/Reflection/tree/6.0.0" + "source": "https://github.com/phpDocumentor/Reflection/tree/6.3.0" }, - "time": "2024-05-23T19:28:12+00:00" + "time": "2025-06-06T13:39:18+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -6843,16 +5959,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.5.1", + "version": "5.6.3", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "0c70d2c566e899666f367ab7b80986beb3581e6f" + "reference": "94f8051919d1b0369a6bcc7931d679a511c03fe9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/0c70d2c566e899666f367ab7b80986beb3581e6f", - "reference": "0c70d2c566e899666f367ab7b80986beb3581e6f", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/94f8051919d1b0369a6bcc7931d679a511c03fe9", + "reference": "94f8051919d1b0369a6bcc7931d679a511c03fe9", "shasum": "" }, "require": { @@ -6861,7 +5977,7 @@ "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.2", "phpdocumentor/type-resolver": "^1.7", - "phpstan/phpdoc-parser": "^1.7", + "phpstan/phpdoc-parser": "^1.7|^2.0", "webmozart/assert": "^1.9.1" }, "require-dev": { @@ -6901,29 +6017,29 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.5.1" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.3" }, - "time": "2024-11-06T11:58:54+00:00" + "time": "2025-08-01T19:43:32+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.9.0", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "1fb5ba8d045f5dd984ebded5b1cc66f29459422d" + "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/1fb5ba8d045f5dd984ebded5b1cc66f29459422d", - "reference": "1fb5ba8d045f5dd984ebded5b1cc66f29459422d", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/679e3ce485b99e84c775d28e2e96fade9a7fb50a", + "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a", "shasum": "" }, "require": { "doctrine/deprecations": "^1.0", "php": "^7.3 || ^8.0", "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.18" + "phpstan/phpdoc-parser": "^1.18|^2.0" }, "require-dev": { "ext-tokenizer": "*", @@ -6959,22 +6075,22 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.9.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.10.0" }, - "time": "2024-11-03T20:11:34+00:00" + "time": "2024-11-09T15:12:26+00:00" }, { "name": "phpoption/phpoption", - "version": "1.9.3", + "version": "1.9.4", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54" + "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/e3fac8b24f56113f7cb96af14958c0dd16330f54", - "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", + "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", "shasum": "" }, "require": { @@ -6982,7 +6098,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" }, "type": "library", "extra": { @@ -7024,7 +6140,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.3" + "source": "https://github.com/schmittjoh/php-option/tree/1.9.4" }, "funding": [ { @@ -7036,34 +6152,34 @@ "type": "tidelift" } ], - "time": "2024-07-20T21:41:07+00:00" + "time": "2025-08-21T11:53:16+00:00" }, { "name": "phpstan/phpdoc-parser", - "version": "1.33.0", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "82a311fd3690fb2bf7b64d5c98f912b3dd746140" + "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/82a311fd3690fb2bf7b64d5c98f912b3dd746140", - "reference": "82a311fd3690fb2bf7b64d5c98f912b3dd746140", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495", + "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "php": "^7.4 || ^8.0" }, "require-dev": { "doctrine/annotations": "^2.0", - "nikic/php-parser": "^4.15", + "nikic/php-parser": "^5.3.0", "php-parallel-lint/php-parallel-lint": "^1.2", "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^1.5", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.0", - "phpunit/phpunit": "^9.5", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", "symfony/process": "^5.2" }, "type": "library", @@ -7081,9 +6197,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.33.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0" }, - "time": "2024-10-13T11:25:22+00:00" + "time": "2025-08-30T15:50:23+00:00" }, { "name": "psr/cache", @@ -7548,16 +6664,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.4", + "version": "v0.12.12", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "2fd717afa05341b4f8152547f142cd2f130f6818" + "reference": "cd23863404a40ccfaf733e3af4db2b459837f7e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/2fd717afa05341b4f8152547f142cd2f130f6818", - "reference": "2fd717afa05341b4f8152547f142cd2f130f6818", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/cd23863404a40ccfaf733e3af4db2b459837f7e7", + "reference": "cd23863404a40ccfaf733e3af4db2b459837f7e7", "shasum": "" }, "require": { @@ -7584,12 +6700,12 @@ ], "type": "library", "extra": { - "branch-alias": { - "dev-main": "0.12.x-dev" - }, "bamarni-bin": { "bin-links": false, "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" } }, "autoload": { @@ -7607,12 +6723,11 @@ "authors": [ { "name": "Justin Hileman", - "email": "justin@justinhileman.info", - "homepage": "http://justinhileman.com" + "email": "justin@justinhileman.info" } ], "description": "An interactive shell for modern PHP.", - "homepage": "http://psysh.org", + "homepage": "https://psysh.org", "keywords": [ "REPL", "console", @@ -7621,9 +6736,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.4" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.12" }, - "time": "2024-06-10T01:18:23+00:00" + "time": "2025-09-20T13:46:31+00:00" }, { "name": "ralouphie/getallheaders", @@ -7747,20 +6862,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.0", + "version": "4.9.1", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0" + "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/4e0e23cc785f0724a0e838279a9eb03f28b092a0", - "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440", + "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13", + "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -7819,29 +6934,29 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.0" + "source": "https://github.com/ramsey/uuid/tree/4.9.1" }, - "time": "2025-06-25T14:20:11+00:00" + "time": "2025-09-04T20:59:21+00:00" }, { "name": "react/promise", - "version": "v3.2.0", + "version": "v3.3.0", "source": { "type": "git", "url": "https://github.com/reactphp/promise.git", - "reference": "8a164643313c71354582dc850b42b33fa12a4b63" + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/promise/zipball/8a164643313c71354582dc850b42b33fa12a4b63", - "reference": "8a164643313c71354582dc850b42b33fa12a4b63", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", "shasum": "" }, "require": { "php": ">=7.1.0" }, "require-dev": { - "phpstan/phpstan": "1.10.39 || 1.4.10", + "phpstan/phpstan": "1.12.28 || 1.4.10", "phpunit/phpunit": "^9.6 || ^7.5" }, "type": "library", @@ -7886,7 +7001,7 @@ ], "support": { "issues": "https://github.com/reactphp/promise/issues", - "source": "https://github.com/reactphp/promise/tree/v3.2.0" + "source": "https://github.com/reactphp/promise/tree/v3.3.0" }, "funding": [ { @@ -7894,79 +7009,7 @@ "type": "open_collective" } ], - "time": "2024-05-24T10:39:05+00:00" - }, - { - "name": "revolt/event-loop", - "version": "v1.0.6", - "source": { - "type": "git", - "url": "https://github.com/revoltphp/event-loop.git", - "reference": "25de49af7223ba039f64da4ae9a28ec2d10d0254" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/25de49af7223ba039f64da4ae9a28ec2d10d0254", - "reference": "25de49af7223ba039f64da4ae9a28ec2d10d0254", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "require-dev": { - "ext-json": "*", - "jetbrains/phpstorm-stubs": "^2019.3", - "phpunit/phpunit": "^9", - "psalm/phar": "^5.15" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Revolt\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "ceesjank@gmail.com" - }, - { - "name": "Christian Lück", - "email": "christian@clue.engineering" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "Rock-solid event loop for concurrent PHP applications.", - "keywords": [ - "async", - "asynchronous", - "concurrency", - "event", - "event-loop", - "non-blocking", - "scheduler" - ], - "support": { - "issues": "https://github.com/revoltphp/event-loop/issues", - "source": "https://github.com/revoltphp/event-loop/tree/v1.0.6" - }, - "time": "2023-11-30T05:34:44+00:00" + "time": "2025-08-19T18:57:03+00:00" }, { "name": "ryangjchandler/blade-capture-directive", @@ -8048,34 +7091,34 @@ }, { "name": "scyllaly/hcaptcha", - "version": "4.4.7", + "version": "4.4.9", "source": { "type": "git", "url": "https://github.com/Scyllaly/hcaptcha.git", - "reference": "f5d3d669a5a21e63457e868effbfb44099c3f8fd" + "reference": "9cf702906770a39191167d7eb9a2358b81d10b02" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Scyllaly/hcaptcha/zipball/f5d3d669a5a21e63457e868effbfb44099c3f8fd", - "reference": "f5d3d669a5a21e63457e868effbfb44099c3f8fd", + "url": "https://api.github.com/repos/Scyllaly/hcaptcha/zipball/9cf702906770a39191167d7eb9a2358b81d10b02", + "reference": "9cf702906770a39191167d7eb9a2358b81d10b02", "shasum": "" }, "require": { - "illuminate/support": "5.*|6.*|7.*|8.*|^9.0|10.*|^11.0", + "illuminate/support": "5.*|6.*|7.*|8.*|^9.0|10.*|^11.0|^12.0", "php": ">=5.5.5" }, "require-dev": { - "phpunit/phpunit": "~4.8|^9.5.10|^10.0" + "phpunit/phpunit": "~4.8|^9.5.10|^10.0|^11.0" }, "type": "library", "extra": { "laravel": { - "providers": [ - "Scyllaly\\HCaptcha\\HCaptchaServiceProvider" - ], "aliases": { "HCaptcha": "Scyllaly\\HCaptcha\\Facades\\HCaptcha" - } + }, + "providers": [ + "Scyllaly\\HCaptcha\\HCaptchaServiceProvider" + ] } }, "autoload": { @@ -8101,9 +7144,9 @@ ], "support": { "issues": "https://github.com/Scyllaly/hcaptcha/issues", - "source": "https://github.com/Scyllaly/hcaptcha/tree/4.4.7" + "source": "https://github.com/Scyllaly/hcaptcha/tree/4.4.9" }, - "time": "2024-08-31T06:44:47+00:00" + "time": "2025-04-10T07:27:15+00:00" }, { "name": "seld/jsonlint", @@ -8584,16 +7627,16 @@ }, { "name": "spatie/icalendar-generator", - "version": "2.9.1", + "version": "2.9.2", "source": { "type": "git", "url": "https://github.com/spatie/icalendar-generator.git", - "reference": "21b8d259156368812a6d55096499f289b265be4e" + "reference": "6fa4d5b20490afeebe711f6cad1733853c667aa2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/icalendar-generator/zipball/21b8d259156368812a6d55096499f289b265be4e", - "reference": "21b8d259156368812a6d55096499f289b265be4e", + "url": "https://api.github.com/repos/spatie/icalendar-generator/zipball/6fa4d5b20490afeebe711f6cad1733853c667aa2", + "reference": "6fa4d5b20490afeebe711f6cad1733853c667aa2", "shasum": "" }, "require": { @@ -8637,9 +7680,9 @@ ], "support": { "issues": "https://github.com/spatie/icalendar-generator/issues", - "source": "https://github.com/spatie/icalendar-generator/tree/2.9.1" + "source": "https://github.com/spatie/icalendar-generator/tree/2.9.2" }, - "time": "2025-01-31T13:50:13+00:00" + "time": "2025-03-21T09:01:17+00:00" }, { "name": "spatie/invade", @@ -8702,20 +7745,20 @@ }, { "name": "spatie/laravel-data", - "version": "4.11.1", + "version": "4.17.1", "source": { "type": "git", "url": "https://github.com/spatie/laravel-data.git", - "reference": "df5b58baebae34475ca35338b4e9a131c9e2a8e0" + "reference": "6ec15bb6798128f01aecb67dcd18a937251a27a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-data/zipball/df5b58baebae34475ca35338b4e9a131c9e2a8e0", - "reference": "df5b58baebae34475ca35338b4e9a131c9e2a8e0", + "url": "https://api.github.com/repos/spatie/laravel-data/zipball/6ec15bb6798128f01aecb67dcd18a937251a27a5", + "reference": "6ec15bb6798128f01aecb67dcd18a937251a27a5", "shasum": "" }, "require": { - "illuminate/contracts": "^10.0|^11.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", "php": "^8.1", "phpdocumentor/reflection": "^6.0", "spatie/laravel-package-tools": "^1.9.0", @@ -8724,18 +7767,17 @@ "require-dev": { "fakerphp/faker": "^1.14", "friendsofphp/php-cs-fixer": "^3.0", - "inertiajs/inertia-laravel": "^1.2", + "inertiajs/inertia-laravel": "^2.0", "livewire/livewire": "^3.0", "mockery/mockery": "^1.6", - "nesbot/carbon": "^2.63", - "nunomaduro/larastan": "^2.0", - "orchestra/testbench": "^8.0|^9.0", - "pestphp/pest": "^2.31", - "pestphp/pest-plugin-laravel": "^2.0", - "pestphp/pest-plugin-livewire": "^2.1", + "nesbot/carbon": "^2.63|^3.0", + "orchestra/testbench": "^8.0|^9.0|^10.0", + "pestphp/pest": "^2.31|^3.0", + "pestphp/pest-plugin-laravel": "^2.0|^3.0", + "pestphp/pest-plugin-livewire": "^2.1|^3.0", "phpbench/phpbench": "^1.2", "phpstan/extension-installer": "^1.1", - "phpunit/phpunit": "^10.0", + "phpunit/phpunit": "^10.0|^11.0|^12.0", "spatie/invade": "^1.0", "spatie/laravel-typescript-transformer": "^2.5", "spatie/pest-plugin-snapshots": "^2.1", @@ -8774,7 +7816,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-data/issues", - "source": "https://github.com/spatie/laravel-data/tree/4.11.1" + "source": "https://github.com/spatie/laravel-data/tree/4.17.1" }, "funding": [ { @@ -8782,7 +7824,7 @@ "type": "github" } ], - "time": "2024-10-23T07:14:53+00:00" + "time": "2025-09-04T08:30:23+00:00" }, { "name": "spatie/laravel-package-tools", @@ -8847,40 +7889,41 @@ }, { "name": "spatie/php-structure-discoverer", - "version": "2.2.0", + "version": "2.3.2", "source": { "type": "git", "url": "https://github.com/spatie/php-structure-discoverer.git", - "reference": "271542206169d95dd2ffe346ddf11f37672553a2" + "reference": "6c46e069349c7f2f6ebbe00429332c9e6b70fa92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/271542206169d95dd2ffe346ddf11f37672553a2", - "reference": "271542206169d95dd2ffe346ddf11f37672553a2", + "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/6c46e069349c7f2f6ebbe00429332c9e6b70fa92", + "reference": "6c46e069349c7f2f6ebbe00429332c9e6b70fa92", "shasum": "" }, "require": { - "amphp/amp": "^v3.0", - "amphp/parallel": "^2.2", - "illuminate/collections": "^10.0|^11.0", + "illuminate/collections": "^10.0|^11.0|^12.0", "php": "^8.1", "spatie/laravel-package-tools": "^1.4.3", "symfony/finder": "^6.0|^7.0" }, "require-dev": { - "illuminate/console": "^10.0|^11.0", + "amphp/parallel": "^2.2", + "illuminate/console": "^10.0|^11.0|^12.0", "laravel/pint": "^1.0", "nunomaduro/collision": "^7.0|^8.0", - "nunomaduro/larastan": "^2.0.1", - "orchestra/testbench": "^7.0|^8.0|^9.0", - "pestphp/pest": "^2.0", - "pestphp/pest-plugin-laravel": "^2.0", + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "pestphp/pest-plugin-laravel": "^2.0|^3.0", "phpstan/extension-installer": "^1.1", "phpstan/phpstan-deprecation-rules": "^1.0", "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^9.5|^10.0", + "phpunit/phpunit": "^9.5|^10.0|^11.5.3", "spatie/laravel-ray": "^1.26" }, + "suggest": { + "amphp/parallel": "When you want to use the Parallel discover worker" + }, "type": "library", "extra": { "laravel": { @@ -8915,7 +7958,7 @@ ], "support": { "issues": "https://github.com/spatie/php-structure-discoverer/issues", - "source": "https://github.com/spatie/php-structure-discoverer/tree/2.2.0" + "source": "https://github.com/spatie/php-structure-discoverer/tree/2.3.2" }, "funding": [ { @@ -8923,20 +7966,20 @@ "type": "github" } ], - "time": "2024-08-29T10:43:45+00:00" + "time": "2025-09-22T14:58:17+00:00" }, { "name": "symfony/console", - "version": "v6.4.23", + "version": "v6.4.26", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "9056771b8eca08d026cd3280deeec3cfd99c4d93" + "reference": "492de6dfd93910d7d7a729c5a04ddcd2b9e99c4f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/9056771b8eca08d026cd3280deeec3cfd99c4d93", - "reference": "9056771b8eca08d026cd3280deeec3cfd99c4d93", + "url": "https://api.github.com/repos/symfony/console/zipball/492de6dfd93910d7d7a729c5a04ddcd2b9e99c4f", + "reference": "492de6dfd93910d7d7a729c5a04ddcd2b9e99c4f", "shasum": "" }, "require": { @@ -9001,7 +8044,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.23" + "source": "https://github.com/symfony/console/tree/v6.4.26" }, "funding": [ { @@ -9012,25 +8055,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:37:22+00:00" + "time": "2025-09-26T12:13:46+00:00" }, { "name": "symfony/css-selector", - "version": "v6.4.13", + "version": "v6.4.24", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "cb23e97813c5837a041b73a6d63a9ddff0778f5e" + "reference": "9b784413143701aa3c94ac1869a159a9e53e8761" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/cb23e97813c5837a041b73a6d63a9ddff0778f5e", - "reference": "cb23e97813c5837a041b73a6d63a9ddff0778f5e", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/9b784413143701aa3c94ac1869a159a9e53e8761", + "reference": "9b784413143701aa3c94ac1869a159a9e53e8761", "shasum": "" }, "require": { @@ -9066,7 +8113,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v6.4.13" + "source": "https://github.com/symfony/css-selector/tree/v6.4.24" }, "funding": [ { @@ -9077,12 +8124,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2025-07-10T08:14:14+00:00" }, { "name": "symfony/deprecation-contracts", @@ -9153,16 +8204,16 @@ }, { "name": "symfony/error-handler", - "version": "v6.4.23", + "version": "v6.4.26", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "b088e0b175c30b4e06d8085200fa465b586f44fa" + "reference": "41bedcaec5b72640b0ec2096547b75fda72ead6c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/b088e0b175c30b4e06d8085200fa465b586f44fa", - "reference": "b088e0b175c30b4e06d8085200fa465b586f44fa", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/41bedcaec5b72640b0ec2096547b75fda72ead6c", + "reference": "41bedcaec5b72640b0ec2096547b75fda72ead6c", "shasum": "" }, "require": { @@ -9208,7 +8259,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v6.4.23" + "source": "https://github.com/symfony/error-handler/tree/v6.4.26" }, "funding": [ { @@ -9219,25 +8270,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-13T07:39:48+00:00" + "time": "2025-09-11T09:57:09+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v6.4.13", + "version": "v6.4.25", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "0ffc48080ab3e9132ea74ef4e09d8dcf26bf897e" + "reference": "b0cf3162020603587363f0551cd3be43958611ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0ffc48080ab3e9132ea74ef4e09d8dcf26bf897e", - "reference": "0ffc48080ab3e9132ea74ef4e09d8dcf26bf897e", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b0cf3162020603587363f0551cd3be43958611ff", + "reference": "b0cf3162020603587363f0551cd3be43958611ff", "shasum": "" }, "require": { @@ -9288,7 +8343,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.13" + "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.25" }, "funding": [ { @@ -9296,7 +8351,11 @@ "type": "custom" }, { - "url": "https://github.com/fabpot", + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" }, { @@ -9304,7 +8363,7 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2025-08-13T09:41:44+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -9384,16 +8443,16 @@ }, { "name": "symfony/filesystem", - "version": "v6.4.13", + "version": "v6.4.24", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "4856c9cf585d5a0313d8d35afd681a526f038dd3" + "reference": "75ae2edb7cdcc0c53766c30b0a2512b8df574bd8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/4856c9cf585d5a0313d8d35afd681a526f038dd3", - "reference": "4856c9cf585d5a0313d8d35afd681a526f038dd3", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/75ae2edb7cdcc0c53766c30b0a2512b8df574bd8", + "reference": "75ae2edb7cdcc0c53766c30b0a2512b8df574bd8", "shasum": "" }, "require": { @@ -9430,7 +8489,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.13" + "source": "https://github.com/symfony/filesystem/tree/v6.4.24" }, "funding": [ { @@ -9441,25 +8500,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-10-25T15:07:50+00:00" + "time": "2025-07-10T08:14:14+00:00" }, { "name": "symfony/finder", - "version": "v6.4.17", + "version": "v6.4.24", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7" + "reference": "73089124388c8510efb8d2d1689285d285937b08" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7", - "reference": "1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7", + "url": "https://api.github.com/repos/symfony/finder/zipball/73089124388c8510efb8d2d1689285d285937b08", + "reference": "73089124388c8510efb8d2d1689285d285937b08", "shasum": "" }, "require": { @@ -9494,7 +8557,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.17" + "source": "https://github.com/symfony/finder/tree/v6.4.24" }, "funding": [ { @@ -9505,25 +8568,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-12-29T13:51:37+00:00" + "time": "2025-07-15T12:02:45+00:00" }, { "name": "symfony/html-sanitizer", - "version": "v6.4.21", + "version": "v6.4.25", "source": { "type": "git", "url": "https://github.com/symfony/html-sanitizer.git", - "reference": "f66d6585c6ece946239317c339f8b2860dfdf2db" + "reference": "e0807701639f32bb9053d3c1125d4886e0586771" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/f66d6585c6ece946239317c339f8b2860dfdf2db", - "reference": "f66d6585c6ece946239317c339f8b2860dfdf2db", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/e0807701639f32bb9053d3c1125d4886e0586771", + "reference": "e0807701639f32bb9053d3c1125d4886e0586771", "shasum": "" }, "require": { @@ -9563,7 +8630,7 @@ "sanitizer" ], "support": { - "source": "https://github.com/symfony/html-sanitizer/tree/v6.4.21" + "source": "https://github.com/symfony/html-sanitizer/tree/v6.4.25" }, "funding": [ { @@ -9574,25 +8641,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-31T07:29:45+00:00" + "time": "2025-08-12T10:22:15+00:00" }, { "name": "symfony/http-foundation", - "version": "v6.4.23", + "version": "v6.4.26", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "452d19f945ee41345fd8a50c18b60783546b7bd3" + "reference": "369241591d92bb5dfb4c6ccd6ee94378a45b1521" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/452d19f945ee41345fd8a50c18b60783546b7bd3", - "reference": "452d19f945ee41345fd8a50c18b60783546b7bd3", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/369241591d92bb5dfb4c6ccd6ee94378a45b1521", + "reference": "369241591d92bb5dfb4c6ccd6ee94378a45b1521", "shasum": "" }, "require": { @@ -9640,7 +8711,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v6.4.23" + "source": "https://github.com/symfony/http-foundation/tree/v6.4.26" }, "funding": [ { @@ -9651,25 +8722,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-26T09:17:58+00:00" + "time": "2025-09-16T08:22:30+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.4.23", + "version": "v6.4.26", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "2bb2cba685aabd859f22cf6946554e8e7f3c329a" + "reference": "8b0f963293aede77593c9845c8c0af34752e893a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/2bb2cba685aabd859f22cf6946554e8e7f3c329a", - "reference": "2bb2cba685aabd859f22cf6946554e8e7f3c329a", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/8b0f963293aede77593c9845c8c0af34752e893a", + "reference": "8b0f963293aede77593c9845c8c0af34752e893a", "shasum": "" }, "require": { @@ -9754,7 +8829,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.4.23" + "source": "https://github.com/symfony/http-kernel/tree/v6.4.26" }, "funding": [ { @@ -9765,25 +8840,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-28T08:14:51+00:00" + "time": "2025-09-27T12:20:56+00:00" }, { "name": "symfony/mailer", - "version": "v6.4.23", + "version": "v6.4.26", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "a480322ddf8e54de262c9bca31fdcbe26b553de5" + "reference": "012185cd31689b799d39505bd706be6d3a57cd3f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/a480322ddf8e54de262c9bca31fdcbe26b553de5", - "reference": "a480322ddf8e54de262c9bca31fdcbe26b553de5", + "url": "https://api.github.com/repos/symfony/mailer/zipball/012185cd31689b799d39505bd706be6d3a57cd3f", + "reference": "012185cd31689b799d39505bd706be6d3a57cd3f", "shasum": "" }, "require": { @@ -9834,7 +8913,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v6.4.23" + "source": "https://github.com/symfony/mailer/tree/v6.4.26" }, "funding": [ { @@ -9845,25 +8924,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-26T21:24:02+00:00" + "time": "2025-09-11T09:57:09+00:00" }, { "name": "symfony/mime", - "version": "v6.4.21", + "version": "v6.4.26", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "fec8aa5231f3904754955fad33c2db50594d22d1" + "reference": "61ab9681cdfe315071eb4fa79b6ad6ab030a9235" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/fec8aa5231f3904754955fad33c2db50594d22d1", - "reference": "fec8aa5231f3904754955fad33c2db50594d22d1", + "url": "https://api.github.com/repos/symfony/mime/zipball/61ab9681cdfe315071eb4fa79b6ad6ab030a9235", + "reference": "61ab9681cdfe315071eb4fa79b6ad6ab030a9235", "shasum": "" }, "require": { @@ -9919,7 +9002,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.4.21" + "source": "https://github.com/symfony/mime/tree/v6.4.26" }, "funding": [ { @@ -9930,16 +9013,20 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-27T13:27:38+00:00" + "time": "2025-09-16T08:22:30+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -9998,7 +9085,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" }, "funding": [ { @@ -10009,6 +9096,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -10018,16 +9109,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", "shasum": "" }, "require": { @@ -10076,7 +9167,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" }, "funding": [ { @@ -10087,16 +9178,20 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2025-06-27T09:58:17+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", @@ -10159,7 +9254,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" }, "funding": [ { @@ -10170,6 +9265,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -10179,7 +9278,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -10240,7 +9339,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" }, "funding": [ { @@ -10251,6 +9350,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -10260,7 +9363,7 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", @@ -10321,7 +9424,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" }, "funding": [ { @@ -10332,6 +9435,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -10341,7 +9448,7 @@ }, { "name": "symfony/polyfill-php73", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php73.git", @@ -10397,7 +9504,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php73/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php73/tree/v1.33.0" }, "funding": [ { @@ -10408,6 +9515,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -10417,7 +9528,7 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", @@ -10477,7 +9588,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" }, "funding": [ { @@ -10488,6 +9599,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -10497,7 +9612,7 @@ }, { "name": "symfony/polyfill-php81", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", @@ -10553,7 +9668,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" }, "funding": [ { @@ -10564,6 +9679,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -10573,16 +9692,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491" + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/2fb86d65e2d424369ad2905e83b236a8805ba491", - "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", "shasum": "" }, "require": { @@ -10629,7 +9748,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" }, "funding": [ { @@ -10640,16 +9759,20 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2025-07-08T02:45:35+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", @@ -10708,7 +9831,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" }, "funding": [ { @@ -10719,6 +9842,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -10728,16 +9855,16 @@ }, { "name": "symfony/process", - "version": "v6.4.20", + "version": "v6.4.26", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "e2a61c16af36c9a07e5c9906498b73e091949a20" + "reference": "48bad913268c8cafabbf7034b39c8bb24fbc5ab8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/e2a61c16af36c9a07e5c9906498b73e091949a20", - "reference": "e2a61c16af36c9a07e5c9906498b73e091949a20", + "url": "https://api.github.com/repos/symfony/process/zipball/48bad913268c8cafabbf7034b39c8bb24fbc5ab8", + "reference": "48bad913268c8cafabbf7034b39c8bb24fbc5ab8", "shasum": "" }, "require": { @@ -10769,7 +9896,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.20" + "source": "https://github.com/symfony/process/tree/v6.4.26" }, "funding": [ { @@ -10780,25 +9907,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-10T17:11:00+00:00" + "time": "2025-09-11T09:57:09+00:00" }, { "name": "symfony/routing", - "version": "v6.4.22", + "version": "v6.4.26", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "1f5234e8457164a3a0038a4c0a4ba27876a9c670" + "reference": "6fc4c445f22857d4b8b40a02b73f423ddab295de" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/1f5234e8457164a3a0038a4c0a4ba27876a9c670", - "reference": "1f5234e8457164a3a0038a4c0a4ba27876a9c670", + "url": "https://api.github.com/repos/symfony/routing/zipball/6fc4c445f22857d4b8b40a02b73f423ddab295de", + "reference": "6fc4c445f22857d4b8b40a02b73f423ddab295de", "shasum": "" }, "require": { @@ -10852,7 +9983,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v6.4.22" + "source": "https://github.com/symfony/routing/tree/v6.4.26" }, "funding": [ { @@ -10863,12 +9994,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-27T16:08:38+00:00" + "time": "2025-09-11T09:57:09+00:00" }, { "name": "symfony/service-contracts", @@ -10955,16 +10090,16 @@ }, { "name": "symfony/string", - "version": "v6.4.21", + "version": "v6.4.26", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "73e2c6966a5aef1d4892873ed5322245295370c6" + "reference": "5621f039a71a11c87c106c1c598bdcd04a19aeea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/73e2c6966a5aef1d4892873ed5322245295370c6", - "reference": "73e2c6966a5aef1d4892873ed5322245295370c6", + "url": "https://api.github.com/repos/symfony/string/zipball/5621f039a71a11c87c106c1c598bdcd04a19aeea", + "reference": "5621f039a71a11c87c106c1c598bdcd04a19aeea", "shasum": "" }, "require": { @@ -10978,7 +10113,6 @@ "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/error-handler": "^5.4|^6.0|^7.0", "symfony/http-client": "^5.4|^6.0|^7.0", "symfony/intl": "^6.2|^7.0", "symfony/translation-contracts": "^2.5|^3.0", @@ -11021,7 +10155,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.21" + "source": "https://github.com/symfony/string/tree/v6.4.26" }, "funding": [ { @@ -11032,25 +10166,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-18T15:23:29+00:00" + "time": "2025-09-11T14:32:46+00:00" }, { "name": "symfony/translation", - "version": "v6.4.23", + "version": "v6.4.26", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "de8afa521e04a5220e9e58a1dc99971ab7cac643" + "reference": "c8559fe25c7ee7aa9d28f228903a46db008156a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/de8afa521e04a5220e9e58a1dc99971ab7cac643", - "reference": "de8afa521e04a5220e9e58a1dc99971ab7cac643", + "url": "https://api.github.com/repos/symfony/translation/zipball/c8559fe25c7ee7aa9d28f228903a46db008156a4", + "reference": "c8559fe25c7ee7aa9d28f228903a46db008156a4", "shasum": "" }, "require": { @@ -11116,7 +10254,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v6.4.23" + "source": "https://github.com/symfony/translation/tree/v6.4.26" }, "funding": [ { @@ -11127,12 +10265,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-26T21:24:02+00:00" + "time": "2025-09-05T18:17:25+00:00" }, { "name": "symfony/translation-contracts", @@ -11214,16 +10356,16 @@ }, { "name": "symfony/uid", - "version": "v6.4.23", + "version": "v6.4.24", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "9c8592da78d7ee6af52011eef593350d87e814c0" + "reference": "17da16a750541a42cf2183935e0f6008316c23f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/9c8592da78d7ee6af52011eef593350d87e814c0", - "reference": "9c8592da78d7ee6af52011eef593350d87e814c0", + "url": "https://api.github.com/repos/symfony/uid/zipball/17da16a750541a42cf2183935e0f6008316c23f7", + "reference": "17da16a750541a42cf2183935e0f6008316c23f7", "shasum": "" }, "require": { @@ -11268,7 +10410,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v6.4.23" + "source": "https://github.com/symfony/uid/tree/v6.4.24" }, "funding": [ { @@ -11279,25 +10421,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-26T08:06:12+00:00" + "time": "2025-07-10T08:14:14+00:00" }, { "name": "symfony/var-dumper", - "version": "v6.4.23", + "version": "v6.4.26", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "d55b1834cdbfcc31bc2cd7e095ba5ed9a88f6600" + "reference": "cfae1497a2f1eaad78dbc0590311c599c7178d4a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/d55b1834cdbfcc31bc2cd7e095ba5ed9a88f6600", - "reference": "d55b1834cdbfcc31bc2cd7e095ba5ed9a88f6600", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/cfae1497a2f1eaad78dbc0590311c599c7178d4a", + "reference": "cfae1497a2f1eaad78dbc0590311c599c7178d4a", "shasum": "" }, "require": { @@ -11309,7 +10455,6 @@ "symfony/console": "<5.4" }, "require-dev": { - "ext-iconv": "*", "symfony/console": "^5.4|^6.0|^7.0", "symfony/error-handler": "^6.3|^7.0", "symfony/http-kernel": "^5.4|^6.0|^7.0", @@ -11353,7 +10498,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.4.23" + "source": "https://github.com/symfony/var-dumper/tree/v6.4.26" }, "funding": [ { @@ -11364,25 +10509,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T15:05:27+00:00" + "time": "2025-09-25T15:37:27+00:00" }, { "name": "symfony/var-exporter", - "version": "v6.4.22", + "version": "v6.4.26", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "f28cf841f5654955c9f88ceaf4b9dc29571988a9" + "reference": "466fcac5fa2e871f83d31173f80e9c2684743bfc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/f28cf841f5654955c9f88ceaf4b9dc29571988a9", - "reference": "f28cf841f5654955c9f88ceaf4b9dc29571988a9", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/466fcac5fa2e871f83d31173f80e9c2684743bfc", + "reference": "466fcac5fa2e871f83d31173f80e9c2684743bfc", "shasum": "" }, "require": { @@ -11430,7 +10579,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v6.4.22" + "source": "https://github.com/symfony/var-exporter/tree/v6.4.26" }, "funding": [ { @@ -11441,25 +10590,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-14T13:00:13+00:00" + "time": "2025-09-11T09:57:09+00:00" }, { "name": "symfony/yaml", - "version": "v6.4.13", + "version": "v6.4.26", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "e99b4e94d124b29ee4cf3140e1b537d2dad8cec9" + "reference": "0fc8b966fd0dcaab544ae59bfc3a433f048c17b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/e99b4e94d124b29ee4cf3140e1b537d2dad8cec9", - "reference": "e99b4e94d124b29ee4cf3140e1b537d2dad8cec9", + "url": "https://api.github.com/repos/symfony/yaml/zipball/0fc8b966fd0dcaab544ae59bfc3a433f048c17b0", + "reference": "0fc8b966fd0dcaab544ae59bfc3a433f048c17b0", "shasum": "" }, "require": { @@ -11502,7 +10655,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v6.4.13" + "source": "https://github.com/symfony/yaml/tree/v6.4.26" }, "funding": [ { @@ -11513,12 +10666,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2025-09-26T15:07:38+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -11795,44 +10952,44 @@ "packages-dev": [ { "name": "barryvdh/laravel-debugbar", - "version": "v3.14.6", + "version": "v3.16.0", "source": { "type": "git", "url": "https://github.com/barryvdh/laravel-debugbar.git", - "reference": "14e4517bd49130d6119228107eb21ae47ae120ab" + "reference": "f265cf5e38577d42311f1a90d619bcd3740bea23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/14e4517bd49130d6119228107eb21ae47ae120ab", - "reference": "14e4517bd49130d6119228107eb21ae47ae120ab", + "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/f265cf5e38577d42311f1a90d619bcd3740bea23", + "reference": "f265cf5e38577d42311f1a90d619bcd3740bea23", "shasum": "" }, "require": { - "illuminate/routing": "^9|^10|^11", - "illuminate/session": "^9|^10|^11", - "illuminate/support": "^9|^10|^11", - "maximebf/debugbar": "~1.23.0", - "php": "^8.0", + "illuminate/routing": "^9|^10|^11|^12", + "illuminate/session": "^9|^10|^11|^12", + "illuminate/support": "^9|^10|^11|^12", + "php": "^8.1", + "php-debugbar/php-debugbar": "~2.2.0", "symfony/finder": "^6|^7" }, "require-dev": { "mockery/mockery": "^1.3.3", - "orchestra/testbench-dusk": "^5|^6|^7|^8|^9", - "phpunit/phpunit": "^9.6|^10.5", + "orchestra/testbench-dusk": "^7|^8|^9|^10", + "phpunit/phpunit": "^9.5.10|^10|^11", "squizlabs/php_codesniffer": "^3.5" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "3.14-dev" - }, "laravel": { - "providers": [ - "Barryvdh\\Debugbar\\ServiceProvider" - ], "aliases": { "Debugbar": "Barryvdh\\Debugbar\\Facades\\Debugbar" - } + }, + "providers": [ + "Barryvdh\\Debugbar\\ServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.16-dev" } }, "autoload": { @@ -11857,13 +11014,14 @@ "keywords": [ "debug", "debugbar", + "dev", "laravel", "profiler", "webprofiler" ], "support": { "issues": "https://github.com/barryvdh/laravel-debugbar/issues", - "source": "https://github.com/barryvdh/laravel-debugbar/tree/v3.14.6" + "source": "https://github.com/barryvdh/laravel-debugbar/tree/v3.16.0" }, "funding": [ { @@ -11875,7 +11033,7 @@ "type": "github" } ], - "time": "2024-10-18T13:15:12+00:00" + "time": "2025-07-14T11:56:43+00:00" }, { "name": "barryvdh/laravel-ide-helper", @@ -11919,13 +11077,13 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "3.1-dev" - }, "laravel": { "providers": [ "Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider" ] + }, + "branch-alias": { + "dev-master": "3.1-dev" } }, "autoload": { @@ -11973,20 +11131,20 @@ }, { "name": "barryvdh/reflection-docblock", - "version": "v2.1.3", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/barryvdh/ReflectionDocBlock.git", - "reference": "c6fad15f7c878be21650c51e1f841bca7e49752e" + "reference": "d103774cbe7e94ddee7e4870f97f727b43fe7201" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/barryvdh/ReflectionDocBlock/zipball/c6fad15f7c878be21650c51e1f841bca7e49752e", - "reference": "c6fad15f7c878be21650c51e1f841bca7e49752e", + "url": "https://api.github.com/repos/barryvdh/ReflectionDocBlock/zipball/d103774cbe7e94ddee7e4870f97f727b43fe7201", + "reference": "d103774cbe7e94ddee7e4870f97f727b43fe7201", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": ">=7.1" }, "require-dev": { "phpunit/phpunit": "^8.5.14|^9" @@ -11998,7 +11156,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-master": "2.3.x-dev" } }, "autoload": { @@ -12019,9 +11177,9 @@ } ], "support": { - "source": "https://github.com/barryvdh/ReflectionDocBlock/tree/v2.1.3" + "source": "https://github.com/barryvdh/ReflectionDocBlock/tree/v2.4.0" }, - "time": "2024-10-23T11:41:03+00:00" + "time": "2025-07-17T06:07:30+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -12076,16 +11234,16 @@ }, { "name": "laravel/pint", - "version": "v1.19.0", + "version": "v1.20.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "8169513746e1bac70c85d6ea1524d9225d4886f0" + "reference": "53072e8ea22213a7ed168a8a15b96fbb8b82d44b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/8169513746e1bac70c85d6ea1524d9225d4886f0", - "reference": "8169513746e1bac70c85d6ea1524d9225d4886f0", + "url": "https://api.github.com/repos/laravel/pint/zipball/53072e8ea22213a7ed168a8a15b96fbb8b82d44b", + "reference": "53072e8ea22213a7ed168a8a15b96fbb8b82d44b", "shasum": "" }, "require": { @@ -12138,32 +11296,32 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2024-12-30T16:20:10+00:00" + "time": "2025-01-14T16:20:53+00:00" }, { "name": "laravel/sail", - "version": "v1.37.1", + "version": "v1.46.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "7efa151ea0d16f48233d6a6cd69f81270acc6e93" + "reference": "eb90c4f113c4a9637b8fdd16e24cfc64f2b0ae6e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/7efa151ea0d16f48233d6a6cd69f81270acc6e93", - "reference": "7efa151ea0d16f48233d6a6cd69f81270acc6e93", + "url": "https://api.github.com/repos/laravel/sail/zipball/eb90c4f113c4a9637b8fdd16e24cfc64f2b0ae6e", + "reference": "eb90c4f113c4a9637b8fdd16e24cfc64f2b0ae6e", "shasum": "" }, "require": { - "illuminate/console": "^9.52.16|^10.0|^11.0", - "illuminate/contracts": "^9.52.16|^10.0|^11.0", - "illuminate/support": "^9.52.16|^10.0|^11.0", + "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0", + "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0", + "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0", "php": "^8.0", "symfony/console": "^6.0|^7.0", "symfony/yaml": "^6.0|^7.0" }, "require-dev": { - "orchestra/testbench": "^7.0|^8.0|^9.0", + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", "phpstan/phpstan": "^1.10" }, "bin": [ @@ -12201,25 +11359,25 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2024-10-29T20:18:14+00:00" + "time": "2025-09-23T13:44:39+00:00" }, { "name": "laravel/telescope", - "version": "v5.2.6", + "version": "v5.14.0", "source": { "type": "git", "url": "https://github.com/laravel/telescope.git", - "reference": "7ee46fbea8e3b01108575c8edf7377abddfe8bb9" + "reference": "99e9487604e67424d07b83b5ab3401850d237685" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/telescope/zipball/7ee46fbea8e3b01108575c8edf7377abddfe8bb9", - "reference": "7ee46fbea8e3b01108575c8edf7377abddfe8bb9", + "url": "https://api.github.com/repos/laravel/telescope/zipball/99e9487604e67424d07b83b5ab3401850d237685", + "reference": "99e9487604e67424d07b83b5ab3401850d237685", "shasum": "" }, "require": { "ext-json": "*", - "laravel/framework": "^8.37|^9.0|^10.0|^11.0", + "laravel/framework": "^8.37|^9.0|^10.0|^11.0|^12.0", "php": "^8.0", "symfony/console": "^5.3|^6.0|^7.0", "symfony/var-dumper": "^5.0|^6.0|^7.0" @@ -12228,9 +11386,9 @@ "ext-gd": "*", "guzzlehttp/guzzle": "^6.0|^7.0", "laravel/octane": "^1.4|^2.0|dev-develop", - "orchestra/testbench": "^6.40|^7.37|^8.17|^9.0", + "orchestra/testbench": "^6.40|^7.37|^8.17|^9.0|^10.0", "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^9.0|^10.5" + "phpunit/phpunit": "^9.0|^10.5|^11.5" }, "type": "library", "extra": { @@ -12268,77 +11426,9 @@ ], "support": { "issues": "https://github.com/laravel/telescope/issues", - "source": "https://github.com/laravel/telescope/tree/v5.2.6" - }, - "time": "2024-11-25T20:34:58+00:00" - }, - { - "name": "maximebf/debugbar", - "version": "v1.23.3", - "source": { - "type": "git", - "url": "https://github.com/maximebf/php-debugbar.git", - "reference": "687400043d77943ef95e8417cb44e1673ee57844" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/maximebf/php-debugbar/zipball/687400043d77943ef95e8417cb44e1673ee57844", - "reference": "687400043d77943ef95e8417cb44e1673ee57844", - "shasum": "" - }, - "require": { - "php": "^7.2|^8", - "psr/log": "^1|^2|^3", - "symfony/var-dumper": "^4|^5|^6|^7" - }, - "require-dev": { - "dbrekelmans/bdi": "^1", - "phpunit/phpunit": "^8|^9", - "symfony/panther": "^1|^2.1", - "twig/twig": "^1.38|^2.7|^3.0" - }, - "suggest": { - "kriswallsmith/assetic": "The best way to manage assets", - "monolog/monolog": "Log using Monolog", - "predis/predis": "Redis storage" + "source": "https://github.com/laravel/telescope/tree/v5.14.0" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.23-dev" - } - }, - "autoload": { - "psr-4": { - "DebugBar\\": "src/DebugBar/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Maxime Bouroumeau-Fuseau", - "email": "maxime.bouroumeau@gmail.com", - "homepage": "http://maximebf.com" - }, - { - "name": "Barry vd. Heuvel", - "email": "barryvdh@gmail.com" - } - ], - "description": "Debug bar in the browser for php application", - "homepage": "https://github.com/maximebf/php-debugbar", - "keywords": [ - "debug", - "debugbar" - ], - "support": { - "issues": "https://github.com/maximebf/php-debugbar/issues", - "source": "https://github.com/maximebf/php-debugbar/tree/v1.23.3" - }, - "time": "2024-10-29T12:24:25+00:00" + "time": "2025-10-06T14:52:49+00:00" }, { "name": "mockery/mockery", @@ -12425,16 +11515,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.12.0", + "version": "1.13.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c" + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c", - "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { @@ -12473,7 +11563,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.12.0" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { @@ -12481,7 +11571,7 @@ "type": "tidelift" } ], - "time": "2024-06-12T14:39:25+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { "name": "phar-io/manifest", @@ -12601,6 +11691,79 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "php-debugbar/php-debugbar", + "version": "v2.2.4", + "source": { + "type": "git", + "url": "https://github.com/php-debugbar/php-debugbar.git", + "reference": "3146d04671f51f69ffec2a4207ac3bdcf13a9f35" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/3146d04671f51f69ffec2a4207ac3bdcf13a9f35", + "reference": "3146d04671f51f69ffec2a4207ac3bdcf13a9f35", + "shasum": "" + }, + "require": { + "php": "^8", + "psr/log": "^1|^2|^3", + "symfony/var-dumper": "^4|^5|^6|^7" + }, + "replace": { + "maximebf/debugbar": "self.version" + }, + "require-dev": { + "dbrekelmans/bdi": "^1", + "phpunit/phpunit": "^8|^9", + "symfony/panther": "^1|^2.1", + "twig/twig": "^1.38|^2.7|^3.0" + }, + "suggest": { + "kriswallsmith/assetic": "The best way to manage assets", + "monolog/monolog": "Log using Monolog", + "predis/predis": "Redis storage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "psr-4": { + "DebugBar\\": "src/DebugBar/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maxime Bouroumeau-Fuseau", + "email": "maxime.bouroumeau@gmail.com", + "homepage": "http://maximebf.com" + }, + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "Debug bar in the browser for php application", + "homepage": "https://github.com/php-debugbar/php-debugbar", + "keywords": [ + "debug", + "debug bar", + "debugbar", + "dev" + ], + "support": { + "issues": "https://github.com/php-debugbar/php-debugbar/issues", + "source": "https://github.com/php-debugbar/php-debugbar/tree/v2.2.4" + }, + "time": "2025-07-22T14:01:30+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "10.1.16", @@ -12924,16 +12087,16 @@ }, { "name": "phpunit/phpunit", - "version": "10.5.38", + "version": "10.5.58", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "a86773b9e887a67bc53efa9da9ad6e3f2498c132" + "reference": "e24fb46da450d8e6a5788670513c1af1424f16ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a86773b9e887a67bc53efa9da9ad6e3f2498c132", - "reference": "a86773b9e887a67bc53efa9da9ad6e3f2498c132", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e24fb46da450d8e6a5788670513c1af1424f16ca", + "reference": "e24fb46da450d8e6a5788670513c1af1424f16ca", "shasum": "" }, "require": { @@ -12943,7 +12106,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.0", + "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.1", @@ -12954,13 +12117,13 @@ "phpunit/php-timer": "^6.0.0", "sebastian/cli-parser": "^2.0.1", "sebastian/code-unit": "^2.0.0", - "sebastian/comparator": "^5.0.3", + "sebastian/comparator": "^5.0.4", "sebastian/diff": "^5.1.1", "sebastian/environment": "^6.1.0", - "sebastian/exporter": "^5.1.2", + "sebastian/exporter": "^5.1.4", "sebastian/global-state": "^6.0.2", "sebastian/object-enumerator": "^5.0.0", - "sebastian/recursion-context": "^5.0.0", + "sebastian/recursion-context": "^5.0.1", "sebastian/type": "^4.0.0", "sebastian/version": "^4.0.1" }, @@ -13005,7 +12168,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.38" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.58" }, "funding": [ { @@ -13016,12 +12179,20 @@ "url": "https://github.com/sebastianbergmann", "type": "github" }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", "type": "tidelift" } ], - "time": "2024-10-28T13:06:21+00:00" + "time": "2025-09-28T12:04:46+00:00" }, { "name": "sebastian/cli-parser", @@ -13193,16 +12364,16 @@ }, { "name": "sebastian/comparator", - "version": "5.0.3", + "version": "5.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e" + "reference": "e8e53097718d2b53cfb2aa859b06a41abf58c62e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e", - "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e8e53097718d2b53cfb2aa859b06a41abf58c62e", + "reference": "e8e53097718d2b53cfb2aa859b06a41abf58c62e", "shasum": "" }, "require": { @@ -13258,15 +12429,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.3" + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.4" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" } ], - "time": "2024-10-18T14:56:07+00:00" + "time": "2025-09-07T05:25:07+00:00" }, { "name": "sebastian/complexity", @@ -13459,16 +12642,16 @@ }, { "name": "sebastian/exporter", - "version": "5.1.2", + "version": "5.1.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "955288482d97c19a372d3f31006ab3f37da47adf" + "reference": "0735b90f4da94969541dac1da743446e276defa6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/955288482d97c19a372d3f31006ab3f37da47adf", - "reference": "955288482d97c19a372d3f31006ab3f37da47adf", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", + "reference": "0735b90f4da94969541dac1da743446e276defa6", "shasum": "" }, "require": { @@ -13477,7 +12660,7 @@ "sebastian/recursion-context": "^5.0" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^10.5" }, "type": "library", "extra": { @@ -13525,15 +12708,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T07:17:12+00:00" + "time": "2025-09-24T06:09:11+00:00" }, { "name": "sebastian/global-state", @@ -13769,23 +12964,23 @@ }, { "name": "sebastian/recursion-context", - "version": "5.0.0", + "version": "5.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "05909fb5bc7df4c52992396d0116aed689f93712" + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/05909fb5bc7df4c52992396d0116aed689f93712", - "reference": "05909fb5bc7df4c52992396d0116aed689f93712", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/47e34210757a2f37a97dcd207d032e1b01e64c7a", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a", "shasum": "" }, "require": { "php": ">=8.1" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^10.5" }, "type": "library", "extra": { @@ -13820,15 +13015,28 @@ "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.0" + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2023-02-03T07:05:40+00:00" + "time": "2025-08-10T07:50:56+00:00" }, { "name": "sebastian/type", @@ -13941,16 +13149,16 @@ }, { "name": "spatie/backtrace", - "version": "1.7.1", + "version": "1.8.1", "source": { "type": "git", "url": "https://github.com/spatie/backtrace.git", - "reference": "0f2477c520e3729de58e061b8192f161c99f770b" + "reference": "8c0f16a59ae35ec8c62d85c3c17585158f430110" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/backtrace/zipball/0f2477c520e3729de58e061b8192f161c99f770b", - "reference": "0f2477c520e3729de58e061b8192f161c99f770b", + "url": "https://api.github.com/repos/spatie/backtrace/zipball/8c0f16a59ae35ec8c62d85c3c17585158f430110", + "reference": "8c0f16a59ae35ec8c62d85c3c17585158f430110", "shasum": "" }, "require": { @@ -13988,7 +13196,8 @@ "spatie" ], "support": { - "source": "https://github.com/spatie/backtrace/tree/1.7.1" + "issues": "https://github.com/spatie/backtrace/issues", + "source": "https://github.com/spatie/backtrace/tree/1.8.1" }, "funding": [ { @@ -14000,34 +13209,34 @@ "type": "other" } ], - "time": "2024-12-02T13:28:15+00:00" + "time": "2025-08-26T08:22:30+00:00" }, { "name": "spatie/error-solutions", - "version": "1.1.1", + "version": "1.1.3", "source": { "type": "git", "url": "https://github.com/spatie/error-solutions.git", - "reference": "ae7393122eda72eed7cc4f176d1e96ea444f2d67" + "reference": "e495d7178ca524f2dd0fe6a1d99a1e608e1c9936" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/error-solutions/zipball/ae7393122eda72eed7cc4f176d1e96ea444f2d67", - "reference": "ae7393122eda72eed7cc4f176d1e96ea444f2d67", + "url": "https://api.github.com/repos/spatie/error-solutions/zipball/e495d7178ca524f2dd0fe6a1d99a1e608e1c9936", + "reference": "e495d7178ca524f2dd0fe6a1d99a1e608e1c9936", "shasum": "" }, "require": { "php": "^8.0" }, "require-dev": { - "illuminate/broadcasting": "^10.0|^11.0", - "illuminate/cache": "^10.0|^11.0", - "illuminate/support": "^10.0|^11.0", - "livewire/livewire": "^2.11|^3.3.5", + "illuminate/broadcasting": "^10.0|^11.0|^12.0", + "illuminate/cache": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "livewire/livewire": "^2.11|^3.5.20", "openai-php/client": "^0.10.1", - "orchestra/testbench": "^7.0|8.22.3|^9.0", - "pestphp/pest": "^2.20", - "phpstan/phpstan": "^1.11", + "orchestra/testbench": "8.22.3|^9.0|^10.0", + "pestphp/pest": "^2.20|^3.0", + "phpstan/phpstan": "^2.1", "psr/simple-cache": "^3.0", "psr/simple-cache-implementation": "^3.0", "spatie/ray": "^1.28", @@ -14066,7 +13275,7 @@ ], "support": { "issues": "https://github.com/spatie/error-solutions/issues", - "source": "https://github.com/spatie/error-solutions/tree/1.1.1" + "source": "https://github.com/spatie/error-solutions/tree/1.1.3" }, "funding": [ { @@ -14074,24 +13283,24 @@ "type": "github" } ], - "time": "2024-07-25T11:06:04+00:00" + "time": "2025-02-14T12:29:50+00:00" }, { "name": "spatie/flare-client-php", - "version": "1.10.0", + "version": "1.10.1", "source": { "type": "git", "url": "https://github.com/spatie/flare-client-php.git", - "reference": "140a42b2c5d59ac4ecf8f5b493386a4f2eb28272" + "reference": "bf1716eb98bd689451b071548ae9e70738dce62f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/flare-client-php/zipball/140a42b2c5d59ac4ecf8f5b493386a4f2eb28272", - "reference": "140a42b2c5d59ac4ecf8f5b493386a4f2eb28272", + "url": "https://api.github.com/repos/spatie/flare-client-php/zipball/bf1716eb98bd689451b071548ae9e70738dce62f", + "reference": "bf1716eb98bd689451b071548ae9e70738dce62f", "shasum": "" }, "require": { - "illuminate/pipeline": "^8.0|^9.0|^10.0|^11.0", + "illuminate/pipeline": "^8.0|^9.0|^10.0|^11.0|^12.0", "php": "^8.0", "spatie/backtrace": "^1.6.1", "symfony/http-foundation": "^5.2|^6.0|^7.0", @@ -14135,7 +13344,7 @@ ], "support": { "issues": "https://github.com/spatie/flare-client-php/issues", - "source": "https://github.com/spatie/flare-client-php/tree/1.10.0" + "source": "https://github.com/spatie/flare-client-php/tree/1.10.1" }, "funding": [ { @@ -14143,20 +13352,20 @@ "type": "github" } ], - "time": "2024-12-02T14:30:06+00:00" + "time": "2025-02-14T13:42:06+00:00" }, { "name": "spatie/ignition", - "version": "1.15.0", + "version": "1.15.1", "source": { "type": "git", "url": "https://github.com/spatie/ignition.git", - "reference": "e3a68e137371e1eb9edc7f78ffa733f3b98991d2" + "reference": "31f314153020aee5af3537e507fef892ffbf8c85" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/ignition/zipball/e3a68e137371e1eb9edc7f78ffa733f3b98991d2", - "reference": "e3a68e137371e1eb9edc7f78ffa733f3b98991d2", + "url": "https://api.github.com/repos/spatie/ignition/zipball/31f314153020aee5af3537e507fef892ffbf8c85", + "reference": "31f314153020aee5af3537e507fef892ffbf8c85", "shasum": "" }, "require": { @@ -14169,7 +13378,7 @@ "symfony/var-dumper": "^5.4|^6.0|^7.0" }, "require-dev": { - "illuminate/cache": "^9.52|^10.0|^11.0", + "illuminate/cache": "^9.52|^10.0|^11.0|^12.0", "mockery/mockery": "^1.4", "pestphp/pest": "^1.20|^2.0", "phpstan/extension-installer": "^1.1", @@ -14226,27 +13435,27 @@ "type": "github" } ], - "time": "2024-06-12T14:55:22+00:00" + "time": "2025-02-21T14:31:39+00:00" }, { "name": "spatie/laravel-ignition", - "version": "2.9.0", + "version": "2.9.1", "source": { "type": "git", "url": "https://github.com/spatie/laravel-ignition.git", - "reference": "62042df15314b829d0f26e02108f559018e2aad0" + "reference": "1baee07216d6748ebd3a65ba97381b051838707a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/62042df15314b829d0f26e02108f559018e2aad0", - "reference": "62042df15314b829d0f26e02108f559018e2aad0", + "url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/1baee07216d6748ebd3a65ba97381b051838707a", + "reference": "1baee07216d6748ebd3a65ba97381b051838707a", "shasum": "" }, "require": { "ext-curl": "*", "ext-json": "*", "ext-mbstring": "*", - "illuminate/support": "^10.0|^11.0", + "illuminate/support": "^10.0|^11.0|^12.0", "php": "^8.1", "spatie/ignition": "^1.15", "symfony/console": "^6.2.3|^7.0", @@ -14255,12 +13464,12 @@ "require-dev": { "livewire/livewire": "^2.11|^3.3.5", "mockery/mockery": "^1.5.1", - "openai-php/client": "^0.8.1", - "orchestra/testbench": "8.22.3|^9.0", - "pestphp/pest": "^2.34", + "openai-php/client": "^0.8.1|^0.10", + "orchestra/testbench": "8.22.3|^9.0|^10.0", + "pestphp/pest": "^2.34|^3.7", "phpstan/extension-installer": "^1.3.1", - "phpstan/phpstan-deprecation-rules": "^1.1.1", - "phpstan/phpstan-phpunit": "^1.3.16", + "phpstan/phpstan-deprecation-rules": "^1.1.1|^2.0", + "phpstan/phpstan-phpunit": "^1.3.16|^2.0", "vlucas/phpdotenv": "^5.5" }, "suggest": { @@ -14317,7 +13526,7 @@ "type": "github" } ], - "time": "2024-12-02T08:43:31+00:00" + "time": "2025-02-20T13:13:55+00:00" }, { "name": "theseer/tokenizer", From 1ac89263b1ad84539ef60829e4a6cf67ca5c3877 Mon Sep 17 00:00:00 2001 From: oliviasculley Date: Sun, 6 Jul 2025 17:01:43 +0000 Subject: [PATCH 08/19] add changes to get slash commands and message updating working --- app-modules/slack-events-bot/routes/api.php | 9 + app-modules/slack-events-bot/routes/web.php | 4 - .../src/Http/Controllers/SlackController.php | 68 ++++- .../src/Jobs/CheckEventsApi.php | 41 +++ .../src/Models/SlackWorkspace.php | 15 ++ .../SlackEventsBotServiceProvider.php | 57 +++-- .../src/Services/AuthService.php | 18 +- .../src/Services/BotService.php | 240 +++++++++++------- .../src/Services/DatabaseService.php | 55 +++- .../src/Services/EventService.php | 15 +- .../src/Services/MessageBuilderService.php | 37 ++- .../tests/SlackEventsBotTest.php | 118 +++++---- app/Models/Venue.php | 9 +- config/app.php | 6 + ...6_000000_create_slack_workspaces_table.php | 27 ++ .../2025_07_06_112939_create_jobs_table.php | 31 +++ ..._07_06_113105_create_failed_jobs_table.php | 31 +++ 17 files changed, 591 insertions(+), 190 deletions(-) create mode 100644 app-modules/slack-events-bot/routes/api.php create mode 100644 app-modules/slack-events-bot/src/Jobs/CheckEventsApi.php create mode 100644 app-modules/slack-events-bot/src/Models/SlackWorkspace.php create mode 100644 database/migrations/2025_07_06_000000_create_slack_workspaces_table.php create mode 100644 database/migrations/2025_07_06_112939_create_jobs_table.php create mode 100644 database/migrations/2025_07_06_113105_create_failed_jobs_table.php diff --git a/app-modules/slack-events-bot/routes/api.php b/app-modules/slack-events-bot/routes/api.php new file mode 100644 index 00000000..bd4707ca --- /dev/null +++ b/app-modules/slack-events-bot/routes/api.php @@ -0,0 +1,9 @@ +middleware(ValidateSlackRequest::class) + ->name('events'); diff --git a/app-modules/slack-events-bot/routes/web.php b/app-modules/slack-events-bot/routes/web.php index a9a0c39b..20bfa810 100644 --- a/app-modules/slack-events-bot/routes/web.php +++ b/app-modules/slack-events-bot/routes/web.php @@ -1,13 +1,9 @@ group(function () { Route::get('/install', [SlackController::class, 'install'])->name('slack.install'); Route::get('/auth', [SlackController::class, 'auth'])->name('slack.auth'); - Route::post('/events', [SlackController::class, 'events']) - ->middleware(ValidateSlackRequest::class) - ->name('slack.events'); }); diff --git a/app-modules/slack-events-bot/src/Http/Controllers/SlackController.php b/app-modules/slack-events-bot/src/Http/Controllers/SlackController.php index 5db08e21..51ca62b1 100644 --- a/app-modules/slack-events-bot/src/Http/Controllers/SlackController.php +++ b/app-modules/slack-events-bot/src/Http/Controllers/SlackController.php @@ -3,6 +3,7 @@ namespace HackGreenville\SlackEventsBot\Http\Controllers; use Exception; +use HackGreenville\SlackEventsBot\Jobs\CheckEventsApi; use HackGreenville\SlackEventsBot\Services\AuthService; use HackGreenville\SlackEventsBot\Services\BotService; use HackGreenville\SlackEventsBot\Services\DatabaseService; @@ -57,21 +58,38 @@ public function auth(Request $request): Response return response("Something is wrong with the installation (error: {$error})", 400); } - if ($code && session('slack_oauth_state') === $state) { + $sessionState = session('slack_oauth_state'); + + if ($code && $sessionState === $state) { session()->forget('slack_oauth_state'); - $response = Http::post('https://slack.com/api/oauth.v2.access', [ + $response = Http::asForm()->post('https://slack.com/api/oauth.v2.access', [ 'client_id' => config('slack-events-bot.client_id'), 'client_secret' => config('slack-events-bot.client_secret'), 'code' => $code, ]); - if ($response->successful()) { + $jsonResponse = $response->json(); + + if ($response->successful() && ($jsonResponse['ok'] ?? false)) { + $this->databaseService->createOrUpdateWorkspace($jsonResponse); return response('The HackGreenville API bot has been installed successfully!'); } + + $error = $jsonResponse['error'] ?? 'unknown_error'; + + if ($error === 'invalid_code') { + Log::warning('Slack OAuth failed with invalid_code. This may be due to a user re-trying the auth flow or the code expiring.', [ + 'response' => $jsonResponse, + ]); + return response('The authorization code is invalid or has expired. Please try installing the app again.', 400); + } + + Log::error('Slack OAuth failed', ['error' => $error, 'response' => $jsonResponse]); + return response("Something is wrong with the installation (error: {$error})", 400); } - return response('Something is wrong with the installation', 400); + return response('Invalid state. Your session may have expired or you tried to use an old authorization link. Please try installing the app again.', 400); } public function events(Request $request): Response @@ -99,64 +117,98 @@ public function events(Request $request): Response private function handleSlashCommand(array $payload): Response { + Log::info('Handling slash command', ['payload' => $payload]); + $command = $payload['command']; $userId = $payload['user_id']; $channelId = $payload['channel_id']; $teamDomain = $payload['team_domain'] ?? null; + $originalCommand = $command; + + // Normalize dev commands to match production commands + // if (str_starts_with($command, '/dev_')) { + // $command = str_replace('/dev_', '/', $command); + // Log::info('Normalized dev command', ['original' => $originalCommand, 'normalized' => $command]); + // } + switch ($command) { case '/add_channel': + Log::info('Executing /add_channel command', ['user_id' => $userId, 'channel_id' => $channelId]); if ( ! $this->authService->isAdmin($userId)) { + Log::warning('/add_channel failed: user is not an admin', ['user_id' => $userId]); return response('You must be a workspace admin in order to run `/add_channel`'); } try { $this->databaseService->addChannel($channelId); + Log::info('Successfully added channel', ['channel_id' => $channelId]); return response('Added channel to slack events bot 👍'); } catch (Exception $e) { + Log::error('/add_channel failed: could not add channel', [ + 'channel_id' => $channelId, + 'exception' => $e->getMessage(), + ]); return response('Slack events bot has already been activated for this channel'); } case '/remove_channel': + Log::info('Executing /remove_channel command', ['user_id' => $userId, 'channel_id' => $channelId]); if ( ! $this->authService->isAdmin($userId)) { + Log::warning('/remove_channel failed: user is not an admin', ['user_id' => $userId]); return response('You must be a workspace admin in order to run `/remove_channel`'); } try { $this->databaseService->removeChannel($channelId); + Log::info('Successfully removed channel', ['channel_id' => $channelId]); return response('Removed channel from slack events bot 👍'); } catch (Exception $e) { + Log::error('/remove_channel failed: could not remove channel', [ + 'channel_id' => $channelId, + 'exception' => $e->getMessage(), + ]); return response('Slack events bot is not activated for this channel'); } case '/check_api': + Log::info('Executing /check_api command', ['user_id' => $userId, 'team_domain' => $teamDomain]); // Check cooldown if ($teamDomain) { $expiryTime = $this->databaseService->getCooldownExpiryTime($teamDomain, 'check_api'); if ($expiryTime && $expiryTime->isFuture()) { + Log::info('/check_api command on cooldown', [ + 'team_domain' => $teamDomain, + 'expires_at' => $expiryTime->toIso8601String(), + ]); return response( 'This command has been run recently and is on a cooldown period. ' . 'Please try again in a little while!' ); } + $cooldownMinutes = config('slack-events-bot.check_api_cooldown_minutes'); // Set new cooldown $this->databaseService->createCooldown( $teamDomain, 'check_api', - config('slack-events-bot.check_api_cooldown_minutes') + $cooldownMinutes ); + Log::info('/check_api cooldown created', [ + 'team_domain' => $teamDomain, + 'duration_minutes' => $cooldownMinutes, + ]); } // Run API check asynchronously - dispatch(function () { - $this->botService->handlePostingToSlack(); - })->afterResponse(); + CheckEventsApi::dispatch(); + Log::info('/check_api command successful, dispatched job.', ['user_id' => $userId]); return response('Checking api for events 👍'); default: + Log::warning('Unknown slash command received', ['command' => $originalCommand, 'payload' => $payload]); return response('Unknown command', 400); } } diff --git a/app-modules/slack-events-bot/src/Jobs/CheckEventsApi.php b/app-modules/slack-events-bot/src/Jobs/CheckEventsApi.php new file mode 100644 index 00000000..01117284 --- /dev/null +++ b/app-modules/slack-events-bot/src/Jobs/CheckEventsApi.php @@ -0,0 +1,41 @@ +handlePostingToSlack(); + Log::info('Finished CheckEventsApi job.'); + } catch (Throwable $e) { + Log::error('CheckEventsApi job failed with an exception.', [ + 'exception' => get_class($e), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString(), + ]); + throw $e; + } + } +} diff --git a/app-modules/slack-events-bot/src/Models/SlackWorkspace.php b/app-modules/slack-events-bot/src/Models/SlackWorkspace.php new file mode 100644 index 00000000..061d5437 --- /dev/null +++ b/app-modules/slack-events-bot/src/Models/SlackWorkspace.php @@ -0,0 +1,15 @@ +mergeConfigFrom( @@ -30,28 +35,30 @@ public function register(): void public function boot(): void { - // Publish config - $this->publishes([ - __DIR__ . '/../../config/slack-events-bot.php' => config_path('slack-events-bot.php'), - ], 'slack-events-bot-config'); - - // Load migrations - $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); - - // Load routes - $this->loadRoutesFrom(__DIR__ . '/../../routes/web.php'); - - // Register commands - if ($this->app->runningInConsole()) { - $this->commands([ - DeleteOldMessagesCommand::class, - ]); - } - - // Schedule tasks - $this->callAfterResolving(Schedule::class, function (Schedule $schedule) { - // Delete old messages once daily - $schedule->command('slack:delete-old-messages')->daily(); - }); + $this->loadRoutes(); + } + + protected function loadRoutes(): void + { + Route::prefix('slack') + ->middleware('api') + ->name('slack.') + ->group("{$this->moduleDir}/routes/api.php"); + + Route::middleware('web') + ->group("{$this->moduleDir}/routes/web.php"); + + // // Register commands + // if ($this->app->runningInConsole()) { + // $this->commands([ + // DeleteOldMessagesCommand::class, + // ]); + // } + + // // Schedule tasks + // $this->callAfterResolving(Schedule::class, function (Schedule $schedule) { + // // Delete old messages once daily + // $schedule->command('slack:delete-old-messages')->daily(); + // }); } } diff --git a/app-modules/slack-events-bot/src/Services/AuthService.php b/app-modules/slack-events-bot/src/Services/AuthService.php index 79ec5730..9ffbe830 100644 --- a/app-modules/slack-events-bot/src/Services/AuthService.php +++ b/app-modules/slack-events-bot/src/Services/AuthService.php @@ -4,6 +4,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; class AuthService { @@ -30,19 +31,32 @@ public function validateSlackRequest(Request $request): bool $signature = $request->header('X-Slack-Signature'); if ( ! $timestamp || ! $signature) { + Log::warning('Slack request validation failed: Missing timestamp or signature.'); return false; } // Check for possible replay attacks (5 minutes) - if (abs(time() - (int) $timestamp) > 60 * 5) { + if (abs(time() - (int)$timestamp) > 60 * 5) { + Log::warning('Slack request validation failed: Timestamp expired (replay attack?).', [ + 'request_timestamp' => $timestamp, + ]); return false; } // Verify signature $signingSecret = config('slack-events-bot.signing_secret'); + if (empty($signingSecret)) { + Log::error('Slack request validation failed: Signing secret is not configured.'); + return false; // Fail safe + } $sigBasestring = 'v0:' . $timestamp . ':' . $request->getContent(); $mySignature = 'v0=' . hash_hmac('sha256', $sigBasestring, $signingSecret); - return hash_equals($mySignature, $signature); + if ( ! hash_equals($mySignature, $signature)) { + Log::warning('Slack request validation failed: Signature mismatch.'); + return false; + } + + return true; } } diff --git a/app-modules/slack-events-bot/src/Services/BotService.php b/app-modules/slack-events-bot/src/Services/BotService.php index 705a5b4c..7465cc2f 100644 --- a/app-modules/slack-events-bot/src/Services/BotService.php +++ b/app-modules/slack-events-bot/src/Services/BotService.php @@ -4,6 +4,7 @@ use App\Models\Event; use Carbon\Carbon; +use Exception; use HackGreenville\SlackEventsBot\Exceptions\UnsafeMessageSpilloverException; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; @@ -22,113 +23,131 @@ public function postOrUpdateMessages(Carbon $week, array $messages): void $channels = $this->databaseService->getSlackChannelIds(); $existingMessages = $this->databaseService->getMessages($week); - // Group existing messages by channel $messageDetails = []; - foreach ($existingMessages as $existingMessage) { - $channelId = $existingMessage['slack_channel_id']; - if ( ! isset($messageDetails[$channelId])) { - $messageDetails[$channelId] = []; + foreach ($channels as $channelId) { + $messageDetails[$channelId] = []; + $channelExistingMessages = $existingMessages->where('slack_channel_id', $channelId); + + foreach ($channelExistingMessages as $existingMessage) { + $position = $existingMessage['sequence_position']; + $messageDetails[$channelId][$position] = [ + 'timestamp' => $existingMessage['message_timestamp'], + 'message_text' => $existingMessage['message'], + ]; } - $messageDetails[$channelId][] = [ - 'timestamp' => $existingMessage['message_timestamp'], - 'message' => $existingMessage['message'], - ]; } - $postedChannelsSet = array_keys($messageDetails); - foreach ($channels as $slackChannelId) { try { foreach ($messages as $msgIdx => $msg) { $msgText = $msg['text']; $msgBlocks = $msg['blocks']; - // If new events now warrant additional messages being posted - if ($msgIdx > count($messageDetails[$slackChannelId] ?? []) - 1) { - if ($this->isUnsafeToSpillover( - count($existingMessages), - count($messages), - $week, - $slackChannelId - )) { - throw new UnsafeMessageSpilloverException; - } - - Log::info("Posting an additional message for week {$week->format('F j')} in {$slackChannelId}"); + $existingMsgDetail = $messageDetails[$slackChannelId][$msgIdx] ?? null; - $slackResponse = $this->postNewMessage($slackChannelId, $msgBlocks, $msgText); - - $this->databaseService->createMessage( - $week, - $msgText, - $slackResponse['ts'], - $slackChannelId, - $msgIdx - ); - } elseif ( - in_array($slackChannelId, $postedChannelsSet) - && $msgText === $messageDetails[$slackChannelId][$msgIdx]['message'] + if ( + ! $existingMsgDetail + || $msgText !== $existingMsgDetail['message_text'] ) { + if ( ! $existingMsgDetail) { + if ($this->isUnsafeToSpillover( + count($existingMessages->where('slack_channel_id', $slackChannelId)), + count($messages), + $week, + $slackChannelId + )) { + throw new UnsafeMessageSpilloverException; + } + + Log::info("Posting new message " . ($msgIdx + 1) . " for week {$week->format('F j')} in {$slackChannelId}"); + + $slackResponse = $this->postNewMessage($slackChannelId, $msgBlocks, $msgText); + + $this->databaseService->createMessage( + $week, + $msgText, + $slackResponse['ts'], + $slackChannelId, + $msgIdx + ); + } else { // An existing message needs to be updated + if ($this->isUnsafeToSpillover( + count($existingMessages->where('slack_channel_id', $slackChannelId)), + count($messages), + $week, + $slackChannelId + )) { + throw new UnsafeMessageSpilloverException; + } + + Log::info( + "Updating message " . ($msgIdx + 1) . " for week {$week->format('F j')} " . + "in {$slackChannelId} due to text content change." + ); + + $timestamp = $existingMsgDetail['timestamp']; + + $slackResponse = Http::withToken(config('slack-events-bot.bot_token')) + ->post('https://slack.com/api/chat.update', [ + 'ts' => $timestamp, + 'channel' => $slackChannelId, + 'blocks' => $msgBlocks, + 'text' => $msgText, + ]); + + $json = $slackResponse->json(); + + if ( ! $slackResponse->successful() || ! ($json['ok'] ?? false)) { + $error = $json['error'] ?? 'unknown_error'; + Log::error("Failed to update message {$timestamp} in Slack channel {$slackChannelId}", [ + 'error' => $error, + 'response' => $json, + ]); + throw new Exception("Slack API error when updating message: {$error}"); + } + + $this->databaseService->updateMessage( + $week, + $msgText, + $timestamp, + $slackChannelId + ); + } + } else { Log::info( "Message " . ($msgIdx + 1) . " for week of {$week->format('F j')} " . "in {$slackChannelId} hasn't changed, not updating" ); - } elseif (in_array($slackChannelId, $postedChannelsSet)) { - if ($this->isUnsafeToSpillover( - count($existingMessages), - count($messages), - $week, - $slackChannelId - )) { - throw new UnsafeMessageSpilloverException; - } - - Log::info( - "Updating message " . ($msgIdx + 1) . " for week {$week->format('F j')} " . - "in {$slackChannelId}" - ); - - $timestamp = $messageDetails[$slackChannelId][$msgIdx]['timestamp']; - - $response = Http::withToken(config('slack-events-bot.bot_token')) - ->post('https://slack.com/api/chat.update', [ - 'ts' => $timestamp, + } + } + // Handle deletion of messages if the new set has fewer messages + $currentMessageCountForChannel = count($messageDetails[$slackChannelId] ?? []); + for ($i = count($messages); $i < $currentMessageCountForChannel; $i++) { + $timestampToDelete = $messageDetails[$slackChannelId][$i]['timestamp'] ?? null; + if ($timestampToDelete) { + Log::info("Deleting old message (sequence_position " . ($i) . ") for week of {$week->format('F j')} in {$slackChannelId}. No longer needed."); + Http::withToken(config('slack-events-bot.bot_token')) + ->post('https://slack.com/api/chat.delete', [ 'channel' => $slackChannelId, - 'blocks' => $msgBlocks, - 'text' => $msgText, + 'ts' => $timestampToDelete, ]); - - $this->databaseService->updateMessage($week, $msgText, $timestamp, $slackChannelId); - } else { - Log::info( - "Posting message " . ($msgIdx + 1) . " for week {$week->format('F j')} " . - "in {$slackChannelId}" - ); - - $slackResponse = $this->postNewMessage($slackChannelId, $msgBlocks, $msgText); - - $this->databaseService->createMessage( - $week, - $msgText, - $slackResponse['ts'], - $slackChannelId, - $msgIdx - ); + $this->databaseService->deleteMessage($slackChannelId, $timestampToDelete); } } + } catch (UnsafeMessageSpilloverException $e) { Log::error( "Cannot update messages for {$week->format('m/d/Y')} for channel {$slackChannelId}. " . "New events have caused the number of messages needed to increase, " . "but the next week's post has already been sent. Cannot resize. " . - "Existing message count: " . count($existingMessages) . " --- New message count: " . count($messages) + "Existing message count: " . count($existingMessages->where('slack_channel_id', $slackChannelId)) . " --- New message count: " . count($messages) ); - continue; + throw $e; } } } - public function parseEventsForWeek(Collection $events): void + public function parseEventsForWeek(Collection $events, Carbon $weekStart): void { $eventBlocks = $this->messageBuilderService->buildEventBlocks($events); $chunkedMessages = $this->messageBuilderService->chunkMessages($eventBlocks, $weekStart); @@ -138,18 +157,46 @@ public function parseEventsForWeek(Collection $events): void public function handlePostingToSlack(): void { - // Keep current week's post up to date - $this->parseEventsForWeek( - events: Event::query() - ->with(['venue.state', 'organization']) - ->published() - ->whereBetween('active_at', [ - now()->copy()->startOfWeek(), - now()->copy()->addDays(7), - ]) - ->oldest('active_at') - ->get() - ); + $weekStart = now()->copy()->startOfWeek(); + + $events = Event::query() + ->with(['venue.state', 'organization']) + ->published() + ->whereBetween('active_at', [ + $weekStart, + $weekStart->copy()->endOfWeek(), + ]) + ->oldest('active_at') + ->get(); + + if ($events->isEmpty()) { + Log::info("No upcoming events found for the week of {$weekStart->format('F j')}. Cleaning up any existing messages for this week."); + $this->deleteMessagesForWeek($weekStart); + } else { + $this->parseEventsForWeek($events, $weekStart); + } + } + + private function deleteMessagesForWeek(Carbon $week): void + { + $messagesToDelete = $this->databaseService->getMessages($week); + + if ($messagesToDelete->isEmpty()) { + return; + } + + foreach ($messagesToDelete as $message) { + Log::info("Deleting stale message {$message['message_timestamp']} in channel {$message['slack_channel_id']}."); + Http::withToken(config('slack-events-bot.bot_token')) + ->post('https://slack.com/api/chat.delete', [ + 'channel' => $message['slack_channel_id'], + 'ts' => $message['message_timestamp'], + ]); + // We don't care if the deletion fails on Slack's end (e.g., message already deleted), + // we still want to remove it from our database. + } + + $this->databaseService->deleteMessagesForWeek($week); } private function isUnsafeToSpillover( @@ -177,7 +224,7 @@ private function isUnsafeToSpillover( private function postNewMessage(string $slackChannelId, array $msgBlocks, string $msgText): array { - $response = Http::withToken(config('slack-events-bot.bot_token')) + $slackResponse = Http::withToken(config('slack-events-bot.bot_token')) ->post('https://slack.com/api/chat.postMessage', [ 'channel' => $slackChannelId, 'blocks' => $msgBlocks, @@ -186,6 +233,17 @@ private function postNewMessage(string $slackChannelId, array $msgBlocks, string 'unfurl_media' => false, ]); - return $response->json(); + $json = $slackResponse->json(); + + if ( ! $slackResponse->successful() || ! ($json['ok'] ?? false)) { + $error = $json['error'] ?? 'unknown_error'; + Log::error("Failed to post new message to Slack channel {$slackChannelId}", [ + 'error' => $error, + 'response' => $json, + ]); + throw new Exception("Slack API error when posting message: {$error}"); + } + + return $json; } } diff --git a/app-modules/slack-events-bot/src/Services/DatabaseService.php b/app-modules/slack-events-bot/src/Services/DatabaseService.php index d875dae8..80ad7732 100644 --- a/app-modules/slack-events-bot/src/Services/DatabaseService.php +++ b/app-modules/slack-events-bot/src/Services/DatabaseService.php @@ -6,7 +6,9 @@ use HackGreenville\SlackEventsBot\Models\SlackChannel; use HackGreenville\SlackEventsBot\Models\SlackCooldown; use HackGreenville\SlackEventsBot\Models\SlackMessage; +use HackGreenville\SlackEventsBot\Models\SlackWorkspace; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; class DatabaseService { @@ -20,7 +22,7 @@ public function createMessage( $channel = SlackChannel::where('slack_channel_id', $slackChannelId)->firstOrFail(); return SlackMessage::create([ - 'week' => $week, + 'week' => $week->toDateTimeString(), 'message' => $message, 'message_timestamp' => $messageTimestamp, 'channel_id' => $channel->id, @@ -36,7 +38,7 @@ public function updateMessage( ): int { $channel = SlackChannel::where('slack_channel_id', $slackChannelId)->firstOrFail(); - return SlackMessage::where('week', $week) + return SlackMessage::whereDate('week', $week->toDateString()) ->where('message_timestamp', $messageTimestamp) ->where('channel_id', $channel->id) ->update(['message' => $message]); @@ -45,7 +47,8 @@ public function updateMessage( public function getMessages(Carbon $week): Collection { return SlackMessage::with('channel') - ->where('week', $week) + ->whereDate('week', $week->toDateString()) + ->orderBy('channel_id') ->orderBy('sequence_position') ->get() ->map(fn ($message) => [ @@ -66,7 +69,7 @@ public function getMostRecentMessageForChannel(string $slackChannelId): ?array $message = SlackMessage::where('channel_id', $channel->id) ->orderBy('week', 'desc') - ->orderBy('message_timestamp', 'desc') + ->orderBy('sequence_position', 'desc') ->first(); if ( ! $message) { @@ -87,7 +90,7 @@ public function getSlackChannelIds(): Collection public function addChannel(string $slackChannelId): SlackChannel { - return SlackChannel::create(['slack_channel_id' => $slackChannelId]); + return SlackChannel::firstOrCreate(['slack_channel_id' => $slackChannelId]); } public function removeChannel(string $slackChannelId): int @@ -95,12 +98,40 @@ public function removeChannel(string $slackChannelId): int return SlackChannel::where('slack_channel_id', $slackChannelId)->delete(); } + public function deleteMessagesForWeek(Carbon $week): int + { + return SlackMessage::whereDate('week', $week->toDateString())->delete(); + } + + /** + * Deletes a specific message by its Slack channel ID and timestamp. + * This is used for cleaning up individual messages no longer needed. + * + * @param string $slackChannelId The Slack channel ID. + * @param string $messageTimestamp The Slack message timestamp (ts). + * @return int The number of deleted records. + */ + public function deleteMessage(string $slackChannelId, string $messageTimestamp): int + { + $channel = SlackChannel::where('slack_channel_id', $slackChannelId)->first(); + + if ( ! $channel) { + Log::warning("Attempted to delete message in non-existent channel: {$slackChannelId}"); + return 0; + } + + return SlackMessage::where('channel_id', $channel->id) + ->where('message_timestamp', $messageTimestamp) + ->delete(); + } + + public function deleteOldMessages(int $daysBack = 90): void { $cutoffDate = Carbon::now()->subDays($daysBack); // Delete old messages - SlackMessage::whereRaw('CAST(message_timestamp AS DECIMAL) < ?', [$cutoffDate->timestamp]) + SlackMessage::whereDate('week', '<', $cutoffDate->startOfWeek()->toDateString()) ->delete(); // Delete old cooldowns @@ -128,4 +159,16 @@ public function getCooldownExpiryTime(string $accessor, string $resource): ?Carb return $cooldown?->expires_at; } + + public function createOrUpdateWorkspace(array $data): SlackWorkspace + { + return SlackWorkspace::updateOrCreate( + ['team_id' => $data['team']['id']], + [ + 'team_name' => $data['team']['name'], + 'access_token' => $data['access_token'], + 'bot_user_id' => $data['bot_user_id'], + ] + ); + } } diff --git a/app-modules/slack-events-bot/src/Services/EventService.php b/app-modules/slack-events-bot/src/Services/EventService.php index 27e95610..a2eecfcb 100644 --- a/app-modules/slack-events-bot/src/Services/EventService.php +++ b/app-modules/slack-events-bot/src/Services/EventService.php @@ -9,12 +9,18 @@ class EventService { public function generateBlocks(Event $event): array { + $eventName = trim((string) $event->event_name); + if (empty($eventName)) { + $eventName = 'Untitled Event'; + } + $limitedEventName = Str::limit($eventName, 150); + return [ [ 'type' => 'header', 'text' => [ 'type' => 'plain_text', - 'text' => Str::limit($event->title, 250), + 'text' => $limitedEventName, ], ], [ @@ -24,7 +30,7 @@ public function generateBlocks(Event $event): array 'text' => Str::limit($event->description, 250), ], 'fields' => [ - ['type' => 'mrkdwn', 'text' => '*' . Str::limit($event->organization->title) . '*', 250], + ['type' => 'mrkdwn', 'text' => '*' . Str::limit($event->organization->title) . '*'], ['type' => 'mrkdwn', 'text' => '<' . $event->url . '|*Link* :link:>'], ['type' => 'mrkdwn', 'text' => '*Status*'], ['type' => 'mrkdwn', 'text' => $this->printStatus($event->status)], @@ -40,8 +46,9 @@ public function generateBlocks(Event $event): array public function generateText(Event $event): string { return sprintf( - "%s\nDescription: %s\nLink: %s\nStatus: %s\nLocation: %s\nTime: %s", - Str::limit($event->title, 250), + "%s\nOrganization: %s\nDescription: %s\nLink: %s\nStatus: %s\nLocation: %s\nTime: %s", + Str::limit($event->event_name, 250), + Str::limit($event->organization->title, 250), Str::limit($event->description, 250), $event['url'], $this->printStatus($event->status), diff --git a/app-modules/slack-events-bot/src/Services/MessageBuilderService.php b/app-modules/slack-events-bot/src/Services/MessageBuilderService.php index 0ae69312..89d4d3c1 100644 --- a/app-modules/slack-events-bot/src/Services/MessageBuilderService.php +++ b/app-modules/slack-events-bot/src/Services/MessageBuilderService.php @@ -12,14 +12,27 @@ public function __construct(private EventService $eventService) { } + /** + * Builds a collection of event blocks for Slack messages. + * + * @param Collection $events A collection of Event models. + * @return Collection A collection where each item represents an event's blocks and text for Slack. + */ public function buildEventBlocks(Collection $events): Collection { - return collect($events) + return $events ->map(fn (Event $event) => $this->buildSingleEventBlock($event)) ->filter() ->values(); } + /** + * Chunks a collection of event blocks into multiple Slack messages. + * + * @param Collection $eventBlocks A collection of arrays, each containing 'blocks', 'text', and 'text_length'. + * @param Carbon $weekStart The start of the week for header generation. + * @return array An array of message arrays, each containing 'blocks' and 'text'. + */ public function chunkMessages(Collection $eventBlocks, Carbon $weekStart): array { $messagesNeeded = $this->totalMessagesNeeded($eventBlocks); @@ -33,7 +46,7 @@ public function chunkMessages(Collection $eventBlocks, Carbon $weekStart): array foreach ($eventBlocks as $event) { // Event can be safely added to existing message - if ($event['text_length'] + mb_strlen($text) < $maxLength) { + if (($event['text_length'] + mb_strlen($text)) < $maxLength) { $blocks = array_merge($blocks, $event['blocks']); $text .= $event['text']; continue; @@ -53,6 +66,14 @@ public function chunkMessages(Collection $eventBlocks, Carbon $weekStart): array return $messages; } + /** + * Builds the header block and text for a Slack message. + * + * @param Carbon $weekStart The start of the week. + * @param int $index The current message index (e.g., 1 of 3). + * @param int $total The total number of messages. + * @return array An array containing 'blocks', 'text', and 'text_length' for the header. + */ private function buildHeader(Carbon $weekStart, int $index, int $total): array { $text = sprintf( @@ -83,6 +104,12 @@ private function buildHeader(Carbon $weekStart, int $index, int $total): array ]; } + /** + * Builds a single event's blocks and text for inclusion in a Slack message. + * + * @param Event $event The Event model. + * @return array|null An array containing 'blocks', 'text', and 'text_length' for the event, or null if the event should be skipped. + */ private function buildSingleEventBlock(Event $event): ?array { $text = $this->eventService->generateText($event) . "\n\n"; @@ -94,6 +121,12 @@ private function buildSingleEventBlock(Event $event): ?array ]; } + /** + * Calculates the total number of Slack messages needed for the given event blocks. + * + * @param Collection $eventBlocks A collection of arrays, each containing 'blocks', 'text', and 'text_length'. + * @return int The total number of messages required. + */ private function totalMessagesNeeded(Collection $eventBlocks): int { $maxLength = config('slack-events-bot.max_message_character_length'); diff --git a/app-modules/slack-events-bot/tests/SlackEventsBotTest.php b/app-modules/slack-events-bot/tests/SlackEventsBotTest.php index e37acad9..45eaf510 100644 --- a/app-modules/slack-events-bot/tests/SlackEventsBotTest.php +++ b/app-modules/slack-events-bot/tests/SlackEventsBotTest.php @@ -2,11 +2,16 @@ namespace HackGreenville\SlackEventsBot\Tests; +use App\Models\Event; +use App\Models\Org; +use App\Models\State; +use App\Models\Venue; use Carbon\Carbon; use HackGreenville\SlackEventsBot\Services\DatabaseService; use HackGreenville\SlackEventsBot\Services\EventService; use HackGreenville\SlackEventsBot\Services\MessageBuilderService; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Collection; use Tests\TestCase; class SlackEventsBotTest extends TestCase @@ -29,16 +34,12 @@ protected function setUp(): void public function test_can_add_and_remove_channel() { $channelId = 'C1234567890'; - - // Add channel - $channel = $this->databaseService->addChannel($channelId); + $this->databaseService->addChannel($channelId); $this->assertDatabaseHas('slack_channels', ['slack_channel_id' => $channelId]); - // Get channels $channels = $this->databaseService->getSlackChannelIds(); $this->assertTrue($channels->contains($channelId)); - // Remove channel $this->databaseService->removeChannel($channelId); $this->assertDatabaseMissing('slack_channels', ['slack_channel_id' => $channelId]); } @@ -46,16 +47,24 @@ public function test_can_add_and_remove_channel() public function test_can_create_and_get_messages() { $channelId = 'C1234567890'; - $this->databaseService->addChannel($channelId); + $channel = $this->databaseService->addChannel($channelId); + $this->assertDatabaseHas('slack_channels', ['slack_channel_id' => $channelId]); $week = Carbon::now()->startOfWeek(); $message = 'Test message content'; $timestamp = '1234567890.123456'; + $sequencePosition = 0; - // Create message - $this->databaseService->createMessage($week, $message, $timestamp, $channelId, 0); + $this->databaseService->createMessage($week, $message, $timestamp, $channelId, $sequencePosition); + + $this->assertDatabaseHas('slack_messages', [ + 'message' => $message, + 'message_timestamp' => $timestamp, + 'week' => $week->toDateTimeString(), + 'sequence_position' => $sequencePosition, + 'channel_id' => $channel->id, + ]); - // Get messages $messages = $this->databaseService->getMessages($week); $this->assertCount(1, $messages); $this->assertEquals($message, $messages->first()['message']); @@ -64,42 +73,43 @@ public function test_can_create_and_get_messages() public function test_event_parsing() { - $eventData = [ + $state = State::factory()->create(['abbr' => 'SC']); + + $organization = Org::factory()->create(['title' => 'Test Group']); + $venue = Venue::factory()->create([ + 'name' => 'Test Venue', + 'address' => '123 Main St', + 'city' => 'Greenville', + 'state_id' => $state->id, + 'zipcode' => '29601', + ]); + $event = Event::factory()->create([ 'event_name' => 'Test Event', - 'group_name' => 'Test Group', 'description' => 'This is a test event description', - 'venue' => [ - 'name' => 'Test Venue', - 'address' => '123 Main St', - 'city' => 'Greenville', - 'state' => 'SC', - 'zip' => '29601', - 'lat' => null, - 'lon' => null, - ], - 'time' => '2024-01-15T18:00:00-05:00', - 'url' => 'https://example.com/event', - 'status' => 'upcoming', - 'uuid' => 'test-uuid-123', - ]; - - $event = $this->eventService->createEventFromJson($eventData); - - $this->assertEquals('Test Event', $event['title']); - $this->assertEquals('Test Group', $event['group_name']); - $this->assertEquals('Test Venue at 123 Main St Greenville, SC 29601', $event['location']); - $this->assertInstanceOf(Carbon::class, $event['time']); - - // Test block generation + 'uri' => 'https://example.com/event', + 'event_uuid' => 'test-uuid-123', + 'active_at' => Carbon::now()->addDays(7), + 'organization_id' => $organization->id, + 'venue_id' => $venue->id, + ]); + + $event->load('organization', 'venue'); + $blocks = $this->eventService->generateBlocks($event); $this->assertIsArray($blocks); $this->assertEquals('header', $blocks[0]['type']); $this->assertEquals('section', $blocks[1]['type']); + $this->assertEquals('Test Event', $blocks[0]['text']['text']); + $this->assertArrayHasKey('type', $blocks[1]['text']); + $this->assertArrayHasKey('text', $blocks[1]['text']); + $this->assertStringContainsString('This is a test event description', $blocks[1]['text']['text']); - // Test text generation $text = $this->eventService->generateText($event); $this->assertStringContainsString('Test Event', $text); $this->assertStringContainsString('Test Group', $text); + $this->assertStringContainsString('Test Venue', $text); + $this->assertStringContainsString('https://example.com/event', $text); + $this->assertStringContainsString('Upcoming ✅', $text); } public function test_cooldown_functionality() @@ -123,24 +133,38 @@ public function test_cooldown_functionality() public function test_message_chunking() { $weekStart = Carbon::now()->startOfWeek(); - $weekEnd = $weekStart->copy()->addDays(7); + $state = State::factory()->create(['abbr' => 'SC']); - // Create multiple events - $events = []; + $eventsData = []; for ($i = 0; $i < 10; $i++) { - $events[] = [ + $organization = Org::factory()->create(['title' => "Group {$i}"]); + $venue = Venue::factory()->create([ + 'name' => "Venue {$i}", + 'address' => "{$i} Main St", + 'city' => 'Greenville', + 'state_id' => $state->id, + 'zipcode' => '29601', + ]); + + $event = Event::factory()->create([ 'event_name' => "Event {$i} with a very long title that takes up space", - 'group_name' => "Group {$i}", 'description' => str_repeat("This is a long description. ", 10), - 'venue' => ['name' => "Venue {$i}"], - 'time' => $weekStart->copy()->addDays($i % 7)->toIso8601String(), - 'url' => "https://example.com/event-{$i}", - 'status' => 'upcoming', - 'uuid' => "uuid-{$i}", - ]; + 'uri' => "https://example.com/event-{$i}", + 'active_at' => $weekStart->copy()->addDays($i % 7), + 'organization_id' => $organization->id, + 'venue_id' => $venue->id, + 'event_uuid' => 'test-uuid-123-' . $i, + ]); + + $event->load('organization', 'venue'); + + $eventsData[] = $event; } - $eventBlocks = $this->messageBuilderService->buildEventBlocks($events, $weekStart, $weekEnd); + $eventsCollection = collect($eventsData); + + $eventBlocks = $this->messageBuilderService->buildEventBlocks($eventsCollection); + $this->assertInstanceOf(Collection::class, $eventBlocks); $this->assertGreaterThan(0, $eventBlocks->count()); $chunkedMessages = $this->messageBuilderService->chunkMessages($eventBlocks, $weekStart); diff --git a/app/Models/Venue.php b/app/Models/Venue.php index 0bbf2111..df77c16a 100644 --- a/app/Models/Venue.php +++ b/app/Models/Venue.php @@ -71,7 +71,14 @@ class Venue extends Model public function fullAddress() { - return "{$this->name} - {$this->address} {$this->city}, {$this->state} {$this->zipcode}"; + $location = collect([$this->city, $this->state?->abbr]) + ->filter() + ->join(', '); + + return collect([ + "{$this->name} - {$this->address}", + trim("{$location} {$this->zipcode}"), + ])->filter()->join(' '); } diff --git a/config/app.php b/config/app.php index fac6cf91..6bb3c0d1 100644 --- a/config/app.php +++ b/config/app.php @@ -197,6 +197,12 @@ App\Providers\EventServiceProvider::class, App\Providers\Filament\AdminPanelProvider::class, App\Providers\RouteServiceProvider::class, + + + /* + * Slack Events Bot + */ + HackGreenville\SlackEventsBot\Providers\SlackEventsBotServiceProvider::class, ], /* diff --git a/database/migrations/2025_07_06_000000_create_slack_workspaces_table.php b/database/migrations/2025_07_06_000000_create_slack_workspaces_table.php new file mode 100644 index 00000000..84f5760f --- /dev/null +++ b/database/migrations/2025_07_06_000000_create_slack_workspaces_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('team_id')->unique(); + $table->string('team_name'); + $table->text('access_token'); + $table->string('bot_user_id'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('slack_workspaces'); + } +}; diff --git a/database/migrations/2025_07_06_112939_create_jobs_table.php b/database/migrations/2025_07_06_112939_create_jobs_table.php new file mode 100644 index 00000000..7a5df54c --- /dev/null +++ b/database/migrations/2025_07_06_112939_create_jobs_table.php @@ -0,0 +1,31 @@ +bigIncrements('id'); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + } +}; diff --git a/database/migrations/2025_07_06_113105_create_failed_jobs_table.php b/database/migrations/2025_07_06_113105_create_failed_jobs_table.php new file mode 100644 index 00000000..51aaa6f9 --- /dev/null +++ b/database/migrations/2025_07_06_113105_create_failed_jobs_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('failed_jobs'); + } +}; From cf2c176e0e50f4f04c7e0d4be639e34c77575ad3 Mon Sep 17 00:00:00 2001 From: oliviasculley Date: Sun, 6 Jul 2025 17:07:49 +0000 Subject: [PATCH 09/19] implement deleting old messages command --- .../Commands/DeleteOldMessagesCommand.php | 6 +-- .../SlackEventsBotServiceProvider.php | 28 +++++----- .../Commands/DeleteOldMessagesCommandTest.php | 53 +++++++++++++++++++ 3 files changed, 71 insertions(+), 16 deletions(-) create mode 100644 app-modules/slack-events-bot/tests/Console/Commands/DeleteOldMessagesCommandTest.php diff --git a/app-modules/slack-events-bot/src/Console/Commands/DeleteOldMessagesCommand.php b/app-modules/slack-events-bot/src/Console/Commands/DeleteOldMessagesCommand.php index 48a884eb..6f0cb572 100644 --- a/app-modules/slack-events-bot/src/Console/Commands/DeleteOldMessagesCommand.php +++ b/app-modules/slack-events-bot/src/Console/Commands/DeleteOldMessagesCommand.php @@ -19,14 +19,14 @@ public function __construct(private DatabaseService $databaseService) public function handle(): int { $days = (int) $this->option('days'); - $this->info("Deleting messages older than {$days} days..."); + $this->info("Deleting messages and cooldowns older than {$days} days..."); try { $this->databaseService->deleteOldMessages($days); - $this->info('Old messages deleted successfully!'); + $this->info('Old messages and cooldowns deleted successfully!'); return self::SUCCESS; } catch (Exception $e) { - $this->error('Error deleting old messages: ' . $e->getMessage()); + $this->error('Error deleting old messages and cooldowns: ' . $e->getMessage()); return self::FAILURE; } } diff --git a/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php b/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php index b82936f0..756036e4 100644 --- a/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php +++ b/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php @@ -2,6 +2,7 @@ namespace HackGreenville\SlackEventsBot\Providers; +use HackGreenville\SlackEventsBot\Console\Commands\DeleteOldMessagesCommand; use HackGreenville\SlackEventsBot\Services\AuthService; use HackGreenville\SlackEventsBot\Services\BotService; use HackGreenville\SlackEventsBot\Services\DatabaseService; @@ -9,6 +10,7 @@ use HackGreenville\SlackEventsBot\Services\MessageBuilderService; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; +use Illuminate\Console\Scheduling\Schedule; class SlackEventsBotServiceProvider extends ServiceProvider { @@ -36,6 +38,19 @@ public function register(): void public function boot(): void { $this->loadRoutes(); + + // Register commands + if ($this->app->runningInConsole()) { + $this->commands([ + DeleteOldMessagesCommand::class, + ]); + } + + // Schedule tasks + $this->callAfterResolving(Schedule::class, function (Schedule $schedule) { + // Delete old messages once daily + $schedule->command('slack:delete-old-messages')->daily(); + }); } protected function loadRoutes(): void @@ -47,18 +62,5 @@ protected function loadRoutes(): void Route::middleware('web') ->group("{$this->moduleDir}/routes/web.php"); - - // // Register commands - // if ($this->app->runningInConsole()) { - // $this->commands([ - // DeleteOldMessagesCommand::class, - // ]); - // } - - // // Schedule tasks - // $this->callAfterResolving(Schedule::class, function (Schedule $schedule) { - // // Delete old messages once daily - // $schedule->command('slack:delete-old-messages')->daily(); - // }); } } diff --git a/app-modules/slack-events-bot/tests/Console/Commands/DeleteOldMessagesCommandTest.php b/app-modules/slack-events-bot/tests/Console/Commands/DeleteOldMessagesCommandTest.php new file mode 100644 index 00000000..49140457 --- /dev/null +++ b/app-modules/slack-events-bot/tests/Console/Commands/DeleteOldMessagesCommandTest.php @@ -0,0 +1,53 @@ +mock(DatabaseService::class, function (MockInterface $mock) { + $mock->shouldReceive('deleteOldMessages')->once()->with(90); + }); + + $this->artisan('slack:delete-old-messages') + ->expectsOutput('Deleting messages and cooldowns older than 90 days...') + ->expectsOutput('Old messages and cooldowns deleted successfully!') + ->assertSuccessful(); + } + + /** @test */ + public function it_deletes_old_messages_with_custom_days_option(): void + { + $this->mock(DatabaseService::class, function (MockInterface $mock) { + $mock->shouldReceive('deleteOldMessages')->once()->with(30); + }); + + $this->artisan('slack:delete-old-messages', ['--days' => 30]) + ->expectsOutput('Deleting messages and cooldowns older than 30 days...') + ->expectsOutput('Old messages and cooldowns deleted successfully!') + ->assertSuccessful(); + } + + /** @test */ + public function it_handles_exceptions_gracefully(): void + { + $this->mock(DatabaseService::class, function (MockInterface $mock) { + $mock->shouldReceive('deleteOldMessages')->once()->with(90)->andThrow(new Exception('Test exception')); + }); + + $this->artisan('slack:delete-old-messages') + ->expectsOutput('Deleting messages and cooldowns older than 90 days...') + ->expectsOutput('Error deleting old messages and cooldowns: Test exception') + ->assertFailed(); + } +} From c1bfeab90b00dad43987e83ac57b48c6bea654cc Mon Sep 17 00:00:00 2001 From: oliviasculley Date: Sun, 6 Jul 2025 17:18:00 +0000 Subject: [PATCH 10/19] update versions --- .env.docker | 4 +- .github/workflows/unit-tests.yml | 4 +- .tool-versions | 6 +- Dockerfile | 2 +- .../slack-events-bot/vendor/autoload.php | 22 + .../vendor/composer/ClassLoader.php | 579 ++++++++++++++++++ .../slack-events-bot/vendor/composer/LICENSE | 21 + .../vendor/composer/autoload_classmap.php | 10 + .../vendor/composer/autoload_namespaces.php | 9 + .../vendor/composer/autoload_psr4.php | 12 + .../vendor/composer/autoload_real.php | 36 ++ .../vendor/composer/autoload_static.php | 46 ++ composer.json | 2 +- composer.lock | 187 +++--- docker-compose.yml | 2 +- 15 files changed, 837 insertions(+), 105 deletions(-) create mode 100644 app-modules/slack-events-bot/vendor/autoload.php create mode 100644 app-modules/slack-events-bot/vendor/composer/ClassLoader.php create mode 100644 app-modules/slack-events-bot/vendor/composer/LICENSE create mode 100644 app-modules/slack-events-bot/vendor/composer/autoload_classmap.php create mode 100644 app-modules/slack-events-bot/vendor/composer/autoload_namespaces.php create mode 100644 app-modules/slack-events-bot/vendor/composer/autoload_psr4.php create mode 100644 app-modules/slack-events-bot/vendor/composer/autoload_real.php create mode 100644 app-modules/slack-events-bot/vendor/composer/autoload_static.php diff --git a/.env.docker b/.env.docker index 7a378215..c36b7c7c 100644 --- a/.env.docker +++ b/.env.docker @@ -10,7 +10,7 @@ FORCE_SSL=false LOG_CHANNEL=single WWWGROUP=1337 LARAVEL_SAIL=1 -PHP_RUNTIME=8.1 +PHP_RUNTIME=8.3 # PHP_RUNTIME=7.4 DB_CONNECTION=mysql @@ -88,4 +88,4 @@ EVENT_IMPORTER_MEETUP_GRAPHQL_PRIVATE_KEY_PATH= # in the past will be returned. That is, start_date = {today's date - EVENTS_API_DEFAULT_DAYS} EVENTS_API_DEFAULT_DAYS=1 -EVENT_DEBUG_LOGGING_ENABLED=1 \ No newline at end of file +EVENT_DEBUG_LOGGING_ENABLED=1 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 91870516..220b2831 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -31,12 +31,12 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 24 - name: Set up PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.3 extensions: :php_psr - name: copy environment variables to .env diff --git a/.tool-versions b/.tool-versions index d400008e..cbf8b46a 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -nodejs 18.17.1 -php 8.1.27 -yarn 1.22.19 +nodejs 24.3.0 +php 8.3.6 +yarn 1.22.22 diff --git a/Dockerfile b/Dockerfile index a3893ea4..a13b51df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM serversideup/php:8.1-fpm-nginx +FROM serversideup/php:8.3-fpm-nginx # Install additional packages USER root diff --git a/app-modules/slack-events-bot/vendor/autoload.php b/app-modules/slack-events-bot/vendor/autoload.php new file mode 100644 index 00000000..d225b9b0 --- /dev/null +++ b/app-modules/slack-events-bot/vendor/autoload.php @@ -0,0 +1,22 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Autoload; + +/** + * ClassLoader implements a PSR-0, PSR-4 and classmap class loader. + * + * $loader = new \Composer\Autoload\ClassLoader(); + * + * // register classes with namespaces + * $loader->add('Symfony\Component', __DIR__.'/component'); + * $loader->add('Symfony', __DIR__.'/framework'); + * + * // activate the autoloader + * $loader->register(); + * + * // to enable searching the include path (eg. for PEAR packages) + * $loader->setUseIncludePath(true); + * + * In this example, if you try to use a class in the Symfony\Component + * namespace or one of its children (Symfony\Component\Console for instance), + * the autoloader will first look for the class under the component/ + * directory, and it will then fallback to the framework/ directory if not + * found before giving up. + * + * This class is loosely based on the Symfony UniversalClassLoader. + * + * @author Fabien Potencier + * @author Jordi Boggiano + * @see https://www.php-fig.org/psr/psr-0/ + * @see https://www.php-fig.org/psr/psr-4/ + */ +class ClassLoader +{ + /** @var \Closure(string):void */ + private static $includeFile; + + /** @var string|null */ + private $vendorDir; + + // PSR-4 + /** + * @var array> + */ + private $prefixLengthsPsr4 = array(); + /** + * @var array> + */ + private $prefixDirsPsr4 = array(); + /** + * @var list + */ + private $fallbackDirsPsr4 = array(); + + // PSR-0 + /** + * List of PSR-0 prefixes + * + * Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2'))) + * + * @var array>> + */ + private $prefixesPsr0 = array(); + /** + * @var list + */ + private $fallbackDirsPsr0 = array(); + + /** @var bool */ + private $useIncludePath = false; + + /** + * @var array + */ + private $classMap = array(); + + /** @var bool */ + private $classMapAuthoritative = false; + + /** + * @var array + */ + private $missingClasses = array(); + + /** @var string|null */ + private $apcuPrefix; + + /** + * @var array + */ + private static $registeredLoaders = array(); + + /** + * @param string|null $vendorDir + */ + public function __construct($vendorDir = null) + { + $this->vendorDir = $vendorDir; + self::initializeIncludeClosure(); + } + + /** + * @return array> + */ + public function getPrefixes() + { + if (!empty($this->prefixesPsr0)) { + return call_user_func_array('array_merge', array_values($this->prefixesPsr0)); + } + + return array(); + } + + /** + * @return array> + */ + public function getPrefixesPsr4() + { + return $this->prefixDirsPsr4; + } + + /** + * @return list + */ + public function getFallbackDirs() + { + return $this->fallbackDirsPsr0; + } + + /** + * @return list + */ + public function getFallbackDirsPsr4() + { + return $this->fallbackDirsPsr4; + } + + /** + * @return array Array of classname => path + */ + public function getClassMap() + { + return $this->classMap; + } + + /** + * @param array $classMap Class to filename map + * + * @return void + */ + public function addClassMap(array $classMap) + { + if ($this->classMap) { + $this->classMap = array_merge($this->classMap, $classMap); + } else { + $this->classMap = $classMap; + } + } + + /** + * Registers a set of PSR-0 directories for a given prefix, either + * appending or prepending to the ones previously set for this prefix. + * + * @param string $prefix The prefix + * @param list|string $paths The PSR-0 root directories + * @param bool $prepend Whether to prepend the directories + * + * @return void + */ + public function add($prefix, $paths, $prepend = false) + { + $paths = (array) $paths; + if (!$prefix) { + if ($prepend) { + $this->fallbackDirsPsr0 = array_merge( + $paths, + $this->fallbackDirsPsr0 + ); + } else { + $this->fallbackDirsPsr0 = array_merge( + $this->fallbackDirsPsr0, + $paths + ); + } + + return; + } + + $first = $prefix[0]; + if (!isset($this->prefixesPsr0[$first][$prefix])) { + $this->prefixesPsr0[$first][$prefix] = $paths; + + return; + } + if ($prepend) { + $this->prefixesPsr0[$first][$prefix] = array_merge( + $paths, + $this->prefixesPsr0[$first][$prefix] + ); + } else { + $this->prefixesPsr0[$first][$prefix] = array_merge( + $this->prefixesPsr0[$first][$prefix], + $paths + ); + } + } + + /** + * Registers a set of PSR-4 directories for a given namespace, either + * appending or prepending to the ones previously set for this namespace. + * + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param list|string $paths The PSR-4 base directories + * @param bool $prepend Whether to prepend the directories + * + * @throws \InvalidArgumentException + * + * @return void + */ + public function addPsr4($prefix, $paths, $prepend = false) + { + $paths = (array) $paths; + if (!$prefix) { + // Register directories for the root namespace. + if ($prepend) { + $this->fallbackDirsPsr4 = array_merge( + $paths, + $this->fallbackDirsPsr4 + ); + } else { + $this->fallbackDirsPsr4 = array_merge( + $this->fallbackDirsPsr4, + $paths + ); + } + } elseif (!isset($this->prefixDirsPsr4[$prefix])) { + // Register directories for a new namespace. + $length = strlen($prefix); + if ('\\' !== $prefix[$length - 1]) { + throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); + } + $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; + $this->prefixDirsPsr4[$prefix] = $paths; + } elseif ($prepend) { + // Prepend directories for an already registered namespace. + $this->prefixDirsPsr4[$prefix] = array_merge( + $paths, + $this->prefixDirsPsr4[$prefix] + ); + } else { + // Append directories for an already registered namespace. + $this->prefixDirsPsr4[$prefix] = array_merge( + $this->prefixDirsPsr4[$prefix], + $paths + ); + } + } + + /** + * Registers a set of PSR-0 directories for a given prefix, + * replacing any others previously set for this prefix. + * + * @param string $prefix The prefix + * @param list|string $paths The PSR-0 base directories + * + * @return void + */ + public function set($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirsPsr0 = (array) $paths; + } else { + $this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths; + } + } + + /** + * Registers a set of PSR-4 directories for a given namespace, + * replacing any others previously set for this namespace. + * + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param list|string $paths The PSR-4 base directories + * + * @throws \InvalidArgumentException + * + * @return void + */ + public function setPsr4($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirsPsr4 = (array) $paths; + } else { + $length = strlen($prefix); + if ('\\' !== $prefix[$length - 1]) { + throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); + } + $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; + $this->prefixDirsPsr4[$prefix] = (array) $paths; + } + } + + /** + * Turns on searching the include path for class files. + * + * @param bool $useIncludePath + * + * @return void + */ + public function setUseIncludePath($useIncludePath) + { + $this->useIncludePath = $useIncludePath; + } + + /** + * Can be used to check if the autoloader uses the include path to check + * for classes. + * + * @return bool + */ + public function getUseIncludePath() + { + return $this->useIncludePath; + } + + /** + * Turns off searching the prefix and fallback directories for classes + * that have not been registered with the class map. + * + * @param bool $classMapAuthoritative + * + * @return void + */ + public function setClassMapAuthoritative($classMapAuthoritative) + { + $this->classMapAuthoritative = $classMapAuthoritative; + } + + /** + * Should class lookup fail if not found in the current class map? + * + * @return bool + */ + public function isClassMapAuthoritative() + { + return $this->classMapAuthoritative; + } + + /** + * APCu prefix to use to cache found/not-found classes, if the extension is enabled. + * + * @param string|null $apcuPrefix + * + * @return void + */ + public function setApcuPrefix($apcuPrefix) + { + $this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null; + } + + /** + * The APCu prefix in use, or null if APCu caching is not enabled. + * + * @return string|null + */ + public function getApcuPrefix() + { + return $this->apcuPrefix; + } + + /** + * Registers this instance as an autoloader. + * + * @param bool $prepend Whether to prepend the autoloader or not + * + * @return void + */ + public function register($prepend = false) + { + spl_autoload_register(array($this, 'loadClass'), true, $prepend); + + if (null === $this->vendorDir) { + return; + } + + if ($prepend) { + self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders; + } else { + unset(self::$registeredLoaders[$this->vendorDir]); + self::$registeredLoaders[$this->vendorDir] = $this; + } + } + + /** + * Unregisters this instance as an autoloader. + * + * @return void + */ + public function unregister() + { + spl_autoload_unregister(array($this, 'loadClass')); + + if (null !== $this->vendorDir) { + unset(self::$registeredLoaders[$this->vendorDir]); + } + } + + /** + * Loads the given class or interface. + * + * @param string $class The name of the class + * @return true|null True if loaded, null otherwise + */ + public function loadClass($class) + { + if ($file = $this->findFile($class)) { + $includeFile = self::$includeFile; + $includeFile($file); + + return true; + } + + return null; + } + + /** + * Finds the path to the file where the class is defined. + * + * @param string $class The name of the class + * + * @return string|false The path if found, false otherwise + */ + public function findFile($class) + { + // class map lookup + if (isset($this->classMap[$class])) { + return $this->classMap[$class]; + } + if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) { + return false; + } + if (null !== $this->apcuPrefix) { + $file = apcu_fetch($this->apcuPrefix.$class, $hit); + if ($hit) { + return $file; + } + } + + $file = $this->findFileWithExtension($class, '.php'); + + // Search for Hack files if we are running on HHVM + if (false === $file && defined('HHVM_VERSION')) { + $file = $this->findFileWithExtension($class, '.hh'); + } + + if (null !== $this->apcuPrefix) { + apcu_add($this->apcuPrefix.$class, $file); + } + + if (false === $file) { + // Remember that this class does not exist. + $this->missingClasses[$class] = true; + } + + return $file; + } + + /** + * Returns the currently registered loaders keyed by their corresponding vendor directories. + * + * @return array + */ + public static function getRegisteredLoaders() + { + return self::$registeredLoaders; + } + + /** + * @param string $class + * @param string $ext + * @return string|false + */ + private function findFileWithExtension($class, $ext) + { + // PSR-4 lookup + $logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext; + + $first = $class[0]; + if (isset($this->prefixLengthsPsr4[$first])) { + $subPath = $class; + while (false !== $lastPos = strrpos($subPath, '\\')) { + $subPath = substr($subPath, 0, $lastPos); + $search = $subPath . '\\'; + if (isset($this->prefixDirsPsr4[$search])) { + $pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1); + foreach ($this->prefixDirsPsr4[$search] as $dir) { + if (file_exists($file = $dir . $pathEnd)) { + return $file; + } + } + } + } + } + + // PSR-4 fallback dirs + foreach ($this->fallbackDirsPsr4 as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) { + return $file; + } + } + + // PSR-0 lookup + if (false !== $pos = strrpos($class, '\\')) { + // namespaced class name + $logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1) + . strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR); + } else { + // PEAR-like class name + $logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext; + } + + if (isset($this->prefixesPsr0[$first])) { + foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) { + if (0 === strpos($class, $prefix)) { + foreach ($dirs as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { + return $file; + } + } + } + } + } + + // PSR-0 fallback dirs + foreach ($this->fallbackDirsPsr0 as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { + return $file; + } + } + + // PSR-0 include paths. + if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) { + return $file; + } + + return false; + } + + /** + * @return void + */ + private static function initializeIncludeClosure() + { + if (self::$includeFile !== null) { + return; + } + + /** + * Scope isolated include. + * + * Prevents access to $this/self from included files. + * + * @param string $file + * @return void + */ + self::$includeFile = \Closure::bind(static function($file) { + include $file; + }, null, null); + } +} diff --git a/app-modules/slack-events-bot/vendor/composer/LICENSE b/app-modules/slack-events-bot/vendor/composer/LICENSE new file mode 100644 index 00000000..f27399a0 --- /dev/null +++ b/app-modules/slack-events-bot/vendor/composer/LICENSE @@ -0,0 +1,21 @@ + +Copyright (c) Nils Adermann, Jordi Boggiano + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/app-modules/slack-events-bot/vendor/composer/autoload_classmap.php b/app-modules/slack-events-bot/vendor/composer/autoload_classmap.php new file mode 100644 index 00000000..0fb0a2c1 --- /dev/null +++ b/app-modules/slack-events-bot/vendor/composer/autoload_classmap.php @@ -0,0 +1,10 @@ + $vendorDir . '/composer/InstalledVersions.php', +); diff --git a/app-modules/slack-events-bot/vendor/composer/autoload_namespaces.php b/app-modules/slack-events-bot/vendor/composer/autoload_namespaces.php new file mode 100644 index 00000000..15a2ff3a --- /dev/null +++ b/app-modules/slack-events-bot/vendor/composer/autoload_namespaces.php @@ -0,0 +1,9 @@ + array($baseDir . '/database/seeders'), + 'HackGreenville\\SlackEventsBot\\Database\\Factories\\' => array($baseDir . '/database/factories'), + 'HackGreenville\\SlackEventsBot\\' => array($baseDir . '/src'), +); diff --git a/app-modules/slack-events-bot/vendor/composer/autoload_real.php b/app-modules/slack-events-bot/vendor/composer/autoload_real.php new file mode 100644 index 00000000..657c4a9b --- /dev/null +++ b/app-modules/slack-events-bot/vendor/composer/autoload_real.php @@ -0,0 +1,36 @@ +register(true); + + return $loader; + } +} diff --git a/app-modules/slack-events-bot/vendor/composer/autoload_static.php b/app-modules/slack-events-bot/vendor/composer/autoload_static.php new file mode 100644 index 00000000..cb1c9742 --- /dev/null +++ b/app-modules/slack-events-bot/vendor/composer/autoload_static.php @@ -0,0 +1,46 @@ + + array ( + 'HackGreenville\\SlackEventsBot\\Database\\Seeders\\' => 47, + 'HackGreenville\\SlackEventsBot\\Database\\Factories\\' => 49, + 'HackGreenville\\SlackEventsBot\\' => 30, + ), + ); + + public static $prefixDirsPsr4 = array ( + 'HackGreenville\\SlackEventsBot\\Database\\Seeders\\' => + array ( + 0 => __DIR__ . '/../..' . '/database/seeders', + ), + 'HackGreenville\\SlackEventsBot\\Database\\Factories\\' => + array ( + 0 => __DIR__ . '/../..' . '/database/factories', + ), + 'HackGreenville\\SlackEventsBot\\' => + array ( + 0 => __DIR__ . '/../..' . '/src', + ), + ); + + public static $classMap = array ( + 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', + ); + + public static function getInitializer(ClassLoader $loader) + { + return \Closure::bind(function () use ($loader) { + $loader->prefixLengthsPsr4 = ComposerStaticInit8c95e1f6ebe5c8c5503a12dcb01d68c6::$prefixLengthsPsr4; + $loader->prefixDirsPsr4 = ComposerStaticInit8c95e1f6ebe5c8c5503a12dcb01d68c6::$prefixDirsPsr4; + $loader->classMap = ComposerStaticInit8c95e1f6ebe5c8c5503a12dcb01d68c6::$classMap; + + }, null, ClassLoader::class); + } +} diff --git a/composer.json b/composer.json index f1572139..ffe05828 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,7 @@ "license": "MIT", "type": "project", "require": { - "php": "^8.1", + "php": "^8.3", "ext-json": "*", "filament/filament": "^3.3", "firebase/php-jwt": "^6.10", diff --git a/composer.lock b/composer.lock index 03689b40..d0aac59e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "35577f87f88ec8269c29a0acb1f4ada5", + "content-hash": "bdb361a695b7785b5da943de2b5bec1d", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -5741,16 +5741,16 @@ }, { "name": "openspout/openspout", - "version": "v4.25.0", + "version": "v4.32.0", "source": { "type": "git", "url": "https://github.com/openspout/openspout.git", - "reference": "519affe730d92e1598720a6467227fc28550f0e6" + "reference": "41f045c1f632e1474e15d4c7bc3abcb4a153563d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/openspout/openspout/zipball/519affe730d92e1598720a6467227fc28550f0e6", - "reference": "519affe730d92e1598720a6467227fc28550f0e6", + "url": "https://api.github.com/repos/openspout/openspout/zipball/41f045c1f632e1474e15d4c7bc3abcb4a153563d", + "reference": "41f045c1f632e1474e15d4c7bc3abcb4a153563d", "shasum": "" }, "require": { @@ -5760,17 +5760,17 @@ "ext-libxml": "*", "ext-xmlreader": "*", "ext-zip": "*", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "php": "~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { "ext-zlib": "*", - "friendsofphp/php-cs-fixer": "^3.64.0", - "infection/infection": "^0.29.6", - "phpbench/phpbench": "^1.3.1", - "phpstan/phpstan": "^1.12.4", - "phpstan/phpstan-phpunit": "^1.4.0", - "phpstan/phpstan-strict-rules": "^1.6.1", - "phpunit/phpunit": "^10.5.20 || ^11.3.6" + "friendsofphp/php-cs-fixer": "^3.86.0", + "infection/infection": "^0.31.2", + "phpbench/phpbench": "^1.4.1", + "phpstan/phpstan": "^2.1.22", + "phpstan/phpstan-phpunit": "^2.0.7", + "phpstan/phpstan-strict-rules": "^2.0.6", + "phpunit/phpunit": "^12.3.7" }, "suggest": { "ext-iconv": "To handle non UTF-8 CSV files (if \"php-mbstring\" is not already installed or is too limited)", @@ -5818,7 +5818,7 @@ ], "support": { "issues": "https://github.com/openspout/openspout/issues", - "source": "https://github.com/openspout/openspout/tree/v4.25.0" + "source": "https://github.com/openspout/openspout/tree/v4.32.0" }, "funding": [ { @@ -5830,7 +5830,7 @@ "type": "github" } ], - "time": "2024-09-24T09:03:42+00:00" + "time": "2025-09-03T16:03:54+00:00" }, { "name": "phpdocumentor/reflection", @@ -8068,20 +8068,20 @@ }, { "name": "symfony/css-selector", - "version": "v6.4.24", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "9b784413143701aa3c94ac1869a159a9e53e8761" + "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/9b784413143701aa3c94ac1869a159a9e53e8761", - "reference": "9b784413143701aa3c94ac1869a159a9e53e8761", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/601a5ce9aaad7bf10797e3663faefce9e26c24e2", + "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "type": "library", "autoload": { @@ -8113,7 +8113,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v6.4.24" + "source": "https://github.com/symfony/css-selector/tree/v7.3.0" }, "funding": [ { @@ -8124,16 +8124,12 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-07-10T08:14:14+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/deprecation-contracts", @@ -8283,24 +8279,24 @@ }, { "name": "symfony/event-dispatcher", - "version": "v6.4.25", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "b0cf3162020603587363f0551cd3be43958611ff" + "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b0cf3162020603587363f0551cd3be43958611ff", - "reference": "b0cf3162020603587363f0551cd3be43958611ff", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b7dc69e71de420ac04bc9ab830cf3ffebba48191", + "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/dependency-injection": "<5.4", + "symfony/dependency-injection": "<6.4", "symfony/service-contracts": "<2.5" }, "provide": { @@ -8309,13 +8305,13 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/error-handler": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/error-handler": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^5.4|^6.0|^7.0" + "symfony/stopwatch": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -8343,7 +8339,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.25" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.3" }, "funding": [ { @@ -8363,7 +8359,7 @@ "type": "tidelift" } ], - "time": "2025-08-13T09:41:44+00:00" + "time": "2025-08-13T11:49:31+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -8443,25 +8439,25 @@ }, { "name": "symfony/filesystem", - "version": "v6.4.24", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "75ae2edb7cdcc0c53766c30b0a2512b8df574bd8" + "reference": "edcbb768a186b5c3f25d0643159a787d3e63b7fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/75ae2edb7cdcc0c53766c30b0a2512b8df574bd8", - "reference": "75ae2edb7cdcc0c53766c30b0a2512b8df574bd8", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/edcbb768a186b5c3f25d0643159a787d3e63b7fd", + "reference": "edcbb768a186b5c3f25d0643159a787d3e63b7fd", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "symfony/process": "^5.4|^6.4|^7.0" + "symfony/process": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -8489,7 +8485,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.24" + "source": "https://github.com/symfony/filesystem/tree/v7.3.2" }, "funding": [ { @@ -8509,7 +8505,7 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:14:14+00:00" + "time": "2025-07-07T08:17:47+00:00" }, { "name": "symfony/finder", @@ -8581,23 +8577,23 @@ }, { "name": "symfony/html-sanitizer", - "version": "v6.4.25", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/html-sanitizer.git", - "reference": "e0807701639f32bb9053d3c1125d4886e0586771" + "reference": "8740fc48979f649dee8b8fc51a2698e5c190bf12" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/e0807701639f32bb9053d3c1125d4886e0586771", - "reference": "e0807701639f32bb9053d3c1125d4886e0586771", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/8740fc48979f649dee8b8fc51a2698e5c190bf12", + "reference": "8740fc48979f649dee8b8fc51a2698e5c190bf12", "shasum": "" }, "require": { "ext-dom": "*", "league/uri": "^6.5|^7.0", "masterminds/html5": "^2.7.2", - "php": ">=8.1" + "php": ">=8.2" }, "type": "library", "autoload": { @@ -8630,7 +8626,7 @@ "sanitizer" ], "support": { - "source": "https://github.com/symfony/html-sanitizer/tree/v6.4.25" + "source": "https://github.com/symfony/html-sanitizer/tree/v7.3.3" }, "funding": [ { @@ -8650,7 +8646,7 @@ "type": "tidelift" } ], - "time": "2025-08-12T10:22:15+00:00" + "time": "2025-08-12T10:34:03+00:00" }, { "name": "symfony/http-foundation", @@ -10090,20 +10086,20 @@ }, { "name": "symfony/string", - "version": "v6.4.26", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "5621f039a71a11c87c106c1c598bdcd04a19aeea" + "reference": "f96476035142921000338bad71e5247fbc138872" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/5621f039a71a11c87c106c1c598bdcd04a19aeea", - "reference": "5621f039a71a11c87c106c1c598bdcd04a19aeea", + "url": "https://api.github.com/repos/symfony/string/zipball/f96476035142921000338bad71e5247fbc138872", + "reference": "f96476035142921000338bad71e5247fbc138872", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-intl-grapheme": "~1.0", "symfony/polyfill-intl-normalizer": "~1.0", @@ -10113,10 +10109,11 @@ "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/http-client": "^5.4|^6.0|^7.0", - "symfony/intl": "^6.2|^7.0", + "symfony/emoji": "^7.1", + "symfony/http-client": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^5.4|^6.0|^7.0" + "symfony/var-exporter": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -10155,7 +10152,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.26" + "source": "https://github.com/symfony/string/tree/v7.3.4" }, "funding": [ { @@ -10175,7 +10172,7 @@ "type": "tidelift" } ], - "time": "2025-09-11T14:32:46+00:00" + "time": "2025-09-11T14:36:48+00:00" }, { "name": "symfony/translation", @@ -10522,26 +10519,26 @@ }, { "name": "symfony/var-exporter", - "version": "v6.4.26", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "466fcac5fa2e871f83d31173f80e9c2684743bfc" + "reference": "0f020b544a30a7fe8ba972e53ee48a74c0bc87f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/466fcac5fa2e871f83d31173f80e9c2684743bfc", - "reference": "466fcac5fa2e871f83d31173f80e9c2684743bfc", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/0f020b544a30a7fe8ba972e53ee48a74c0bc87f4", + "reference": "0f020b544a30a7fe8ba972e53ee48a74c0bc87f4", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3" }, "require-dev": { "symfony/property-access": "^6.4|^7.0", "symfony/serializer": "^6.4|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/var-dumper": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -10579,7 +10576,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v6.4.26" + "source": "https://github.com/symfony/var-exporter/tree/v7.3.4" }, "funding": [ { @@ -10599,32 +10596,32 @@ "type": "tidelift" } ], - "time": "2025-09-11T09:57:09+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/yaml", - "version": "v6.4.26", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "0fc8b966fd0dcaab544ae59bfc3a433f048c17b0" + "reference": "d4f4a66866fe2451f61296924767280ab5732d9d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/0fc8b966fd0dcaab544ae59bfc3a433f048c17b0", - "reference": "0fc8b966fd0dcaab544ae59bfc3a433f048c17b0", + "url": "https://api.github.com/repos/symfony/yaml/zipball/d4f4a66866fe2451f61296924767280ab5732d9d", + "reference": "d4f4a66866fe2451f61296924767280ab5732d9d", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/console": "<5.4" + "symfony/console": "<6.4" }, "require-dev": { - "symfony/console": "^5.4|^6.0|^7.0" + "symfony/console": "^6.4|^7.0" }, "bin": [ "Resources/bin/yaml-lint" @@ -10655,7 +10652,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v6.4.26" + "source": "https://github.com/symfony/yaml/tree/v7.3.3" }, "funding": [ { @@ -10675,7 +10672,7 @@ "type": "tidelift" } ], - "time": "2025-09-26T15:07:38+00:00" + "time": "2025-08-27T11:34:33+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -11234,16 +11231,16 @@ }, { "name": "laravel/pint", - "version": "v1.20.0", + "version": "v1.25.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "53072e8ea22213a7ed168a8a15b96fbb8b82d44b" + "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/53072e8ea22213a7ed168a8a15b96fbb8b82d44b", - "reference": "53072e8ea22213a7ed168a8a15b96fbb8b82d44b", + "url": "https://api.github.com/repos/laravel/pint/zipball/5016e263f95d97670d71b9a987bd8996ade6d8d9", + "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9", "shasum": "" }, "require": { @@ -11251,15 +11248,15 @@ "ext-mbstring": "*", "ext-tokenizer": "*", "ext-xml": "*", - "php": "^8.1.0" + "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.66.0", - "illuminate/view": "^10.48.25", - "larastan/larastan": "^2.9.12", - "laravel-zero/framework": "^10.48.25", + "friendsofphp/php-cs-fixer": "^3.87.2", + "illuminate/view": "^11.46.0", + "larastan/larastan": "^3.7.1", + "laravel-zero/framework": "^11.45.0", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^1.17.0", + "nunomaduro/termwind": "^2.3.1", "pestphp/pest": "^2.36.0" }, "bin": [ @@ -11296,7 +11293,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-01-14T16:20:53+00:00" + "time": "2025-09-19T02:57:12+00:00" }, { "name": "laravel/sail", @@ -13585,7 +13582,7 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.1", + "php": "^8.3", "ext-json": "*" }, "platform-dev": { diff --git a/docker-compose.yml b/docker-compose.yml index 969c0187..bd6baf43 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ version: '3' services: hackgreenville: build: - context: './vendor/laravel/sail/runtimes/${PHP_RUNTIME:-8.1}' + context: './vendor/laravel/sail/runtimes/${PHP_RUNTIME:-8.3}' dockerfile: Dockerfile args: WWWGROUP: '${WWWGROUP:-www-data}' From 783d3076ebfeac121b61cb7bceb5017a2d0e7ae2 Mon Sep 17 00:00:00 2001 From: oliviasculley Date: Sun, 6 Jul 2025 17:29:01 +0000 Subject: [PATCH 11/19] add development mode changes --- .../src/Http/Controllers/SlackController.php | 10 +++++----- .../src/Providers/SlackEventsBotServiceProvider.php | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app-modules/slack-events-bot/src/Http/Controllers/SlackController.php b/app-modules/slack-events-bot/src/Http/Controllers/SlackController.php index 51ca62b1..9ad53cb2 100644 --- a/app-modules/slack-events-bot/src/Http/Controllers/SlackController.php +++ b/app-modules/slack-events-bot/src/Http/Controllers/SlackController.php @@ -127,10 +127,10 @@ private function handleSlashCommand(array $payload): Response $originalCommand = $command; // Normalize dev commands to match production commands - // if (str_starts_with($command, '/dev_')) { - // $command = str_replace('/dev_', '/', $command); - // Log::info('Normalized dev command', ['original' => $originalCommand, 'normalized' => $command]); - // } + if (app()->isLocal() && str_starts_with($command, '/dev_')) { + $command = str_replace('/dev_', '/', $command); + Log::info('Normalized dev command', ['original' => $originalCommand, 'normalized' => $command]); + } switch ($command) { case '/add_channel': @@ -174,7 +174,7 @@ private function handleSlashCommand(array $payload): Response case '/check_api': Log::info('Executing /check_api command', ['user_id' => $userId, 'team_domain' => $teamDomain]); // Check cooldown - if ($teamDomain) { + if ( ! app()->isLocal() && $teamDomain) { $expiryTime = $this->databaseService->getCooldownExpiryTime($teamDomain, 'check_api'); if ($expiryTime && $expiryTime->isFuture()) { diff --git a/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php b/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php index 756036e4..ff2d5dd7 100644 --- a/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php +++ b/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php @@ -8,9 +8,9 @@ use HackGreenville\SlackEventsBot\Services\DatabaseService; use HackGreenville\SlackEventsBot\Services\EventService; use HackGreenville\SlackEventsBot\Services\MessageBuilderService; +use Illuminate\Console\Scheduling\Schedule; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; -use Illuminate\Console\Scheduling\Schedule; class SlackEventsBotServiceProvider extends ServiceProvider { From c076f501122495212e6195b5279ee2866ba15bb0 Mon Sep 17 00:00:00 2001 From: oliviasculley Date: Wed, 9 Jul 2025 03:18:21 +0000 Subject: [PATCH 12/19] add details about creating a slack app --- app-modules/slack-events-bot/README.md | 18 ++++++++++++++++++ .../slack-events-bot/slackbot-manifest.json | 0 2 files changed, 18 insertions(+) rename slackbot-manifest.json => app-modules/slack-events-bot/slackbot-manifest.json (100%) diff --git a/app-modules/slack-events-bot/README.md b/app-modules/slack-events-bot/README.md index 3823d3cb..7a8d84b6 100644 --- a/app-modules/slack-events-bot/README.md +++ b/app-modules/slack-events-bot/README.md @@ -17,6 +17,24 @@ SLACK_CLIENT_ID=your-client-id SLACK_CLIENT_SECRET=your-client-secret ``` +## Setting up Slack Credentials for Testing + +To obtain the necessary Slack credentials for testing (SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET), you'll need to create a Slack app and configure it. Follow these steps: + +1. Run `cp app-modules/slack-events-bot/slackbot-manifest.json app-modules/slack-events-bot/slackbot-manifest.dev.json` to create a copy version of the manifest. Modify the values in `slackbot-manifest.dev.json` to match your public endpoint. If you don't have a public endpoint, you may need to create one with [ngrok](https://ngrok.com/). + +2. Go to [api.slack.com/apps](https://api.slack.com/apps) and click "Create New App". Then, upload your development manifest to get a head start. + +3. Get the following environment variables and add them to your `.env` file: `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET`, `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET` + +4. Next, navigate to your public endpoint and add `/slack/install` at the end. You should see a `Add to Slack` button displayed if it worked correctly! + +5. Go through the flow to connect the bot to a server and channel accordingly. + +6. In your Slack workspace, try doing the `/check_api`, `/add_channel`, and `/remove_channel` commands. + +7. The slackbot should work correctly! + ## Publishing Configuration To publish the configuration file: diff --git a/slackbot-manifest.json b/app-modules/slack-events-bot/slackbot-manifest.json similarity index 100% rename from slackbot-manifest.json rename to app-modules/slack-events-bot/slackbot-manifest.json From 924c66f0550465c9735c05b125db67d34acd1d4e Mon Sep 17 00:00:00 2001 From: oliviasculley Date: Wed, 9 Jul 2025 05:10:33 +0000 Subject: [PATCH 13/19] fix bug that AI found? --- app/Http/Controllers/CalendarFeedController.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/CalendarFeedController.php b/app/Http/Controllers/CalendarFeedController.php index e07b4e4f..c47315f3 100644 --- a/app/Http/Controllers/CalendarFeedController.php +++ b/app/Http/Controllers/CalendarFeedController.php @@ -23,7 +23,8 @@ public function index(CalendarFeedRequest $request) ->when( value: $request->validOrganizations()->isNotEmpty(), callback: function (Builder $query) use ($request) { - $query->orderByFieldSequence('id', $request->validOrganizations()->pluck('id')->toArray()) + $query->whereIn('id', $request->validOrganizations()->pluck('id')->toArray()) + ->orderByFieldSequence('id', $request->validOrganizations()->pluck('id')->toArray()) ->orderBy('title'); }, default: fn (Builder $query) => $query->orderBy('title') From 333b9dbbfe93169ed5e960f210871ddcc00409e2 Mon Sep 17 00:00:00 2001 From: oliviasculley Date: Thu, 10 Jul 2025 00:51:39 +0000 Subject: [PATCH 14/19] use access tokens per workspace --- app-modules/slack-events-bot/composer.json | 3 +- .../factories/SlackChannelFactory.php | 20 + .../factories/SlackMessageFactory.php | 24 + .../factories/SlackWorkspaceFactory.php | 31 + ...k_workspace_id_to_slack_channels_table.php | 27 + ...6_add_domain_to_slack_workspaces_table.php | 27 + app-modules/slack-events-bot/routes/web.php | 6 +- .../src/Http/Controllers/SlackController.php | 11 +- .../src/Models/SlackChannel.php | 16 + .../src/Models/SlackMessage.php | 9 + .../src/Models/SlackWorkspace.php | 37 ++ .../SlackEventsBotServiceProvider.php | 17 +- .../src/Services/AuthService.php | 16 +- .../src/Services/BotService.php | 66 +- .../src/Services/DatabaseService.php | 23 +- .../src/Services/MessageBuilderService.php | 4 + .../tests/Feature/SchedulingTest.php | 32 + .../tests/Feature/SlackEventsBotTest.php | 568 ++++++++++++++++++ .../tests/Jobs/CheckEventsApiTest.php | 89 +++ .../tests/Models/SlackWorkspaceTest.php | 63 ++ .../tests/Services/AuthServiceTest.php | 197 ++++++ .../tests/Services/BotServiceTest.php | 450 ++++++++++++++ .../tests/Services/DatabaseServiceTest.php | 476 +++++++++++++++ .../tests/Services/EventServiceTest.php | 310 ++++++++++ .../Services/MessageBuilderServiceTest.php | 124 ++++ .../tests/SlackEventsBotTest.php | 184 ------ docker-compose.local.yml | 4 + docker-compose.yml | 18 + 28 files changed, 2607 insertions(+), 245 deletions(-) create mode 100644 app-modules/slack-events-bot/database/factories/SlackChannelFactory.php create mode 100644 app-modules/slack-events-bot/database/factories/SlackMessageFactory.php create mode 100644 app-modules/slack-events-bot/database/factories/SlackWorkspaceFactory.php create mode 100644 app-modules/slack-events-bot/database/migrations/2025_07_08_232704_add_slack_workspace_id_to_slack_channels_table.php create mode 100644 app-modules/slack-events-bot/database/migrations/2025_07_08_233636_add_domain_to_slack_workspaces_table.php create mode 100644 app-modules/slack-events-bot/tests/Feature/SchedulingTest.php create mode 100644 app-modules/slack-events-bot/tests/Feature/SlackEventsBotTest.php create mode 100644 app-modules/slack-events-bot/tests/Jobs/CheckEventsApiTest.php create mode 100644 app-modules/slack-events-bot/tests/Models/SlackWorkspaceTest.php create mode 100644 app-modules/slack-events-bot/tests/Services/AuthServiceTest.php create mode 100644 app-modules/slack-events-bot/tests/Services/BotServiceTest.php create mode 100644 app-modules/slack-events-bot/tests/Services/DatabaseServiceTest.php create mode 100644 app-modules/slack-events-bot/tests/Services/EventServiceTest.php create mode 100644 app-modules/slack-events-bot/tests/Services/MessageBuilderServiceTest.php delete mode 100644 app-modules/slack-events-bot/tests/SlackEventsBotTest.php diff --git a/app-modules/slack-events-bot/composer.json b/app-modules/slack-events-bot/composer.json index 8dc2fbda..53f4b16d 100644 --- a/app-modules/slack-events-bot/composer.json +++ b/app-modules/slack-events-bot/composer.json @@ -6,7 +6,8 @@ "version": "1.0", "autoload": { "psr-4": { - "HackGreenville\\SlackEventsBot\\": "src/" + "HackGreenville\\SlackEventsBot\\": "src/", + "HackGreenville\\SlackEventsBot\\Database\\Factories\\": "./database/factories/" } }, "autoload-dev": { diff --git a/app-modules/slack-events-bot/database/factories/SlackChannelFactory.php b/app-modules/slack-events-bot/database/factories/SlackChannelFactory.php new file mode 100644 index 00000000..6ab5da0c --- /dev/null +++ b/app-modules/slack-events-bot/database/factories/SlackChannelFactory.php @@ -0,0 +1,20 @@ + $this->faker->unique()->regexify('[C][A-Z0-9]{10}'), + 'slack_workspace_id' => SlackWorkspace::factory(), + ]; + } +} diff --git a/app-modules/slack-events-bot/database/factories/SlackMessageFactory.php b/app-modules/slack-events-bot/database/factories/SlackMessageFactory.php new file mode 100644 index 00000000..8dee6023 --- /dev/null +++ b/app-modules/slack-events-bot/database/factories/SlackMessageFactory.php @@ -0,0 +1,24 @@ + Carbon::now()->startOfWeek(), + 'message' => $this->faker->sentence, + 'message_timestamp' => now()->timestamp . '.' . $this->faker->randomNumber(6, true), + 'sequence_position' => $this->faker->numberBetween(0, 10), + 'channel_id' => SlackChannel::factory(), + ]; + } +} diff --git a/app-modules/slack-events-bot/database/factories/SlackWorkspaceFactory.php b/app-modules/slack-events-bot/database/factories/SlackWorkspaceFactory.php new file mode 100644 index 00000000..67655920 --- /dev/null +++ b/app-modules/slack-events-bot/database/factories/SlackWorkspaceFactory.php @@ -0,0 +1,31 @@ + $this->faker->unique()->regexify('[T][A-Z0-9]{10}'), + 'team_name' => $this->faker->company, + 'access_token' => $this->faker->sha256, + 'bot_user_id' => $this->faker->unique()->regexify('[U][A-Z0-9]{10}'), + ]; + } +} diff --git a/app-modules/slack-events-bot/database/migrations/2025_07_08_232704_add_slack_workspace_id_to_slack_channels_table.php b/app-modules/slack-events-bot/database/migrations/2025_07_08_232704_add_slack_workspace_id_to_slack_channels_table.php new file mode 100644 index 00000000..d43e4a07 --- /dev/null +++ b/app-modules/slack-events-bot/database/migrations/2025_07_08_232704_add_slack_workspace_id_to_slack_channels_table.php @@ -0,0 +1,27 @@ +string('slack_workspace_id')->nullable()->after('channel_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('slack_channels', function (Blueprint $table) { + $table->dropColumn('slack_workspace_id'); + }); + } +}; diff --git a/app-modules/slack-events-bot/database/migrations/2025_07_08_233636_add_domain_to_slack_workspaces_table.php b/app-modules/slack-events-bot/database/migrations/2025_07_08_233636_add_domain_to_slack_workspaces_table.php new file mode 100644 index 00000000..6541a81e --- /dev/null +++ b/app-modules/slack-events-bot/database/migrations/2025_07_08_233636_add_domain_to_slack_workspaces_table.php @@ -0,0 +1,27 @@ +string('domain')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('slack_workspaces', function (Blueprint $table) { + $table->dropColumn('domain'); + }); + } +}; diff --git a/app-modules/slack-events-bot/routes/web.php b/app-modules/slack-events-bot/routes/web.php index 20bfa810..76ac51f6 100644 --- a/app-modules/slack-events-bot/routes/web.php +++ b/app-modules/slack-events-bot/routes/web.php @@ -3,7 +3,5 @@ use HackGreenville\SlackEventsBot\Http\Controllers\SlackController; use Illuminate\Support\Facades\Route; -Route::prefix('slack')->group(function () { - Route::get('/install', [SlackController::class, 'install'])->name('slack.install'); - Route::get('/auth', [SlackController::class, 'auth'])->name('slack.auth'); -}); +Route::get('/install', [SlackController::class, 'install'])->name('install'); +Route::get('/auth', [SlackController::class, 'auth'])->name('auth'); diff --git a/app-modules/slack-events-bot/src/Http/Controllers/SlackController.php b/app-modules/slack-events-bot/src/Http/Controllers/SlackController.php index 9ad53cb2..14361edb 100644 --- a/app-modules/slack-events-bot/src/Http/Controllers/SlackController.php +++ b/app-modules/slack-events-bot/src/Http/Controllers/SlackController.php @@ -122,12 +122,13 @@ private function handleSlashCommand(array $payload): Response $command = $payload['command']; $userId = $payload['user_id']; $channelId = $payload['channel_id']; + $teamId = $payload['team_id']; $teamDomain = $payload['team_domain'] ?? null; $originalCommand = $command; // Normalize dev commands to match production commands - if (app()->isLocal() && str_starts_with($command, '/dev_')) { + if (true || app()->isLocal() && str_starts_with($command, '/dev_')) { $command = str_replace('/dev_', '/', $command); Log::info('Normalized dev command', ['original' => $originalCommand, 'normalized' => $command]); } @@ -135,13 +136,13 @@ private function handleSlashCommand(array $payload): Response switch ($command) { case '/add_channel': Log::info('Executing /add_channel command', ['user_id' => $userId, 'channel_id' => $channelId]); - if ( ! $this->authService->isAdmin($userId)) { + if ( ! $this->authService->isAdmin($userId, $teamId)) { Log::warning('/add_channel failed: user is not an admin', ['user_id' => $userId]); return response('You must be a workspace admin in order to run `/add_channel`'); } try { - $this->databaseService->addChannel($channelId); + $this->databaseService->addChannel($channelId, $teamId); Log::info('Successfully added channel', ['channel_id' => $channelId]); return response('Added channel to slack events bot 👍'); } catch (Exception $e) { @@ -154,7 +155,7 @@ private function handleSlashCommand(array $payload): Response case '/remove_channel': Log::info('Executing /remove_channel command', ['user_id' => $userId, 'channel_id' => $channelId]); - if ( ! $this->authService->isAdmin($userId)) { + if ( ! $this->authService->isAdmin($userId, $teamId)) { Log::warning('/remove_channel failed: user is not an admin', ['user_id' => $userId]); return response('You must be a workspace admin in order to run `/remove_channel`'); } @@ -174,7 +175,7 @@ private function handleSlashCommand(array $payload): Response case '/check_api': Log::info('Executing /check_api command', ['user_id' => $userId, 'team_domain' => $teamDomain]); // Check cooldown - if ( ! app()->isLocal() && $teamDomain) { + if (false && ! app()->isLocal() && $teamDomain) { $expiryTime = $this->databaseService->getCooldownExpiryTime($teamDomain, 'check_api'); if ($expiryTime && $expiryTime->isFuture()) { diff --git a/app-modules/slack-events-bot/src/Models/SlackChannel.php b/app-modules/slack-events-bot/src/Models/SlackChannel.php index 9b2d0c8d..f3e0cb70 100644 --- a/app-modules/slack-events-bot/src/Models/SlackChannel.php +++ b/app-modules/slack-events-bot/src/Models/SlackChannel.php @@ -2,17 +2,33 @@ namespace HackGreenville\SlackEventsBot\Models; +use HackGreenville\SlackEventsBot\Database\Factories\SlackChannelFactory; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; class SlackChannel extends Model { + use HasFactory; + protected $fillable = [ 'slack_channel_id', + 'slack_workspace_id', ]; public function messages(): HasMany { return $this->hasMany(SlackMessage::class, 'channel_id'); } + + public function workspace(): BelongsTo + { + return $this->belongsTo(SlackWorkspace::class, 'slack_workspace_id'); + } + + protected static function newFactory() + { + return SlackChannelFactory::new(); + } } diff --git a/app-modules/slack-events-bot/src/Models/SlackMessage.php b/app-modules/slack-events-bot/src/Models/SlackMessage.php index 8b570326..fca1a5d6 100644 --- a/app-modules/slack-events-bot/src/Models/SlackMessage.php +++ b/app-modules/slack-events-bot/src/Models/SlackMessage.php @@ -2,11 +2,15 @@ namespace HackGreenville\SlackEventsBot\Models; +use HackGreenville\SlackEventsBot\Database\Factories\SlackMessageFactory; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class SlackMessage extends Model { + use HasFactory; + protected $fillable = [ 'week', 'message_timestamp', @@ -23,4 +27,9 @@ public function channel(): BelongsTo { return $this->belongsTo(SlackChannel::class, 'channel_id'); } + + protected static function newFactory() + { + return SlackMessageFactory::new(); + } } diff --git a/app-modules/slack-events-bot/src/Models/SlackWorkspace.php b/app-modules/slack-events-bot/src/Models/SlackWorkspace.php index 061d5437..5452e2a8 100644 --- a/app-modules/slack-events-bot/src/Models/SlackWorkspace.php +++ b/app-modules/slack-events-bot/src/Models/SlackWorkspace.php @@ -2,14 +2,51 @@ namespace HackGreenville\SlackEventsBot\Models; +use HackGreenville\SlackEventsBot\Database\Factories\SlackWorkspaceFactory; +use Illuminate\Contracts\Encryption\DecryptException; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Crypt; +use Illuminate\Support\Facades\Log; class SlackWorkspace extends Model { + use HasFactory; + protected $fillable = [ 'team_id', 'team_name', 'access_token', 'bot_user_id', ]; + + /** + * Encrypt the access token when setting it. + */ + public function setAccessTokenAttribute(string $value): void + { + $this->attributes['access_token'] = Crypt::encryptString($value); + } + + /** + * Decrypt the access token when getting it. + */ + public function getAccessTokenAttribute(string $value): string + { + try { + return Crypt::decryptString($value); + } catch (DecryptException $e) { + Log::warning('Could not decrypt access token for workspace. The token might be stored in plain text.', [ + 'workspace_id' => $this->id, + 'team_id' => $this->team_id, + ]); + + return $value; + } + } + + protected static function newFactory() + { + return SlackWorkspaceFactory::new(); + } } diff --git a/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php b/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php index ff2d5dd7..e6124e14 100644 --- a/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php +++ b/app-modules/slack-events-bot/src/Providers/SlackEventsBotServiceProvider.php @@ -3,6 +3,7 @@ namespace HackGreenville\SlackEventsBot\Providers; use HackGreenville\SlackEventsBot\Console\Commands\DeleteOldMessagesCommand; +use HackGreenville\SlackEventsBot\Jobs\CheckEventsApi; use HackGreenville\SlackEventsBot\Services\AuthService; use HackGreenville\SlackEventsBot\Services\BotService; use HackGreenville\SlackEventsBot\Services\DatabaseService; @@ -46,21 +47,25 @@ public function boot(): void ]); } + $this->loadMigrationsFrom($this->moduleDir . '/database/migrations'); + // Schedule tasks $this->callAfterResolving(Schedule::class, function (Schedule $schedule) { - // Delete old messages once daily $schedule->command('slack:delete-old-messages')->daily(); + $schedule->job(CheckEventsApi::class)->hourly(); }); } protected function loadRoutes(): void { - Route::prefix('slack') - ->middleware('api') + Route::prefix(config('slack-events-bot.route_prefix', 'slack')) ->name('slack.') - ->group("{$this->moduleDir}/routes/api.php"); + ->group(function () { + Route::middleware('web') + ->group("{$this->moduleDir}/routes/web.php"); - Route::middleware('web') - ->group("{$this->moduleDir}/routes/web.php"); + Route::middleware('api') + ->group("{$this->moduleDir}/routes/api.php"); + }); } } diff --git a/app-modules/slack-events-bot/src/Services/AuthService.php b/app-modules/slack-events-bot/src/Services/AuthService.php index 9ffbe830..118abeed 100644 --- a/app-modules/slack-events-bot/src/Services/AuthService.php +++ b/app-modules/slack-events-bot/src/Services/AuthService.php @@ -2,15 +2,23 @@ namespace HackGreenville\SlackEventsBot\Services; +use HackGreenville\SlackEventsBot\Models\SlackWorkspace; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use RuntimeException; class AuthService { - public function getUserInfo(string $userId): array + public function getUserInfo(string $userId, string $teamId): array { - $response = Http::withToken(config('slack-events-bot.bot_token')) + $workspace = SlackWorkspace::where('team_id', $teamId)->first(); + + if ( ! $workspace) { + throw new RuntimeException("Workspace with team ID {$teamId} not found."); + } + + $response = Http::withToken($workspace->access_token) ->get('https://slack.com/api/users.info', [ 'user' => $userId, ]); @@ -18,9 +26,9 @@ public function getUserInfo(string $userId): array return $response->json(); } - public function isAdmin(string $userId): bool + public function isAdmin(string $userId, string $teamId): bool { - $userInfo = $this->getUserInfo($userId); + $userInfo = $this->getUserInfo($userId, $teamId); return $userInfo['user']['is_admin'] ?? false; } diff --git a/app-modules/slack-events-bot/src/Services/BotService.php b/app-modules/slack-events-bot/src/Services/BotService.php index 7465cc2f..ba55614f 100644 --- a/app-modules/slack-events-bot/src/Services/BotService.php +++ b/app-modules/slack-events-bot/src/Services/BotService.php @@ -20,24 +20,27 @@ public function __construct( public function postOrUpdateMessages(Carbon $week, array $messages): void { - $channels = $this->databaseService->getSlackChannelIds(); + $channels = $this->databaseService->getSlackChannels(); $existingMessages = $this->databaseService->getMessages($week); $messageDetails = []; - foreach ($channels as $channelId) { + foreach ($channels as $channel) { + $channelId = $channel->slack_channel_id; $messageDetails[$channelId] = []; - $channelExistingMessages = $existingMessages->where('slack_channel_id', $channelId); + $channelExistingMessages = $existingMessages->where('channel.slack_channel_id', $channelId); foreach ($channelExistingMessages as $existingMessage) { - $position = $existingMessage['sequence_position']; + $position = $existingMessage->sequence_position; $messageDetails[$channelId][$position] = [ - 'timestamp' => $existingMessage['message_timestamp'], - 'message_text' => $existingMessage['message'], + 'timestamp' => $existingMessage->message_timestamp, + 'message_text' => $existingMessage->message, ]; } } - foreach ($channels as $slackChannelId) { + foreach ($channels as $channel) { + $slackChannelId = $channel->slack_channel_id; + $token = $channel->workspace->access_token; try { foreach ($messages as $msgIdx => $msg) { $msgText = $msg['text']; @@ -51,7 +54,7 @@ public function postOrUpdateMessages(Carbon $week, array $messages): void ) { if ( ! $existingMsgDetail) { if ($this->isUnsafeToSpillover( - count($existingMessages->where('slack_channel_id', $slackChannelId)), + count($existingMessages->where('channel.slack_channel_id', $slackChannelId)), count($messages), $week, $slackChannelId @@ -61,7 +64,7 @@ public function postOrUpdateMessages(Carbon $week, array $messages): void Log::info("Posting new message " . ($msgIdx + 1) . " for week {$week->format('F j')} in {$slackChannelId}"); - $slackResponse = $this->postNewMessage($slackChannelId, $msgBlocks, $msgText); + $slackResponse = $this->postNewMessage($slackChannelId, $msgBlocks, $msgText, $token); $this->databaseService->createMessage( $week, @@ -72,7 +75,7 @@ public function postOrUpdateMessages(Carbon $week, array $messages): void ); } else { // An existing message needs to be updated if ($this->isUnsafeToSpillover( - count($existingMessages->where('slack_channel_id', $slackChannelId)), + count($existingMessages->where('channel.slack_channel_id', $slackChannelId)), count($messages), $week, $slackChannelId @@ -87,7 +90,7 @@ public function postOrUpdateMessages(Carbon $week, array $messages): void $timestamp = $existingMsgDetail['timestamp']; - $slackResponse = Http::withToken(config('slack-events-bot.bot_token')) + $slackResponse = Http::withToken($token) ->post('https://slack.com/api/chat.update', [ 'ts' => $timestamp, 'channel' => $slackChannelId, @@ -126,7 +129,7 @@ public function postOrUpdateMessages(Carbon $week, array $messages): void $timestampToDelete = $messageDetails[$slackChannelId][$i]['timestamp'] ?? null; if ($timestampToDelete) { Log::info("Deleting old message (sequence_position " . ($i) . ") for week of {$week->format('F j')} in {$slackChannelId}. No longer needed."); - Http::withToken(config('slack-events-bot.bot_token')) + Http::withToken($token) ->post('https://slack.com/api/chat.delete', [ 'channel' => $slackChannelId, 'ts' => $timestampToDelete, @@ -139,8 +142,7 @@ public function postOrUpdateMessages(Carbon $week, array $messages): void Log::error( "Cannot update messages for {$week->format('m/d/Y')} for channel {$slackChannelId}. " . "New events have caused the number of messages needed to increase, " . - "but the next week's post has already been sent. Cannot resize. " . - "Existing message count: " . count($existingMessages->where('slack_channel_id', $slackChannelId)) . " --- New message count: " . count($messages) + "but the next week's post has already been sent. Cannot resize. " . "Existing message count: " . count($existingMessages->where('channel.slack_channel_id', $slackChannelId)) . " --- New message count: " . count($messages) ); throw $e; } @@ -159,7 +161,19 @@ public function handlePostingToSlack(): void { $weekStart = now()->copy()->startOfWeek(); - $events = Event::query() + $events = $this->getEventsForWeek($weekStart); + + if ($events->isEmpty()) { + Log::info("No upcoming events found for the week of {$weekStart->format('F j')}. Cleaning up any existing messages for this week."); + $this->deleteMessagesForWeek($weekStart); + } else { + $this->parseEventsForWeek($events, $weekStart); + } + } + + protected function getEventsForWeek(Carbon $weekStart): Collection + { + return Event::query() ->with(['venue.state', 'organization']) ->published() ->whereBetween('active_at', [ @@ -168,16 +182,9 @@ public function handlePostingToSlack(): void ]) ->oldest('active_at') ->get(); - - if ($events->isEmpty()) { - Log::info("No upcoming events found for the week of {$weekStart->format('F j')}. Cleaning up any existing messages for this week."); - $this->deleteMessagesForWeek($weekStart); - } else { - $this->parseEventsForWeek($events, $weekStart); - } } - private function deleteMessagesForWeek(Carbon $week): void + protected function deleteMessagesForWeek(Carbon $week): void { $messagesToDelete = $this->databaseService->getMessages($week); @@ -186,11 +193,12 @@ private function deleteMessagesForWeek(Carbon $week): void } foreach ($messagesToDelete as $message) { - Log::info("Deleting stale message {$message['message_timestamp']} in channel {$message['slack_channel_id']}."); - Http::withToken(config('slack-events-bot.bot_token')) + $token = $message->channel->workspace->access_token; + Log::info("Deleting stale message {$message->message_timestamp} in channel {$message->channel->slack_channel_id}."); + Http::withToken($token) ->post('https://slack.com/api/chat.delete', [ - 'channel' => $message['slack_channel_id'], - 'ts' => $message['message_timestamp'], + 'channel' => $message->channel->slack_channel_id, + 'ts' => $message->message_timestamp, ]); // We don't care if the deletion fails on Slack's end (e.g., message already deleted), // we still want to remove it from our database. @@ -222,9 +230,9 @@ private function isUnsafeToSpillover( return false; } - private function postNewMessage(string $slackChannelId, array $msgBlocks, string $msgText): array + private function postNewMessage(string $slackChannelId, array $msgBlocks, string $msgText, string $token): array { - $slackResponse = Http::withToken(config('slack-events-bot.bot_token')) + $slackResponse = Http::withToken($token) ->post('https://slack.com/api/chat.postMessage', [ 'channel' => $slackChannelId, 'blocks' => $msgBlocks, diff --git a/app-modules/slack-events-bot/src/Services/DatabaseService.php b/app-modules/slack-events-bot/src/Services/DatabaseService.php index 80ad7732..020d2ae1 100644 --- a/app-modules/slack-events-bot/src/Services/DatabaseService.php +++ b/app-modules/slack-events-bot/src/Services/DatabaseService.php @@ -46,17 +46,11 @@ public function updateMessage( public function getMessages(Carbon $week): Collection { - return SlackMessage::with('channel') + return SlackMessage::with('channel.workspace') ->whereDate('week', $week->toDateString()) ->orderBy('channel_id') ->orderBy('sequence_position') - ->get() - ->map(fn ($message) => [ - 'message' => $message->message, - 'message_timestamp' => $message->message_timestamp, - 'slack_channel_id' => $message->channel->slack_channel_id, - 'sequence_position' => $message->sequence_position, - ]); + ->get(); } public function getMostRecentMessageForChannel(string $slackChannelId): ?array @@ -83,14 +77,19 @@ public function getMostRecentMessageForChannel(string $slackChannelId): ?array ]; } - public function getSlackChannelIds(): Collection + public function getSlackChannels(): Collection { - return SlackChannel::pluck('slack_channel_id'); + return SlackChannel::with('workspace')->get(); } - public function addChannel(string $slackChannelId): SlackChannel + public function addChannel(string $slackChannelId, string $teamId): SlackChannel { - return SlackChannel::firstOrCreate(['slack_channel_id' => $slackChannelId]); + $workspace = SlackWorkspace::where('team_id', $teamId)->firstOrFail(); + + return SlackChannel::firstOrCreate( + ['slack_channel_id' => $slackChannelId], + ['slack_workspace_id' => $workspace->id] + ); } public function removeChannel(string $slackChannelId): int diff --git a/app-modules/slack-events-bot/src/Services/MessageBuilderService.php b/app-modules/slack-events-bot/src/Services/MessageBuilderService.php index 89d4d3c1..2e5ab550 100644 --- a/app-modules/slack-events-bot/src/Services/MessageBuilderService.php +++ b/app-modules/slack-events-bot/src/Services/MessageBuilderService.php @@ -114,6 +114,10 @@ private function buildSingleEventBlock(Event $event): ?array { $text = $this->eventService->generateText($event) . "\n\n"; + if (empty(trim($text))) { // Check if text is empty or just whitespace + return null; + } + return [ 'blocks' => array_merge($this->eventService->generateBlocks($event), [['type' => 'divider']]), 'text' => $text, diff --git a/app-modules/slack-events-bot/tests/Feature/SchedulingTest.php b/app-modules/slack-events-bot/tests/Feature/SchedulingTest.php new file mode 100644 index 00000000..8be9e14c --- /dev/null +++ b/app-modules/slack-events-bot/tests/Feature/SchedulingTest.php @@ -0,0 +1,32 @@ +app->make(Schedule::class); + + $event = collect($schedule->events())->first(fn ($event) => property_exists($event, 'command') && str_contains($event->command, 'slack:delete-old-messages')); + + $this->assertNotNull($event, 'The delete old messages command is not scheduled.'); + $this->assertEquals('0 0 * * *', $event->expression); + } + + /** @test */ + public function it_schedules_the_check_events_api_job_hourly() + { + $schedule = $this->app->make(Schedule::class); + + $event = collect($schedule->events())->first(fn ($event) => $event instanceof \Illuminate\Console\Scheduling\CallbackEvent && + str_contains($event->description, 'CheckEventsApi')); + + $this->assertNotNull($event, 'The check events api job is not scheduled.'); + $this->assertEquals('0 * * * *', $event->expression); + } +} diff --git a/app-modules/slack-events-bot/tests/Feature/SlackEventsBotTest.php b/app-modules/slack-events-bot/tests/Feature/SlackEventsBotTest.php new file mode 100644 index 00000000..a29fec49 --- /dev/null +++ b/app-modules/slack-events-bot/tests/Feature/SlackEventsBotTest.php @@ -0,0 +1,568 @@ +databaseService = app(DatabaseService::class); + $this->eventService = app(EventService::class); + $this->messageBuilderService = app(MessageBuilderService::class); + + // Mock AuthService to control isAdmin behavior in tests + $this->authService = $this->mock(AuthService::class); + $this->authService->shouldReceive('validateSlackRequest')->andReturn(true); // Allow middleware to pass + $this->app->instance(AuthService::class, $this->authService); + + // Set up config values that the controller relies on + config()->set('slack-events-bot.client_id', 'test_client_id'); + config()->set('slack-events-bot.client_secret', 'test_client_secret'); + config()->set('slack-events-bot.scopes', ['commands', 'chat:write']); + config()->set('slack-events-bot.check_api_cooldown_minutes', 15); // For cooldown tests + config()->set('slack-events-bot.max_message_character_length', 3000); // For chunking tests + } + + /** + * Test the /slack/install route. + * Verifies that the route returns an HTML response with the "Add to Slack" button + * and sets a session state. + */ + public function test_install_route() + { + $response = $this->get('/slack/install'); + + $response->assertStatus(200); + $response->assertSee('Add to Slack'); + $response->assertSee('https://slack.com/oauth/v2/authorize?'); + $response->assertSee('client_id=test_client_id'); + $response->assertSee('scope=commands%2Cchat%3Awrite'); + + // Assert that a session state was set + $response->assertSessionHas('slack_oauth_state'); + } + + /** + * Test the /slack/auth route for a successful OAuth callback. + * Mocks the Slack API response and verifies workspace creation. + */ + public function test_auth_route_success() + { + $code = 'test_code'; + $state = uniqid('slack_', true); + Session::put('slack_oauth_state', $state); + + // Mock the HTTP call to Slack's oauth.v2.access endpoint + Http::fake([ + 'https://slack.com/api/oauth.v2.access' => Http::response([ + 'ok' => true, + 'access_token' => 'xoxb-test-token', + 'team' => ['id' => 'T123', 'name' => 'Test Team'], + 'authed_user' => ['id' => 'U123'], + 'bot_user_id' => 'BU123', // Corrected bot_user_id location + 'incoming_webhook' => ['channel' => 'C123', 'channel_id' => 'C123', 'configuration_url' => 'http://example.com'], + ], 200), + 'https://slack.com/api/users.info' => Http::response([ + 'ok' => true, + 'user' => ['is_admin' => true], + ], 200), + ]); + + $response = $this->get("/slack/auth?code={$code}&state={$state}"); + + $response->assertStatus(200); + $response->assertSee('The HackGreenville API bot has been installed successfully!'); + + // Assert that the session state was forgotten + $response->assertSessionMissing('slack_oauth_state'); + + // Assert that the workspace was created in the database + $workspace = SlackWorkspace::where('team_id', 'T123')->first(); + $this->assertNotNull($workspace); + $this->assertEquals('T123', $workspace->team_id); + $this->assertEquals('Test Team', $workspace->team_name); + $this->assertEquals('xoxb-test-token', $workspace->access_token); + } + + /** + * Test the /slack/auth route when the state parameter is invalid. + */ + public function test_auth_route_invalid_state() + { + $code = 'test_code'; + $validState = uniqid('slack_', true); + $invalidState = 'wrong_state'; + Session::put('slack_oauth_state', $validState); + + $response = $this->get("/slack/auth?code={$code}&state={$invalidState}"); + + $response->assertStatus(400); + $response->assertSee('Invalid state. Your session may have expired or you tried to use an old authorization link.'); + $response->assertSessionHas('slack_oauth_state', $validState); // State should still be there if it didn't match + } + + /** + * Test the /slack/auth route when Slack's API returns an error. + */ + public function test_auth_route_slack_error() + { + $code = 'test_code'; + $state = uniqid('slack_', true); + Session::put('slack_oauth_state', $state); + + // Mock the HTTP call to Slack's oauth.v2.access endpoint to return an error + Http::fake([ + 'https://slack.com/api/oauth.v2.access' => Http::response([ + 'ok' => false, + 'error' => 'invalid_code', + ], 200), + ]); + + $response = $this->get("/slack/auth?code={$code}&state={$state}"); + + $response->assertStatus(400); + $response->assertSee('The authorization code is invalid or has expired.'); + $response->assertSessionMissing('slack_oauth_state'); // Should still forget the state + } + + /** + * Test the /slack/events route for URL verification. + */ + public function test_events_url_verification() + { + $challenge = $this->faker->uuid; + $payload = [ + 'token' => 'test_token', + 'challenge' => $challenge, + 'type' => 'url_verification', + ]; + + $response = $this->postJson('/slack/events', $payload); + + $response->assertStatus(200); + $response->assertContent($challenge); + } + + /** + * Test the /slack/events route for the /add_channel command as an admin. + */ + public function test_events_add_channel_command_as_admin() + { + $workspace = SlackWorkspace::factory()->create(['team_id' => 'T123']); + $channelId = 'C1234567890'; + $userId = 'U_ADMIN'; + + // Mock isAdmin to return true + $this->authService->shouldReceive('isAdmin') + ->once() + ->with($userId, $workspace->team_id) + ->andReturn(true); + + $payload = [ + 'token' => 'test_token', + 'team_id' => $workspace->team_id, + 'channel_id' => $channelId, + 'user_id' => $userId, + 'command' => '/add_channel', + 'text' => '', + 'response_url' => 'http://example.com/response', + 'trigger_id' => 'trigger_id', + ]; + + $response = $this->postJson('/slack/events', $payload); + + $response->assertStatus(200); + $response->assertSee('Added channel to slack events bot 👍'); + $this->assertDatabaseHas('slack_channels', [ + 'slack_channel_id' => $channelId, + 'slack_workspace_id' => $workspace->id, + ]); + } + + /** + * Test the /slack/events route for the /add_channel command when not an admin. + */ + public function test_events_add_channel_command_not_admin() + { + $workspace = SlackWorkspace::factory()->create(['team_id' => 'T123']); + $channelId = 'C1234567890'; + $userId = 'U_NON_ADMIN'; + + // Mock isAdmin to return false + $this->authService->shouldReceive('isAdmin') + ->once() + ->with($userId, $workspace->team_id) + ->andReturn(false); + + $payload = [ + 'token' => 'test_token', + 'team_id' => $workspace->team_id, + 'channel_id' => $channelId, + 'user_id' => $userId, + 'command' => '/add_channel', + 'text' => '', + 'response_url' => 'http://example.com/response', + 'trigger_id' => 'trigger_id', + ]; + + $response = $this->postJson('/slack/events', $payload); + + $response->assertStatus(200); + $response->assertSee('You must be a workspace admin in order to run `/add_channel`'); + $this->assertDatabaseMissing('slack_channels', [ + 'slack_channel_id' => $channelId, + ]); + } + + /** + * Test the /slack/events route for the /remove_channel command as an admin. + */ + public function test_events_remove_channel_command_as_admin() + { + $workspace = SlackWorkspace::factory()->create(['team_id' => 'T123']); + $channel = $this->databaseService->addChannel('C1234567890', $workspace->team_id); + $userId = 'U_ADMIN'; + + // Mock isAdmin to return true + $this->authService->shouldReceive('isAdmin') + ->once() + ->with($userId, $workspace->team_id) + ->andReturn(true); + + $payload = [ + 'token' => 'test_token', + 'team_id' => $workspace->team_id, + 'channel_id' => $channel->slack_channel_id, + 'user_id' => $userId, + 'command' => '/remove_channel', + 'text' => '', + 'response_url' => 'http://example.com/response', + 'trigger_id' => 'trigger_id', + ]; + + $response = $this->postJson('/slack/events', $payload); + + $response->assertStatus(200); + $response->assertSee('Removed channel from slack events bot 👍'); + $this->assertDatabaseMissing('slack_channels', [ + 'slack_channel_id' => $channel->slack_channel_id, + ]); + } + + /** + * Test the /slack/events route for the /remove_channel command when not an admin. + */ + public function test_events_remove_channel_command_not_admin() + { + $workspace = SlackWorkspace::factory()->create(['team_id' => 'T123']); + $channel = $this->databaseService->addChannel('C1234567890', $workspace->team_id); + $userId = 'U_NON_ADMIN'; + + // Mock isAdmin to return false + $this->authService->shouldReceive('isAdmin') + ->once() + ->with($userId, $workspace->team_id) + ->andReturn(false); + + $payload = [ + 'token' => 'test_token', + 'team_id' => $workspace->team_id, + 'channel_id' => $channel->slack_channel_id, + 'user_id' => $userId, + 'command' => '/remove_channel', + 'text' => '', + 'response_url' => 'http://example.com/response', + 'trigger_id' => 'trigger_id', + ]; + + $response = $this->postJson('/slack/events', $payload); + + $response->assertStatus(200); + $response->assertSee('You must be a workspace admin in order to run `/remove_channel`'); + $this->assertDatabaseHas('slack_channels', [ // Should still be in DB + 'slack_channel_id' => $channel->slack_channel_id, + ]); + } + + /** + * Test the /slack/events route for the /check_api command. + * Verifies that the job is dispatched and a success message is returned. + */ + public function test_events_check_api_command() + { + Queue::fake(); // Prevent actual job dispatch + SlackWorkspace::factory()->create(['team_id' => 'T123', 'team_name' => 'Test Team', 'access_token' => 'xoxb-test-token', 'domain' => 'testdomain']); + + $userId = 'U123'; + $teamId = 'T123'; + $teamDomain = 'testdomain'; + + // Mock isAdmin as true for simplicity, though not strictly required for /check_api + $this->authService->shouldReceive('isAdmin') + ->andReturn(true); + + $payload = [ + 'token' => 'test_token', + 'team_id' => $teamId, + 'channel_id' => 'C456', + 'user_id' => $userId, + 'command' => '/check_api', + 'team_domain' => $teamDomain, + ]; + + $response = $this->postJson('/slack/events', $payload); + + $response->assertStatus(200); + $response->assertSee('Checking api for events 👍'); + + // Assert that the CheckEventsApi job was dispatched + Queue::assertPushed(CheckEventsApi::class); + + // Assert cooldown was created + $this->assertNotNull($this->databaseService->getCooldownExpiryTime($teamDomain, 'check_api')); + } + + /** + * Test the /slack/events route for the /check_api command when on cooldown. + */ + public function test_events_check_api_command_cooldown() + { + Queue::fake(); + SlackWorkspace::factory()->create(['team_id' => 'T123', 'team_name' => 'Test Team', 'access_token' => 'xoxb-test-token', 'domain' => 'testdomain']); + + $userId = 'U123'; + $teamId = 'T123'; + $teamDomain = 'testdomain'; + + // Create a cooldown that is in the future + $this->databaseService->createCooldown($teamDomain, 'check_api', 15); // 15 minutes from now + + // Mock isAdmin as true for simplicity + $this->authService->shouldReceive('isAdmin') + ->andReturn(true); + + $payload = [ + 'token' => 'test_token', + 'team_id' => $teamId, + 'channel_id' => 'C456', + 'user_id' => $userId, + 'command' => '/check_api', + 'team_domain' => $teamDomain, + ]; + + $response = $this->postJson('/slack/events', $payload); + + $response->assertStatus(200); + $response->assertSee('This command has been run recently and is on a cooldown period.'); + + // Assert that the CheckEventsApi job was NOT dispatched + Queue::assertNotPushed(CheckEventsApi::class); + } + + /** + * Test the /slack/events route for an unknown slash command. + */ + public function test_events_unknown_command() + { + $payload = [ + 'token' => 'test_token', + 'team_id' => 'T123', + 'channel_id' => 'C456', + 'user_id' => 'U123', + 'command' => '/unknown_command', + 'text' => '', + 'response_url' => 'http://example.com/response', + 'trigger_id' => 'trigger_id', + ]; + + $response = $this->postJson('/slack/events', $payload); + + $response->assertStatus(400); + $response->assertSee('Unknown command'); + } + + // Original tests (unmodified as they test service logic directly, not HTTP endpoints) + + public function test_can_add_and_remove_channel() + { + $workspace = SlackWorkspace::factory()->create(); + $channelId = 'C1234567890'; + $this->databaseService->addChannel($channelId, $workspace->team_id); + $this->assertDatabaseHas('slack_channels', ['slack_channel_id' => $channelId]); + + $channels = $this->databaseService->getSlackChannels(); + $this->assertTrue($channels->contains(fn ($channel) => $channel->slack_channel_id === $channelId)); + + $this->databaseService->removeChannel($channelId); + $this->assertDatabaseMissing('slack_channels', ['slack_channel_id' => $channelId]); + } + + public function test_can_create_and_get_messages() + { + $workspace = SlackWorkspace::factory()->create(); + $channelId = 'C1234567890'; + $channel = $this->databaseService->addChannel($channelId, $workspace->team_id); + $this->assertDatabaseHas('slack_channels', ['slack_channel_id' => $channelId]); + + $week = Carbon::now()->startOfWeek(); + $message = 'Test message content'; + $timestamp = '1234567890.123456'; + $sequencePosition = 0; + + $this->databaseService->createMessage($week, $message, $timestamp, $channelId, $sequencePosition); + + $this->assertDatabaseHas('slack_messages', [ + 'message' => $message, + 'message_timestamp' => $timestamp, + 'week' => $week->toDateTimeString(), + 'sequence_position' => $sequencePosition, + 'channel_id' => $channel->id, + ]); + + $messages = $this->databaseService->getMessages($week); + $this->assertCount(1, $messages); + $this->assertEquals($message, $messages->first()->message); + $this->assertEquals($channelId, $messages->first()->channel->slack_channel_id); + } + + public function test_event_parsing() + { + $state = State::factory()->create(['abbr' => 'SC']); + + $organization = Org::factory()->create(['title' => 'Test Group']); + $venue = Venue::factory()->create([ + 'name' => 'Test Venue', + 'address' => '123 Main St', + 'city' => 'Greenville', + 'state_id' => $state->id, + 'zipcode' => '29601', + ]); + $event = Event::factory()->create([ + 'event_name' => 'Test Event', + 'description' => 'This is a test event description', + 'uri' => 'https://example.com/event', + 'event_uuid' => $this->faker->uuid, + 'active_at' => Carbon::now()->addDays(7), + 'organization_id' => $organization->id, + 'venue_id' => $venue->id, + ]); + + $event->load('organization', 'venue'); + + $blocks = $this->eventService->generateBlocks($event); + $this->assertIsArray($blocks); + $this->assertEquals('header', $blocks[0]['type']); + $this->assertEquals('section', $blocks[1]['type']); + $this->assertEquals('Test Event', $blocks[0]['text']['text']); + $this->assertArrayHasKey('type', $blocks[1]['text']); + $this->assertArrayHasKey('text', $blocks[1]['text']); + $this->assertStringContainsString('This is a test event description', $blocks[1]['text']['text']); + + $text = $this->eventService->generateText($event); + $this->assertStringContainsString('Test Event', $text); + $this->assertStringContainsString('Test Group', $text); + $this->assertStringContainsString('Test Venue', $text); + $this->assertStringContainsString('https://example.com/event', $text); + $this->assertStringContainsString('Upcoming ✅', $text); + } + + public function test_cooldown_functionality() + { + $accessor = 'test-workspace'; + $resource = 'check_api'; + + // No cooldown initially + $expiry = $this->databaseService->getCooldownExpiryTime($accessor, $resource); + $this->assertNull($expiry); + + // Create cooldown + $this->databaseService->createCooldown($accessor, $resource, 15); + + // Check cooldown exists and is in future + $expiry = $this->databaseService->getCooldownExpiryTime($accessor, $resource); + $this->assertNotNull($expiry); + $this->assertTrue($expiry->isFuture()); + } + + public function test_message_chunking() + { + $weekStart = Carbon::now()->startOfWeek(); + $state = State::factory()->create(['abbr' => 'SC']); + + $eventsData = []; + for ($i = 0; $i < 10; $i++) { + $organization = Org::factory()->create(['title' => "Group {$i}"]); + $venue = Venue::factory()->create([ + 'name' => "Venue {$i}", + 'address' => "{$i} Main St", + 'city' => 'Greenville', + 'state_id' => $state->id, + 'zipcode' => '29601', + ]); + + $event = Event::factory()->create([ + 'event_name' => "Event {$i} with a very long title that takes up space", + 'description' => str_repeat("This is a long description. ", 10), + 'uri' => "https://example.com/event-{$i}", + 'active_at' => $weekStart->copy()->addDays($i % 7), + 'organization_id' => $organization->id, + 'venue_id' => $venue->id, + 'event_uuid' => $this->faker->uuid, + ]); + + $event->load('organization', 'venue'); + + $eventsData[] = $event; + } + + $eventsCollection = collect($eventsData); + + $eventBlocks = $this->messageBuilderService->buildEventBlocks($eventsCollection); + $this->assertInstanceOf(Collection::class, $eventBlocks); + $this->assertGreaterThan(0, $eventBlocks->count()); + + $chunkedMessages = $this->messageBuilderService->chunkMessages($eventBlocks, $weekStart); + $this->assertIsArray($chunkedMessages); + $this->assertGreaterThan(0, count($chunkedMessages)); + + // Verify each message has required structure + foreach ($chunkedMessages as $message) { + $this->assertArrayHasKey('blocks', $message); + $this->assertArrayHasKey('text', $message); + $this->assertLessThan( + config('slack-events-bot.max_message_character_length'), + mb_strlen($message['text']) + ); + } + } +} diff --git a/app-modules/slack-events-bot/tests/Jobs/CheckEventsApiTest.php b/app-modules/slack-events-bot/tests/Jobs/CheckEventsApiTest.php new file mode 100644 index 00000000..9ef9a2ca --- /dev/null +++ b/app-modules/slack-events-bot/tests/Jobs/CheckEventsApiTest.php @@ -0,0 +1,89 @@ +shouldReceive('handlePostingToSlack') + ->once() + ->andReturnNull(); // Simulate successful execution + + // Bind the mock to the service container + $this->app->instance(BotService::class, $botServiceMock); + + // Expect specific log messages + Log::shouldReceive('info') + ->once() + ->with('Executing CheckEventsApi job.'); + + Log::shouldReceive('info') + ->once() + ->with('Finished CheckEventsApi job.'); + + // Dispatch the job + CheckEventsApi::dispatchSync(); + } + + /** + * Test that the job handles exceptions from BotService::handlePostingToSlack() + * by logging the error and re-throwing the exception. + */ + public function test_job_handles_exceptions() + { + // Create a dummy exception + $exception = new Exception('Simulated BotService error'); + + // Mock the BotService to throw an exception + $botServiceMock = Mockery::mock(BotService::class); + $botServiceMock->shouldReceive('handlePostingToSlack') + ->once() + ->andThrow($exception); + + // Bind the mock to the service container + $this->app->instance(BotService::class, $botServiceMock); + + // Expect specific log messages + Log::shouldReceive('info') + ->once() + ->with('Executing CheckEventsApi job.'); + + Log::shouldReceive('error') + ->once() + ->withArgs(fn ($message, $context) => str_contains($message, 'CheckEventsApi job failed with an exception.') && + $context['exception'] === get_class($exception) && + $context['message'] === $exception->getMessage() && + $context['file'] === $exception->getFile() && + $context['line'] === $exception->getLine()); + + // Expect the exception to be re-thrown + $this->expectException(Throwable::class); + $this->expectExceptionMessage('Simulated BotService error'); + + // Dispatch the job + CheckEventsApi::dispatchSync(); + } +} diff --git a/app-modules/slack-events-bot/tests/Models/SlackWorkspaceTest.php b/app-modules/slack-events-bot/tests/Models/SlackWorkspaceTest.php new file mode 100644 index 00000000..e5a8a1d7 --- /dev/null +++ b/app-modules/slack-events-bot/tests/Models/SlackWorkspaceTest.php @@ -0,0 +1,63 @@ +access_token = $plainTextToken; + + // The attribute should be encrypted, so it should not be the plain text token + $this->assertNotEquals($plainTextToken, $workspace->getAttributes()['access_token']); + // And it should be decryptable back to the original + $this->assertEquals($plainTextToken, Crypt::decryptString($workspace->getAttributes()['access_token'])); + } + + /** @test */ + public function it_decrypts_access_token_on_get() + { + $workspace = new SlackWorkspace; + $plainTextToken = 'fake-token-abcdefghijklmnopqrstuvwxyz'; + $encryptedToken = Crypt::encryptString($plainTextToken); + + // Manually set the encrypted token to simulate it coming from the database + $workspace->setRawAttributes(['access_token' => $encryptedToken]); + + // When getting the attribute, it should be decrypted + $this->assertEquals($plainTextToken, $workspace->access_token); + } + + /** @test */ + public function it_handles_decrypt_exception_gracefully() + { + $invalidToken = 'not-a-valid-encrypted-string'; + + // Manually set an invalid token and team_id to simulate it coming from the database + $workspace = new SlackWorkspace; + $workspace->setRawAttributes([ + 'access_token' => $invalidToken, + 'team_id' => 'T12345', // Set team_id here + ]); + + Log::shouldReceive('warning') + ->once() + ->with('Could not decrypt access token for workspace. The token might be stored in plain text.', Mockery::subset([ + 'workspace_id' => null, // Expect null for a new model + 'team_id' => 'T12345', + ])); + + // When getting the attribute, it should return the original invalid token + // and log a warning. + $this->assertEquals($invalidToken, $workspace->access_token); + } +} diff --git a/app-modules/slack-events-bot/tests/Services/AuthServiceTest.php b/app-modules/slack-events-bot/tests/Services/AuthServiceTest.php new file mode 100644 index 00000000..0268cbe1 --- /dev/null +++ b/app-modules/slack-events-bot/tests/Services/AuthServiceTest.php @@ -0,0 +1,197 @@ +authService = $this->app->make(AuthService::class); + } + + /** + * Test getUserInfo method. + */ + public function test_get_user_info() + { + $teamId = 'T123'; + $userId = 'U123'; + $accessToken = 'xoxb-test-token'; + + // Mock Http facade + Http::shouldReceive('withToken') + ->with($accessToken) + ->andReturnSelf(); + + Http::shouldReceive('get') + ->with('https://slack.com/api/users.info', ['user' => $userId]) + ->andReturn(new \Illuminate\Http\Client\Response(new \GuzzleHttp\Psr7\Response(200, [], json_encode(['ok' => true, 'user' => ['id' => $userId, 'is_admin' => true]])))); + + SlackWorkspace::factory()->create([ + 'team_id' => $teamId, + 'access_token' => $accessToken, + ]); + + $userInfo = $this->authService->getUserInfo($userId, $teamId); + + $this->assertIsArray($userInfo); + $this->assertTrue($userInfo['ok']); + $this->assertEquals($userId, $userInfo['user']['id']); + } + + public function test_get_user_info_workspace_not_found() + { + $teamId = 'T123'; + $userId = 'U123'; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Workspace with team ID {$teamId} not found."); + + $this->authService->getUserInfo($userId, $teamId); + } + + public function test_get_user_info_slack_api_error() + { + $teamId = 'T123'; + $userId = 'U123'; + $accessToken = 'xoxb-test-token'; + + // Mock Http facade to return an error + Http::shouldReceive('withToken') + ->with($accessToken) + ->andReturnSelf(); + + Http::shouldReceive('get') + ->with('https://slack.com/api/users.info', ['user' => $userId]) + ->andReturn(new \Illuminate\Http\Client\Response(new \GuzzleHttp\Psr7\Response(200, [], json_encode(['ok' => false, 'error' => 'user_not_found'])))); + + SlackWorkspace::factory()->create([ + 'team_id' => $teamId, + 'access_token' => $accessToken, + ]); + + $userInfo = $this->authService->getUserInfo($userId, $teamId); + + $this->assertIsArray($userInfo); + $this->assertFalse($userInfo['ok']); + $this->assertEquals('user_not_found', $userInfo['error']); + } + + /** + * Test isAdmin method. + */ + public function test_is_admin() + { + $userId = 'U123'; + $teamId = 'T123'; + + // Test case: User is admin + $this->authService = Mockery::mock(AuthService::class . '[getUserInfo]'); + $this->authService->shouldReceive('getUserInfo') + ->once() + ->with($userId, $teamId) + ->andReturn(['ok' => true, 'user' => ['is_admin' => true]]); + + $this->assertTrue($this->authService->isAdmin($userId, $teamId)); + + // Test case: User is not admin + $this->authService = Mockery::mock(AuthService::class . '[getUserInfo]'); + $this->authService->shouldReceive('getUserInfo') + ->once() + ->with($userId, $teamId) + ->andReturn(['ok' => true, 'user' => ['is_admin' => false]]); + + $this->assertFalse($this->authService->isAdmin($userId, $teamId)); + + // Test case: User info not available (e.g., 'user' key missing or 'is_admin' missing) + $this->authService = Mockery::mock(AuthService::class . '[getUserInfo]'); + $this->authService->shouldReceive('getUserInfo') + ->once() + ->with($userId, $teamId) + ->andReturn(['ok' => true, 'user' => []]); // Missing 'is_admin' + + $this->assertFalse($this->authService->isAdmin($userId, $teamId)); + + $this->authService = Mockery::mock(AuthService::class . '[getUserInfo]'); + $this->authService->shouldReceive('getUserInfo') + ->once() + ->with($userId, $teamId) + ->andReturn(['ok' => true]); // Missing 'user' key + + $this->assertFalse($this->authService->isAdmin($userId, $teamId)); + } + + /** + * Test validateSlackRequest method. + */ + public function test_validate_slack_request() + { + $signingSecret = 'test_signing_secret'; + config(['slack-events-bot.signing_secret' => $signingSecret]); + + // Test case: Valid request + $timestamp = time(); + $body = 'token=gIkuvaNzCQz2PNYwY2TBCJg2&team_id=T0001&team_domain=example&channel_id=C2147483705&channel_name=fun&user_id=U2147483697&user_name=steve&command=/weather&text=94070&response_url=https://hooks.slack.com/commands/1234/5678&trigger_id=1334522460.139236018'; + $signature = 'v0=' . hash_hmac('sha256', 'v0:' . $timestamp . ':' . $body, $signingSecret); + + $request = Request::create('/slack/events', 'POST', [], [], [], [ + 'HTTP_X_SLACK_REQUEST_TIMESTAMP' => $timestamp, + 'HTTP_X_SLACK_SIGNATURE' => $signature, + ], $body); + + $this->assertTrue($this->authService->validateSlackRequest($request)); + + // Test case: Missing timestamp or signature + Log::shouldReceive('warning')->once()->with('Slack request validation failed: Missing timestamp or signature.'); + $request = Request::create('/slack/events', 'POST', [], [], [], [], $body); + $this->assertFalse($this->authService->validateSlackRequest($request)); + + // Test case: Timestamp expired (replay attack) + Log::shouldReceive('warning')->once()->with('Slack request validation failed: Timestamp expired (replay attack?).', Mockery::any()); + $timestamp = time() - (60 * 6); // 6 minutes ago + $signature = 'v0=' . hash_hmac('sha256', 'v0:' . $timestamp . ':' . $body, $signingSecret); + $request = Request::create('/slack/events', 'POST', [], [], [], [ + 'HTTP_X_SLACK_REQUEST_TIMESTAMP' => $timestamp, + 'HTTP_X_SLACK_SIGNATURE' => $signature, + ], $body); + $this->assertFalse($this->authService->validateSlackRequest($request)); + + // Test case: Signing secret not configured + Log::shouldReceive('error')->once()->with('Slack request validation failed: Signing secret is not configured.'); + config(['slack-events-bot.signing_secret' => null]); + $timestamp = time(); + $signature = 'v0=' . hash_hmac('sha256', 'v0:' . $timestamp . ':' . $body, ''); // Empty secret + $request = Request::create('/slack/events', 'POST', [], [], [], [ + 'HTTP_X_SLACK_REQUEST_TIMESTAMP' => $timestamp, + 'HTTP_X_SLACK_SIGNATURE' => $signature, + ], $body); + $this->assertFalse($this->authService->validateSlackRequest($request)); + config(['slack-events-bot.signing_secret' => $signingSecret]); // Reset for other tests + + // Test case: Signature mismatch + Log::shouldReceive('warning')->once()->with('Slack request validation failed: Signature mismatch.'); + $timestamp = time(); + $invalidSignature = 'v0=' . hash_hmac('sha256', 'v0:' . $timestamp . ':' . 'invalid_body', $signingSecret); + $request = Request::create('/slack/events', 'POST', [], [], [], [ + 'HTTP_X_SLACK_REQUEST_TIMESTAMP' => $timestamp, + 'HTTP_X_SLACK_SIGNATURE' => $invalidSignature, + ], $body); + $this->assertFalse($this->authService->validateSlackRequest($request)); + } +} diff --git a/app-modules/slack-events-bot/tests/Services/BotServiceTest.php b/app-modules/slack-events-bot/tests/Services/BotServiceTest.php new file mode 100644 index 00000000..b29d42a4 --- /dev/null +++ b/app-modules/slack-events-bot/tests/Services/BotServiceTest.php @@ -0,0 +1,450 @@ +databaseServiceMock = Mockery::mock(DatabaseService::class); + $this->messageBuilderServiceMock = Mockery::mock(MessageBuilderService::class); + + $this->botService = new BotService( + $this->databaseServiceMock, + $this->messageBuilderServiceMock + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * Test handlePostingToSlack when no events are found. + */ + public function test_handle_posting_to_slack_no_events() + { + $this->refreshApplication(); + + + + + + $this->botService = Mockery::mock(BotService::class . '[getEventsForWeek, deleteMessagesForWeek]', [ + $this->databaseServiceMock, + $this->messageBuilderServiceMock + ])->makePartial()->shouldAllowMockingProtectedMethods(); + + // Expect getEventsForWeek to be called and return an empty collection + $this->botService->shouldReceive('getEventsForWeek') + ->once() + ->andReturn(new Collection); + + // Expect deleteMessagesForWeek to be called + $this->botService->shouldReceive('deleteMessagesForWeek') + ->once(); + + // Expect Log::info for no events found + Log::shouldReceive('info') + ->once() + ->withArgs(fn ($message) => str_contains($message, 'No upcoming events found for the week of')); + + // Call the real handlePostingToSlack method on the botService instance + $this->botService->handlePostingToSlack(); + + $this->assertTrue(true); // Assert that the test ran without issues + } + + /** + * Test handlePostingToSlack when events are found. + */ + public function test_handle_posting_to_slack_with_events() + { + $events = new Collection([Mockery::mock(Event::class)]); + + $eventMock = Mockery::mock(Event::class); + $queryBuilderMock = Mockery::mock(\Illuminate\Database\Eloquent\Builder::class); + + $eventMock->shouldReceive('newQuery')->andReturn($queryBuilderMock); + $queryBuilderMock->shouldReceive('with')->andReturnSelf(); + $queryBuilderMock->shouldReceive('published')->andReturnSelf(); + $queryBuilderMock->shouldReceive('whereBetween')->andReturnSelf(); + $queryBuilderMock->shouldReceive('oldest')->andReturnSelf(); + $queryBuilderMock->shouldReceive('get')->andReturn($events); + + $this->app->instance(Event::class, $eventMock); + + // Expect messageBuilderService methods to be called + $this->messageBuilderServiceMock->shouldReceive('buildEventBlocks') + ->once() + ->with(Mockery::any()) // Use Mockery::any() for argument matching + ->andReturn(new Collection); // Return a Collection + + $this->messageBuilderServiceMock->shouldReceive('chunkMessages') + ->once() + ->with(Mockery::any(), Mockery::any()) + ->andReturn([]); + + // Expect postOrUpdateMessages to be called + $this->databaseServiceMock->shouldReceive('getSlackChannels') + ->once() + ->andReturn(new Collection); + + $this->databaseServiceMock->shouldReceive('getMessages') + ->once() + ->andReturn(new Collection); + + // Expect Log::info for posting/updating messages (from postOrUpdateMessages) + Log::shouldReceive('info') + ->zeroOrMoreTimes(); + + $this->botService->handlePostingToSlack(); + + $this->assertTrue(true); // Assert that the test ran without issues + } + + /** + * Test parseEventsForWeek method. + */ + public function test_parse_events_for_week() + { + $events = new Collection([Mockery::mock(Event::class)]); + $weekStart = Carbon::now()->startOfWeek(); + $eventBlocks = new Collection([['type' => 'section', 'text' => ['type' => 'mrkdwn', 'text' => 'Event 1']]]); + $chunkedMessages = [['text' => 'Message 1', 'blocks' => []]]; + + $this->messageBuilderServiceMock->shouldReceive('buildEventBlocks') + ->once() + ->with(Mockery::any()) + ->andReturn($eventBlocks); + + $this->messageBuilderServiceMock->shouldReceive('chunkMessages') + ->once() + ->with(Mockery::any(), Mockery::any()) + ->andReturn($chunkedMessages); + + // Mock dependencies for postOrUpdateMessages + $this->databaseServiceMock->shouldReceive('getSlackChannels') + ->once() + ->andReturn(new Collection); + + $this->databaseServiceMock->shouldReceive('getMessages') + ->once() + ->andReturn(new Collection); + + Log::shouldReceive('info') + ->zeroOrMoreTimes(); + + $this->botService->parseEventsForWeek($events, $weekStart); + + $this->assertTrue(true); // Assert that the test ran without issues + } + + /** + * Test postOrUpdateMessages - posting a new message. + */ + public function test_post_or_update_messages_post_new() + { + $week = Carbon::now()->startOfWeek(); + $messages = [['text' => 'New message', 'blocks' => []]]; + $channels = new Collection([ + (object)['slack_channel_id' => 'C123', 'workspace' => (object)['access_token' => 'xoxb-token']], + ]); + $existingMessages = new Collection; + + $this->databaseServiceMock->shouldReceive('getSlackChannels') + ->once() + ->andReturn($channels); + + $this->databaseServiceMock->shouldReceive('getMessages') + ->once() + ->with($week) + ->andReturn($existingMessages); + + // Mock isUnsafeToSpillover indirectly by mocking getMostRecentMessageForChannel + $this->databaseServiceMock->shouldReceive('getMostRecentMessageForChannel') + ->zeroOrMoreTimes() + ->andReturn(null); // Simulate safe spillover + + Log::shouldReceive('info') + ->once() + ->withArgs(fn ($message) => str_contains($message, 'Posting new message')); + + Http::shouldReceive('withToken') + ->once() + ->with('xoxb-token') + ->andReturnSelf(); + + $mockResponse = Mockery::mock(Response::class); + $mockResponse->shouldReceive('successful')->andReturn(true); + $mockResponse->shouldReceive('json')->andReturn(['ok' => true, 'ts' => '123.456']); + + Http::shouldReceive('post') + ->once() + ->with('https://slack.com/api/chat.postMessage', Mockery::any()) + ->andReturn($mockResponse); + + $this->databaseServiceMock->shouldReceive('createMessage') + ->once() + ->with($week, 'New message', '123.456', 'C123', 0); + + $this->botService->postOrUpdateMessages($week, $messages); + + $this->assertTrue(true); // Assert that the test ran without issues + } + + /** + * Test postOrUpdateMessages - updating an existing message. + */ + public function test_post_or_update_messages_update_existing() + { + $week = Carbon::now()->startOfWeek(); + $messages = [['text' => 'Updated message', 'blocks' => []]]; + $channels = new Collection([ + (object)['slack_channel_id' => 'C123', 'workspace' => (object)['access_token' => 'xoxb-token']], + ]); + $existingMessages = new Collection([ + (object)[ + 'channel' => (object)['slack_channel_id' => 'C123'], + 'sequence_position' => 0, + 'message_timestamp' => '123.456', + 'message' => 'Old message', + ], + ]); + + $this->databaseServiceMock->shouldReceive('getSlackChannels') + ->once() + ->andReturn($channels); + + $this->databaseServiceMock->shouldReceive('getMessages') + ->once() + ->with($week) + ->andReturn($existingMessages); + + // Mock isUnsafeToSpillover indirectly + $this->databaseServiceMock->shouldReceive('getMostRecentMessageForChannel') + ->zeroOrMoreTimes() + ->andReturn(null); + + Log::shouldReceive('info') + ->once() + ->withArgs(fn ($message) => str_contains($message, 'Updating message')); + + Http::shouldReceive('withToken') + ->once() + ->with('xoxb-token') + ->andReturnSelf(); + + $mockResponse = Mockery::mock(Response::class); + $mockResponse->shouldReceive('successful')->andReturn(true); + $mockResponse->shouldReceive('json')->andReturn(['ok' => true]); + + Http::shouldReceive('post') + ->once() + ->with('https://slack.com/api/chat.update', Mockery::any()) + ->andReturn($mockResponse); + + $this->databaseServiceMock->shouldReceive('updateMessage') + ->once() + ->with($week, 'Updated message', '123.456', 'C123'); + + $this->botService->postOrUpdateMessages($week, $messages); + + $this->assertTrue(true); // Assert that the test ran without issues + } + + /** + * Test postOrUpdateMessages - deleting an old message. + */ + public function test_post_or_update_messages_delete_old() + { + $week = Carbon::now()->startOfWeek(); + $messages = []; // No new messages + $channels = new Collection([ + (object)['slack_channel_id' => 'C123', 'workspace' => (object)['access_token' => 'xoxb-token']], + ]); + $existingMessages = new Collection([ + (object)[ + 'channel' => (object)['slack_channel_id' => 'C123'], + 'sequence_position' => 0, + 'message_timestamp' => '123.456', + 'message' => 'Old message to be deleted', + ], + ]); + + $this->databaseServiceMock->shouldReceive('getSlackChannels') + ->once() + ->andReturn($channels); + + $this->databaseServiceMock->shouldReceive('getMessages') + ->once() + ->with($week) + ->andReturn($existingMessages); + + Log::shouldReceive('info') + ->once() + ->withArgs(fn ($message) => str_contains($message, 'Deleting old message')); + + Http::shouldReceive('withToken') + ->once() + ->with('xoxb-token') + ->andReturnSelf(); + + $mockResponse = Mockery::mock(Response::class); + $mockResponse->shouldReceive('successful')->andReturn(true); + $mockResponse->shouldReceive('json')->andReturn(['ok' => true]); // Mock json() even if not used directly + + Http::shouldReceive('post') + ->once() + ->with('https://slack.com/api/chat.delete', Mockery::any()) + ->andReturn($mockResponse); + + $this->databaseServiceMock->shouldReceive('deleteMessage') + ->once() + ->with('C123', '123.456'); + + $this->botService->postOrUpdateMessages($week, $messages); + + $this->assertTrue(true); // Assert that the test ran without issues + } + + /** + * Test postOrUpdateMessages - UnsafeMessageSpilloverException is thrown. + */ + public function test_post_or_update_messages_unsafe_spillover_exception() + { + // Http::fake() to prevent real HTTP calls + Http::fake([ + 'https://slack.com/api/chat.update' => Http::response(['ok' => true], 200), + 'https://slack.com/api/chat.postMessage' => Http::response(['ok' => true, 'ts' => '123.456'], 200), + ]); + + $week = Carbon::now()->startOfWeek(); + // Change messages to have more items than existingMessages + $messages = [['text' => 'New message 1', 'blocks' => []], ['text' => 'New message 2', 'blocks' => []]]; + $channels = new Collection([ + (object)['slack_channel_id' => 'C123', 'workspace' => (object)['access_token' => 'xoxb-token']], + ]); + $existingMessages = new Collection([ + (object)[ + 'channel' => (object)['slack_channel_id' => 'C123'], + 'sequence_position' => 0, + 'message_timestamp' => '123.456', + 'message' => 'Old message', + ], + ]); + + $this->databaseServiceMock->shouldReceive('getSlackChannels') + ->once() + ->andReturn($channels); + + $this->databaseServiceMock->shouldReceive('getMessages') + ->once() + ->with($week) + ->andReturn($existingMessages); + + // Mock isUnsafeToSpillover to return true + $this->databaseServiceMock->shouldReceive('getMostRecentMessageForChannel') + ->once() + ->andReturn(['week' => $week->copy()->addWeek()->toDateString()]); + + // Ensure updateMessage is NOT called + $this->databaseServiceMock->shouldNotReceive('updateMessage'); + + Log::shouldReceive('info') + ->zeroOrMoreTimes(); // Add this expectation + + Log::shouldReceive('error') + ->once() + ->withArgs(fn ($message) => str_contains($message, 'Cannot update messages for') && + str_contains($message, 'New events have caused the number of messages needed to increase')); + + $this->expectException(UnsafeMessageSpilloverException::class); + + $this->botService->postOrUpdateMessages($week, $messages); + } + + /** + * Test postOrUpdateMessages - Slack API error when updating. + */ + public function test_post_or_update_messages_slack_api_error_update() + { + $week = Carbon::now()->startOfWeek(); + $messages = [['text' => 'Updated message', 'blocks' => []]]; + $channels = new Collection([ + (object)['slack_channel_id' => 'C123', 'workspace' => (object)['access_token' => 'xoxb-token']], + ]); + $existingMessages = new Collection([ + (object)[ + 'channel' => (object)['slack_channel_id' => 'C123'], + 'sequence_position' => 0, + 'message_timestamp' => '123.456', + 'message' => 'Old message', + ], + ]); + + $this->databaseServiceMock->shouldReceive('getSlackChannels') + ->once() + ->andReturn($channels); + + $this->databaseServiceMock->shouldReceive('getMessages') + ->once() + ->with($week) + ->andReturn($existingMessages); + + // Mock isUnsafeToSpillover indirectly + $this->databaseServiceMock->shouldReceive('getMostRecentMessageForChannel') + ->zeroOrMoreTimes() + ->andReturn(null); + + Log::shouldReceive('info') + ->once() + ->withArgs(fn ($message) => str_contains($message, 'Updating message')); + + Http::shouldReceive('withToken') + ->once() + ->with('xoxb-token') + ->andReturnSelf(); + + $mockResponse = Mockery::mock(Response::class); + $mockResponse->shouldReceive('successful')->andReturn(false); + $mockResponse->shouldReceive('json')->andReturn(['ok' => false, 'error' => 'update_failed']); + + Http::shouldReceive('post') + ->once() + ->with('https://slack.com/api/chat.update', Mockery::any()) + ->andReturn($mockResponse); + + Log::shouldReceive('error') + ->once() + ->withArgs(fn ($message, $context) => str_contains($message, 'Failed to update message') && + $context['error'] === 'update_failed'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Slack API error when updating message: update_failed'); + + $this->botService->postOrUpdateMessages($week, $messages); + } +} diff --git a/app-modules/slack-events-bot/tests/Services/DatabaseServiceTest.php b/app-modules/slack-events-bot/tests/Services/DatabaseServiceTest.php new file mode 100644 index 00000000..a140263b --- /dev/null +++ b/app-modules/slack-events-bot/tests/Services/DatabaseServiceTest.php @@ -0,0 +1,476 @@ +databaseService = new DatabaseService; + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** @test */ + public function it_creates_a_message(): void + { + $workspace = SlackWorkspace::factory()->create(); + $channel = SlackChannel::factory()->create(['slack_workspace_id' => $workspace->id]); + + $week = Carbon::now(); + $message = 'Test message'; + $messageTimestamp = '12345.6789'; + $slackChannelId = $channel->slack_channel_id; + $sequencePosition = 1; + + $slackMessage = $this->databaseService->createMessage( + $week, + $message, + $messageTimestamp, + $slackChannelId, + $sequencePosition + ); + + $this->assertInstanceOf(SlackMessage::class, $slackMessage); + $this->assertDatabaseHas('slack_messages', [ + 'week' => $week->toDateTimeString(), + 'message' => $message, + 'message_timestamp' => $messageTimestamp, + 'channel_id' => $channel->id, + 'sequence_position' => $sequencePosition, + ]); + } + + /** @test */ + public function it_updates_a_message(): void + { + $workspace = SlackWorkspace::factory()->create(); + $channel = SlackChannel::factory()->create(['slack_workspace_id' => $workspace->id]); + + $week = Carbon::now(); + $oldMessage = 'Old message'; + $newMessage = 'New message'; + $messageTimestamp = '12345.6789'; + $slackChannelId = $channel->slack_channel_id; + $sequencePosition = 1; + + SlackMessage::create([ + 'week' => $week->toDateTimeString(), + 'message' => $oldMessage, + 'message_timestamp' => $messageTimestamp, + 'channel_id' => $channel->id, + 'sequence_position' => $sequencePosition, + ]); + + $updatedRows = $this->databaseService->updateMessage( + $week, + $newMessage, + $messageTimestamp, + $slackChannelId + ); + + $this->assertEquals(1, $updatedRows); + $this->assertDatabaseHas('slack_messages', [ + 'week' => $week->toDateTimeString(), + 'message' => $newMessage, + 'message_timestamp' => $messageTimestamp, + 'channel_id' => $channel->id, + ]); + $this->assertDatabaseMissing('slack_messages', [ + 'message' => $oldMessage, + ]); + } + + /** @test */ + public function it_gets_messages_for_a_week(): void + { + $workspace = SlackWorkspace::factory()->create(); + $channel1 = SlackChannel::factory()->create(['slack_workspace_id' => $workspace->id]); + $channel2 = SlackChannel::factory()->create(['slack_workspace_id' => $workspace->id]); + + $week = Carbon::now(); + + SlackMessage::create([ + 'week' => $week->toDateTimeString(), + 'message' => 'Message 1', + 'message_timestamp' => '1', + 'channel_id' => $channel1->id, + 'sequence_position' => 1, + ]); + SlackMessage::create([ + 'week' => $week->toDateTimeString(), + 'message' => 'Message 2', + 'message_timestamp' => '2', + 'channel_id' => $channel2->id, + 'sequence_position' => 2, + ]); + SlackMessage::create([ + 'week' => Carbon::now()->subWeek()->toDateTimeString(), // Different week + 'message' => 'Message 3', + 'message_timestamp' => '3', + 'channel_id' => $channel1->id, + 'sequence_position' => 3, + ]); + + $messages = $this->databaseService->getMessages($week); + + $this->assertCount(2, $messages); + $this->assertEquals('Message 1', $messages->first()->message); + $this->assertEquals('Message 2', $messages->last()->message); + } + + /** @test */ + public function it_gets_most_recent_message_for_channel(): void + { + $workspace = SlackWorkspace::factory()->create(); + $channel = SlackChannel::factory()->create(['slack_workspace_id' => $workspace->id]); + + SlackMessage::create([ + 'week' => Carbon::now()->subDays(2)->toDateTimeString(), + 'message' => 'Old message', + 'message_timestamp' => '1', + 'channel_id' => $channel->id, + 'sequence_position' => 1, + ]); + $expectedMessage = SlackMessage::create([ + 'week' => Carbon::now()->toDateTimeString(), + 'message' => 'New message', + 'message_timestamp' => '2', + 'channel_id' => $channel->id, + 'sequence_position' => 2, + ]); + + $mostRecentMessage = $this->databaseService->getMostRecentMessageForChannel($channel->slack_channel_id); + + $this->assertIsArray($mostRecentMessage); + $this->assertEquals($expectedMessage->message, $mostRecentMessage['message']); + $this->assertEquals($expectedMessage->message_timestamp, $mostRecentMessage['message_timestamp']); + } + + /** @test */ + public function it_returns_null_if_channel_not_found_for_most_recent_message(): void + { + $mostRecentMessage = $this->databaseService->getMostRecentMessageForChannel('non-existent-channel'); + $this->assertNull($mostRecentMessage); + } + + /** @test */ + public function it_returns_null_if_no_messages_for_channel(): void + { + $workspace = SlackWorkspace::factory()->create(); + $channel = SlackChannel::factory()->create(['slack_workspace_id' => $workspace->id]); + + $mostRecentMessage = $this->databaseService->getMostRecentMessageForChannel($channel->slack_channel_id); + $this->assertNull($mostRecentMessage); + } + + /** @test */ + public function it_gets_slack_channels(): void + { + $workspace = SlackWorkspace::factory()->create(); + SlackChannel::factory()->count(3)->create(['slack_workspace_id' => $workspace->id]); + + $channels = $this->databaseService->getSlackChannels(); + + $this->assertCount(3, $channels); + $this->assertInstanceOf(SlackChannel::class, $channels->first()); + } + + /** @test */ + public function it_adds_a_channel(): void + { + $workspace = SlackWorkspace::factory()->create(); + $slackChannelId = 'C12345'; + $teamId = $workspace->team_id; + + $channel = $this->databaseService->addChannel($slackChannelId, $teamId); + + $this->assertInstanceOf(SlackChannel::class, $channel); + $this->assertDatabaseHas('slack_channels', [ + 'slack_channel_id' => $slackChannelId, + 'slack_workspace_id' => $workspace->id, + ]); + } + + /** @test */ + public function it_removes_a_channel(): void + { + $workspace = SlackWorkspace::factory()->create(); + $channel = SlackChannel::factory()->create(['slack_workspace_id' => $workspace->id]); + + $deletedRows = $this->databaseService->removeChannel($channel->slack_channel_id); + + $this->assertEquals(1, $deletedRows); + $this->assertDatabaseMissing('slack_channels', [ + 'id' => $channel->id, + ]); + } + + /** @test */ + public function it_deletes_messages_for_a_week(): void + { + $workspace = SlackWorkspace::factory()->create(); + $channel = SlackChannel::factory()->create(['slack_workspace_id' => $workspace->id]); + + $weekToDelete = Carbon::now(); + $otherWeek = Carbon::now()->subWeek(); + + SlackMessage::create([ + 'week' => $weekToDelete->toDateTimeString(), + 'message' => 'Message 1', + 'message_timestamp' => '1', + 'channel_id' => $channel->id, + 'sequence_position' => 1, + ]); + SlackMessage::create([ + 'week' => $weekToDelete->toDateTimeString(), + 'message' => 'Message 2', + 'message_timestamp' => '2', + 'channel_id' => $channel->id, + 'sequence_position' => 2, + ]); + SlackMessage::create([ + 'week' => Carbon::now()->subWeek()->toDateTimeString(), // Different week + 'message' => 'Message 3', + 'message_timestamp' => '3', + 'channel_id' => $channel->id, + 'sequence_position' => 1, + ]); + + $deletedRows = $this->databaseService->deleteMessagesForWeek($weekToDelete); + + $this->assertEquals(2, $deletedRows); + $this->assertDatabaseMissing('slack_messages', [ + 'message' => 'Message 1', + ]); + $this->assertDatabaseMissing('slack_messages', [ + 'message' => 'Message 2', + ]); + $this->assertDatabaseHas('slack_messages', [ + 'message' => 'Message 3', + ]); + } + + /** @test */ + public function it_deletes_a_specific_message(): void + { + $workspace = SlackWorkspace::factory()->create(); + $channel = SlackChannel::factory()->create(['slack_workspace_id' => $workspace->id]); + + $messageToDelete = SlackMessage::create([ + 'week' => Carbon::now()->toDateTimeString(), + 'message' => 'Message to delete', + 'message_timestamp' => '123', + 'channel_id' => $channel->id, + 'sequence_position' => 1, + ]); + SlackMessage::create([ + 'week' => Carbon::now()->toDateTimeString(), + 'message' => 'Another message', + 'message_timestamp' => '456', + 'channel_id' => $channel->id, + 'sequence_position' => 2, + ]); + + $deletedRows = $this->databaseService->deleteMessage($channel->slack_channel_id, $messageToDelete->message_timestamp); + + $this->assertEquals(1, $deletedRows); + $this->assertDatabaseMissing('slack_messages', [ + 'id' => $messageToDelete->id, + ]); + $this->assertDatabaseHas('slack_messages', [ + 'message_timestamp' => '456', + ]); + } + + /** @test */ + public function it_handles_deleting_message_in_non_existent_channel(): void + { + Log::shouldReceive('warning') + ->once() + ->with('Attempted to delete message in non-existent channel: non-existent-channel'); + + $deletedRows = $this->databaseService->deleteMessage('non-existent-channel', '123'); + + $this->assertEquals(0, $deletedRows); + } + + /** @test */ + public function it_deletes_old_messages_and_cooldowns(): void + { + $workspace = SlackWorkspace::factory()->create(); + $channel = SlackChannel::factory()->create(['slack_workspace_id' => $workspace->id]); + + // Create messages + SlackMessage::create([ + 'week' => Carbon::now()->subDays(100)->toDateTimeString(), + 'message' => 'Old message 1', + 'message_timestamp' => '1', + 'channel_id' => $channel->id, + 'sequence_position' => 1, + ]); + SlackMessage::create([ + 'week' => Carbon::now()->subDays(50)->toDateTimeString(), + 'message' => 'Recent message 1', + 'message_timestamp' => '2', + 'channel_id' => $channel->id, + 'sequence_position' => 2, + ]); + + // Create cooldowns + SlackCooldown::create([ + 'accessor' => 'user1', + 'resource' => 'resource1', + 'expires_at' => Carbon::now()->subDays(100), + ]); + SlackCooldown::create([ + 'accessor' => 'user2', + 'resource' => 'resource2', + 'expires_at' => Carbon::now()->addDays(10), + ]); + + $this->databaseService->deleteOldMessages(90); + + $this->assertDatabaseMissing('slack_messages', [ + 'message' => 'Old message 1', + ]); + $this->assertDatabaseHas('slack_messages', [ + 'message' => 'Recent message 1', + ]); + $this->assertDatabaseMissing('slack_cooldowns', [ + 'accessor' => 'user1', + ]); + $this->assertDatabaseHas('slack_cooldowns', [ + 'accessor' => 'user2', + ]); + } + + /** @test */ + public function it_creates_or_updates_a_cooldown(): void + { + $accessor = 'user1'; + $resource = 'resource1'; + $cooldownMinutes = 5; + + // Create new cooldown + $cooldown = $this->databaseService->createCooldown($accessor, $resource, $cooldownMinutes); + + $this->assertInstanceOf(SlackCooldown::class, $cooldown); + $this->assertDatabaseHas('slack_cooldowns', [ + 'accessor' => $accessor, + 'resource' => $resource, + ]); + + // Update existing cooldown + $newCooldownMinutes = 10; + $updatedCooldown = $this->databaseService->createCooldown($accessor, $resource, $newCooldownMinutes); + + $this->assertInstanceOf(SlackCooldown::class, $updatedCooldown); + $this->assertEquals($cooldown->id, $updatedCooldown->id); + $this->assertDatabaseHas('slack_cooldowns', [ + 'accessor' => $accessor, + 'resource' => $resource, + 'expires_at' => $updatedCooldown->expires_at, + ]); + } + + /** @test */ + public function it_gets_cooldown_expiry_time(): void + { + $accessor = 'user1'; + $resource = 'resource1'; + $expiresAt = Carbon::now()->addMinutes(10); + + SlackCooldown::create([ + 'accessor' => $accessor, + 'resource' => $resource, + 'expires_at' => $expiresAt, + ]); + + $expiryTime = $this->databaseService->getCooldownExpiryTime($accessor, $resource); + + $this->assertInstanceOf(Carbon::class, $expiryTime); + $this->assertEquals($expiresAt->toDateTimeString(), $expiryTime->toDateTimeString()); + } + + /** @test */ + public function it_returns_null_for_non_existent_cooldown_expiry_time(): void + { + $expiryTime = $this->databaseService->getCooldownExpiryTime('non-existent', 'resource'); + $this->assertNull($expiryTime); + } + + /** @test */ + public function it_creates_or_updates_a_workspace(): void + { + $data = [ + 'team' => [ + 'id' => 'T123', + 'name' => 'Test Team', + ], + 'access_token' => 'xoxb-test-token', + 'bot_user_id' => 'B123', + ]; + + // Create new workspace + $workspace = $this->databaseService->createOrUpdateWorkspace($data); + + $this->assertInstanceOf(SlackWorkspace::class, $workspace); + $this->assertDatabaseHas('slack_workspaces', [ + 'team_id' => 'T123', + 'team_name' => 'Test Team', + 'bot_user_id' => 'B123', + ]); + // Retrieve the created workspace to check the decrypted token + $createdWorkspace = SlackWorkspace::where('team_id', 'T123')->first(); + $this->assertEquals('xoxb-test-token', $createdWorkspace->access_token); + + + // Update existing workspace + $updatedData = [ + 'team' => [ + 'id' => 'T123', + 'name' => 'Updated Team', + ], + 'access_token' => 'xoxb-updated-token', + 'bot_user_id' => 'B456', + ]; + + $updatedWorkspace = $this->databaseService->createOrUpdateWorkspace($updatedData); + + $this->assertInstanceOf(SlackWorkspace::class, $updatedWorkspace); + $this->assertEquals($workspace->id, $updatedWorkspace->id); + $this->assertDatabaseHas('slack_workspaces', [ + 'team_id' => 'T123', + 'team_name' => 'Updated Team', + 'bot_user_id' => 'B456', + ]); + // Retrieve the updated workspace to check the decrypted token + $retrievedUpdatedWorkspace = SlackWorkspace::where('team_id', 'T123')->first(); + $this->assertEquals('xoxb-updated-token', $retrievedUpdatedWorkspace->access_token); + } +} diff --git a/app-modules/slack-events-bot/tests/Services/EventServiceTest.php b/app-modules/slack-events-bot/tests/Services/EventServiceTest.php new file mode 100644 index 00000000..d3bd08e3 --- /dev/null +++ b/app-modules/slack-events-bot/tests/Services/EventServiceTest.php @@ -0,0 +1,310 @@ +eventService = new EventService; + } + + /** @test */ + public function it_generates_blocks_for_an_event(): void + { + // Create related models for the Event + $organization = Org::factory()->create(['title' => 'Test Organization']); + $state = \App\Models\State::factory()->create(['name' => 'South Carolina', 'abbr' => 'SC']); // Create a State model + $venue = Venue::factory()->create([ + 'name' => 'Test Venue', + 'address' => '123 Main St', + 'city' => 'Greenville', + 'state_id' => $state->id, // Use state_id + 'zipcode' => '29601' // Use zipcode instead of zip + ]); + + $event = Event::factory()->create([ + 'event_name' => 'My Awesome Event', + 'description' => 'This is a very interesting description for my awesome event.', + 'organization_id' => $organization->id, + 'venue_id' => $venue->id, + 'uri' => 'https://example.com/event', + 'active_at' => now()->addDays(7), + ]); + + $blocks = $this->eventService->generateBlocks($event); + + $this->assertIsArray($blocks); + $this->assertCount(2, $blocks); // Header and Section + + // Assert Header Block + $this->assertEquals('header', $blocks[0]['type']); + $this->assertEquals('plain_text', $blocks[0]['text']['type']); + $this->assertEquals('My Awesome Event', $blocks[0]['text']['text']); + + // Assert Section Block + $this->assertEquals('section', $blocks[1]['type']); + $this->assertEquals('plain_text', $blocks[1]['text']['type']); + $this->assertEquals('This is a very interesting description for my awesome event.', $blocks[1]['text']['text']); + $this->assertIsArray($blocks[1]['fields']); + $this->assertCount(8, $blocks[1]['fields']); // 4 pairs of mrkdwn/plain_text + + // Assert fields content + $this->assertEquals('*Test Organization*', $blocks[1]['fields'][0]['text']); // Organization + $this->assertEquals('', $blocks[1]['fields'][1]['text']); // Link + $this->assertEquals('*Status*', $blocks[1]['fields'][2]['text']); // Status Label + $this->assertEquals('Upcoming ✅', $blocks[1]['fields'][3]['text']); // Status Value + $this->assertEquals('*Location*', $blocks[1]['fields'][4]['text']); // Location Label + $this->assertEquals('Test Venue - 123 Main St Greenville, SC 29601', $blocks[1]['fields'][5]['text']); // Location Value + $this->assertEquals('*Time*', $blocks[1]['fields'][6]['text']); // Time Label + $this->assertEquals($event->active_at->format('F j, Y g:i A T'), $blocks[1]['fields'][7]['text']); // Time Value + } + + /** @test */ + public function it_generates_blocks_with_limited_text(): void + { + $longEventName = str_repeat('a', 200); + $longDescription = str_repeat('b', 300); + + $organization = Org::factory()->create(['title' => 'Test Organization']); + $state = \App\Models\State::factory()->create(['name' => 'South Carolina', 'abbr' => 'SC']); + $venue = Venue::factory()->create([ + 'name' => 'Test Venue', + 'address' => '123 Main St', + 'city' => 'Greenville', + 'state_id' => $state->id, + 'zipcode' => '29601', + ]); + + $event = Event::factory()->create([ + 'event_name' => $longEventName, + 'description' => $longDescription, + 'organization_id' => $organization->id, + 'venue_id' => $venue->id, + 'uri' => 'https://example.com/event', + 'active_at' => now()->addDays(7), + ]); + + $blocks = $this->eventService->generateBlocks($event); + + $this->assertEquals(153, mb_strlen($blocks[0]['text']['text'])); + $this->assertStringEndsWith('...', $blocks[0]['text']['text']); + $this->assertEquals(253, mb_strlen($blocks[1]['text']['text'])); + $this->assertStringEndsWith('...', $blocks[1]['text']['text']); + } + + /** @test */ + public function it_generates_blocks_with_no_venue(): void + { + $organization = Org::factory()->create(['title' => 'Test Organization']); + + $event = Event::factory()->create([ + 'event_name' => 'Event Without Venue', + 'description' => 'Description for event without venue.', + 'organization_id' => $organization->id, + 'venue_id' => null, + 'uri' => 'https://example.com/event', + 'active_at' => now()->addDays(7), + ]); + + $blocks = $this->eventService->generateBlocks($event); + + $this->assertEquals('No location', $blocks[1]['fields'][5]['text']); + } + + /** @test */ + public function it_generates_blocks_with_empty_event_name(): void + { + $organization = Org::factory()->create(['title' => 'Test Organization']); + + $event = Event::factory()->create([ + 'event_name' => '', + 'description' => 'Description for event with empty name.', + 'organization_id' => $organization->id, + 'uri' => 'https://example.com/event', + 'active_at' => now()->addDays(7), + ]); + + $blocks = $this->eventService->generateBlocks($event); + + $this->assertEquals('Untitled Event', $blocks[0]['text']['text']); + } + + /** @test */ + public function it_generates_text_for_an_event(): void + { + $organization = Org::factory()->create(['title' => 'Test Organization']); + $state = \App\Models\State::factory()->create(['name' => 'South Carolina', 'abbr' => 'SC']); + $venue = Venue::factory()->create([ + 'name' => 'Test Venue', + 'address' => '123 Main St', + 'city' => 'Greenville', + 'state_id' => $state->id, + 'zipcode' => '29601', + ]); + + $event = Event::factory()->create([ + 'event_name' => 'My Awesome Event', + 'description' => 'This is a very interesting description for my awesome event.', + 'organization_id' => $organization->id, + 'venue_id' => $venue->id, + 'uri' => 'https://example.com/event', + 'active_at' => now()->addDays(7), + ]); + + $expectedText = sprintf( + "%s\nOrganization: %s\nDescription: %s\nLink: %s\nStatus: %s\nLocation: %s\nTime: %s", + 'My Awesome Event', + 'Test Organization', + 'This is a very interesting description for my awesome event.', + 'https://example.com/event', + 'Upcoming ✅', + 'Test Venue', + $event->active_at->format('F j, Y g:i A T') + ); + + $generatedText = $this->eventService->generateText($event); + + $this->assertEquals($expectedText, $generatedText); + } + + /** @test */ + public function it_generates_text_with_limited_text(): void + { + $longEventName = str_repeat('x', 300); + $longOrgTitle = str_repeat('y', 255); + $longDescription = str_repeat('z', 300); + + $organization = Org::factory()->create(['title' => $longOrgTitle]); + + $event = Event::factory()->create([ + 'event_name' => $longEventName, + 'description' => $longDescription, + 'organization_id' => $organization->id, + 'venue_id' => null, + 'uri' => 'https://example.com/event', + 'active_at' => now()->addDays(7), + ]); + + $generatedText = $this->eventService->generateText($event); + + $expectedEventName = mb_substr($longEventName, 0, 250) . '...'; + $expectedOrgTitle = mb_substr($longOrgTitle, 0, 250) . '...'; + $expectedDescription = mb_substr($longDescription, 0, 250) . '...'; + + $expectedText = sprintf( + "%s\nOrganization: %s\nDescription: %s\nLink: %s\nStatus: %s\nLocation: %s\nTime: %s", + $expectedEventName, + $expectedOrgTitle, + $expectedDescription, + 'https://example.com/event', + 'Upcoming ✅', + 'No location', + $event->active_at->format('F j, Y g:i A T') + ); + + $this->assertEquals($expectedText, $generatedText); + } + + /** @test */ + public function it_generates_text_with_no_venue(): void + { + $organization = Org::factory()->create(['title' => 'Test Organization']); + + $event = Event::factory()->create([ + 'event_name' => 'Event Without Venue', + 'description' => 'Description for event without venue.', + 'organization_id' => $organization->id, + 'venue_id' => null, + 'uri' => 'https://example.com/event', + 'active_at' => now()->addDays(7), + ]); + + $expectedText = sprintf( + "%s\nOrganization: %s\nDescription: %s\nLink: %s\nStatus: %s\nLocation: %s\nTime: %s", + 'Event Without Venue', + 'Test Organization', + 'Description for event without venue.', + 'https://example.com/event', + 'Upcoming ✅', + 'No location', + $event->active_at->format('F j, Y g:i A T') + ); + + $generatedText = $this->eventService->generateText($event); + + $this->assertEquals($expectedText, $generatedText); + } + + /** @test */ + public function it_generates_text_for_past_event(): void + { + $organization = Org::factory()->create(['title' => 'Test Organization']); + $event = Event::factory()->create([ + 'event_name' => 'Past Event', + 'description' => 'Description for past event.', + 'organization_id' => $organization->id, + 'uri' => 'https://example.com/past-event', + 'active_at' => now()->subDays(7), + 'venue_id' => null, + ]); + + $expectedText = sprintf( + "%s\nOrganization: %s\nDescription: %s\nLink: %s\nStatus: %s\nLocation: %s\nTime: %s", + 'Past Event', + 'Test Organization', + 'Description for past event.', + 'https://example.com/past-event', + 'Past ✔', + 'No location', + $event->active_at->format('F j, Y g:i A T') + ); + + $generatedText = $this->eventService->generateText($event); + + $this->assertEquals($expectedText, $generatedText); + } + + /** @test */ + public function it_generates_text_for_cancelled_event(): void + { + $organization = Org::factory()->create(['title' => 'Test Organization']); + $event = Event::factory()->create([ + 'event_name' => 'Cancelled Event', + 'description' => 'Description for cancelled event.', + 'organization_id' => $organization->id, + 'uri' => 'https://example.com/cancelled-event', + 'active_at' => now()->addDays(7), + 'cancelled_at' => now(), + 'venue_id' => null, + ]); + + $expectedText = sprintf( + "%s\nOrganization: %s\nDescription: %s\nLink: %s\nStatus: %s\nLocation: %s\nTime: %s", + 'Cancelled Event', + 'Test Organization', + 'Description for cancelled event.', + 'https://example.com/cancelled-event', + 'Cancelled ❌', + 'No location', + $event->active_at->format('F j, Y g:i A T') + ); + + $generatedText = $this->eventService->generateText($event); + + $this->assertEquals($expectedText, $generatedText); + } +} diff --git a/app-modules/slack-events-bot/tests/Services/MessageBuilderServiceTest.php b/app-modules/slack-events-bot/tests/Services/MessageBuilderServiceTest.php new file mode 100644 index 00000000..9ed5892b --- /dev/null +++ b/app-modules/slack-events-bot/tests/Services/MessageBuilderServiceTest.php @@ -0,0 +1,124 @@ + 'Event 1', 'description' => 'Description 1']); + $event2 = new Event(['name' => 'Event 2', 'description' => 'Description 2']); + $events = Collection::make([$event1, $event2]); + + $eventServiceMock->shouldReceive('generateText') + ->with($event1) + ->andReturn('Event 1 Text'); + $eventServiceMock->shouldReceive('generateBlocks') + ->with($event1) + ->andReturn([['type' => 'section', 'text' => ['type' => 'mrkdwn', 'text' => 'Event 1 Block']]]); + + $eventServiceMock->shouldReceive('generateText') + ->with($event2) + ->andReturn('Event 2 Text'); + $eventServiceMock->shouldReceive('generateBlocks') + ->with($event2) + ->andReturn([['type' => 'section', 'text' => ['type' => 'mrkdwn', 'text' => 'Event 2 Block']]]); + + $result = $messageBuilderService->buildEventBlocks($events); + + $this->assertCount(2, $result); + $this->assertEquals('Event 1 Text' . "\n\n", $result[0]['text']); + $this->assertStringContainsString('Event 1 Block', json_encode($result[0]['blocks'])); + $this->assertEquals('Event 2 Text' . "\n\n", $result[1]['text']); + $this->assertStringContainsString('Event 2 Block', json_encode($result[1]['blocks'])); + } + + /** @test */ + public function it_chunks_messages_correctly() + { + $eventServiceMock = Mockery::mock(EventService::class); + $messageBuilderService = new MessageBuilderService($eventServiceMock); + + // Set a small max character length for testing chunking + config(['slack-events-bot.max_message_character_length' => 100]); + config(['slack-events-bot.header_buffer_length' => 50]); // A reasonable buffer for header + + $weekStart = Carbon::parse('2025-07-07'); // Monday + + $eventBlocks = Collection::make([ + ['blocks' => [['type' => 'section', 'text' => ['text' => 'Event 1']]], 'text' => 'Event 1 Text' . "\n\n", 'text_length' => mb_strlen('Event 1 Text' . "\n\n")], + ['blocks' => [['type' => 'section', 'text' => ['text' => 'Event 2']]], 'text' => 'Event 2 Text' . "\n\n", 'text_length' => mb_strlen('Event 2 Text' . "\n\n")], + ['blocks' => [['type' => 'section', 'text' => ['text' => 'Event 3']]], 'text' => 'Event 3 Text' . "\n\n", 'text_length' => mb_strlen('Event 3 Text' . "\n\n")], + ['blocks' => [['type' => 'section', 'text' => ['text' => 'Event 4']]], 'text' => 'Event 4 Text' . "\n\n", 'text_length' => mb_strlen('Event 4 Text' . "\n\n")], + ]); + + $messages = $messageBuilderService->chunkMessages($eventBlocks, $weekStart); + + // Based on the small max_message_character_length, we expect multiple messages. + // The exact number depends on the header length and event text lengths. + // Let's assume each event text is around 15 chars, and header is around 50 chars. + // Max content length per message = 100 - 50 = 50 chars. + // Each event text is 15 chars. So, 3 events per message (3 * 15 = 45). + // With 4 events, we should get 2 messages. + $this->assertGreaterThanOrEqual(2, count($messages)); + + // Verify the first message + $this->assertStringContainsString('HackGreenville Events for the week of July 7 - 1 of', $messages[0]['text']); + $this->assertStringContainsString('Event 1 Text', $messages[0]['text']); + $this->assertStringContainsString('Event 2 Text', $messages[0]['text']); + + // Verify the second message + $this->assertStringContainsString('HackGreenville Events for the week of July 7 - 2 of', $messages[1]['text']); + $this->assertStringContainsString('Event 3 Text', $messages[1]['text']); + $this->assertStringContainsString('Event 4 Text', $messages[1]['text']); + } + + /** @test */ + public function it_filters_out_null_event_blocks() + { + $eventServiceMock = Mockery::mock(EventService::class); + $messageBuilderService = new MessageBuilderService($eventServiceMock); + + $event1 = new Event(['name' => 'Event 1']); + $event2 = new Event(['name' => 'Event 2']); + $events = Collection::make([$event1, $event2]); + + $eventServiceMock->shouldReceive('generateText') + ->with($event1) + ->andReturn('Event 1 Text'); + $eventServiceMock->shouldReceive('generateBlocks') + ->with($event1) + ->andReturn([['type' => 'section', 'text' => ['type' => 'mrkdwn', 'text' => 'Event 1 Block']]]); + + // Simulate an event that should be skipped (e.g., generateBlocks returns null or empty) + $eventServiceMock->shouldReceive('generateText') + ->with($event2) + ->andReturn(''); // Empty text will cause buildSingleEventBlock to return null + $eventServiceMock->shouldReceive('generateBlocks') + ->with($event2) + ->andReturn([]); // Empty blocks will cause buildSingleEventBlock to return null + + $result = $messageBuilderService->buildEventBlocks($events); + + $this->assertCount(1, $result); + $this->assertEquals('Event 1 Text' . "\n\n", $result[0]['text']); + } +} diff --git a/app-modules/slack-events-bot/tests/SlackEventsBotTest.php b/app-modules/slack-events-bot/tests/SlackEventsBotTest.php deleted file mode 100644 index 45eaf510..00000000 --- a/app-modules/slack-events-bot/tests/SlackEventsBotTest.php +++ /dev/null @@ -1,184 +0,0 @@ -databaseService = app(DatabaseService::class); - $this->eventService = app(EventService::class); - $this->messageBuilderService = app(MessageBuilderService::class); - } - - public function test_can_add_and_remove_channel() - { - $channelId = 'C1234567890'; - $this->databaseService->addChannel($channelId); - $this->assertDatabaseHas('slack_channels', ['slack_channel_id' => $channelId]); - - $channels = $this->databaseService->getSlackChannelIds(); - $this->assertTrue($channels->contains($channelId)); - - $this->databaseService->removeChannel($channelId); - $this->assertDatabaseMissing('slack_channels', ['slack_channel_id' => $channelId]); - } - - public function test_can_create_and_get_messages() - { - $channelId = 'C1234567890'; - $channel = $this->databaseService->addChannel($channelId); - $this->assertDatabaseHas('slack_channels', ['slack_channel_id' => $channelId]); - - $week = Carbon::now()->startOfWeek(); - $message = 'Test message content'; - $timestamp = '1234567890.123456'; - $sequencePosition = 0; - - $this->databaseService->createMessage($week, $message, $timestamp, $channelId, $sequencePosition); - - $this->assertDatabaseHas('slack_messages', [ - 'message' => $message, - 'message_timestamp' => $timestamp, - 'week' => $week->toDateTimeString(), - 'sequence_position' => $sequencePosition, - 'channel_id' => $channel->id, - ]); - - $messages = $this->databaseService->getMessages($week); - $this->assertCount(1, $messages); - $this->assertEquals($message, $messages->first()['message']); - $this->assertEquals($channelId, $messages->first()['slack_channel_id']); - } - - public function test_event_parsing() - { - $state = State::factory()->create(['abbr' => 'SC']); - - $organization = Org::factory()->create(['title' => 'Test Group']); - $venue = Venue::factory()->create([ - 'name' => 'Test Venue', - 'address' => '123 Main St', - 'city' => 'Greenville', - 'state_id' => $state->id, - 'zipcode' => '29601', - ]); - $event = Event::factory()->create([ - 'event_name' => 'Test Event', - 'description' => 'This is a test event description', - 'uri' => 'https://example.com/event', - 'event_uuid' => 'test-uuid-123', - 'active_at' => Carbon::now()->addDays(7), - 'organization_id' => $organization->id, - 'venue_id' => $venue->id, - ]); - - $event->load('organization', 'venue'); - - $blocks = $this->eventService->generateBlocks($event); - $this->assertIsArray($blocks); - $this->assertEquals('header', $blocks[0]['type']); - $this->assertEquals('section', $blocks[1]['type']); - $this->assertEquals('Test Event', $blocks[0]['text']['text']); - $this->assertArrayHasKey('type', $blocks[1]['text']); - $this->assertArrayHasKey('text', $blocks[1]['text']); - $this->assertStringContainsString('This is a test event description', $blocks[1]['text']['text']); - - $text = $this->eventService->generateText($event); - $this->assertStringContainsString('Test Event', $text); - $this->assertStringContainsString('Test Group', $text); - $this->assertStringContainsString('Test Venue', $text); - $this->assertStringContainsString('https://example.com/event', $text); - $this->assertStringContainsString('Upcoming ✅', $text); - } - - public function test_cooldown_functionality() - { - $accessor = 'test-workspace'; - $resource = 'check_api'; - - // No cooldown initially - $expiry = $this->databaseService->getCooldownExpiryTime($accessor, $resource); - $this->assertNull($expiry); - - // Create cooldown - $this->databaseService->createCooldown($accessor, $resource, 15); - - // Check cooldown exists and is in future - $expiry = $this->databaseService->getCooldownExpiryTime($accessor, $resource); - $this->assertNotNull($expiry); - $this->assertTrue($expiry->isFuture()); - } - - public function test_message_chunking() - { - $weekStart = Carbon::now()->startOfWeek(); - $state = State::factory()->create(['abbr' => 'SC']); - - $eventsData = []; - for ($i = 0; $i < 10; $i++) { - $organization = Org::factory()->create(['title' => "Group {$i}"]); - $venue = Venue::factory()->create([ - 'name' => "Venue {$i}", - 'address' => "{$i} Main St", - 'city' => 'Greenville', - 'state_id' => $state->id, - 'zipcode' => '29601', - ]); - - $event = Event::factory()->create([ - 'event_name' => "Event {$i} with a very long title that takes up space", - 'description' => str_repeat("This is a long description. ", 10), - 'uri' => "https://example.com/event-{$i}", - 'active_at' => $weekStart->copy()->addDays($i % 7), - 'organization_id' => $organization->id, - 'venue_id' => $venue->id, - 'event_uuid' => 'test-uuid-123-' . $i, - ]); - - $event->load('organization', 'venue'); - - $eventsData[] = $event; - } - - $eventsCollection = collect($eventsData); - - $eventBlocks = $this->messageBuilderService->buildEventBlocks($eventsCollection); - $this->assertInstanceOf(Collection::class, $eventBlocks); - $this->assertGreaterThan(0, $eventBlocks->count()); - - $chunkedMessages = $this->messageBuilderService->chunkMessages($eventBlocks, $weekStart); - $this->assertIsArray($chunkedMessages); - $this->assertGreaterThan(0, count($chunkedMessages)); - - // Verify each message has required structure - foreach ($chunkedMessages as $message) { - $this->assertArrayHasKey('blocks', $message); - $this->assertArrayHasKey('text', $message); - $this->assertLessThan( - config('slack-events-bot.max_message_character_length'), - mb_strlen($message['text']) - ); - } - } -} diff --git a/docker-compose.local.yml b/docker-compose.local.yml index cb639f7b..cb57108d 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -28,3 +28,7 @@ services: extends: file: docker-compose.yml service: hackgreenville-redis + hackgreenville-queue: + extends: + file: docker-compose.yml + service: hackgreenville-queue diff --git a/docker-compose.yml b/docker-compose.yml index bd6baf43..72e6c913 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,3 +47,21 @@ services: - './redis:/data' healthcheck: test: ["CMD", "redis-cli", "ping"] + + hackgreenville-queue: + build: + context: './vendor/laravel/sail/runtimes/${PHP_RUNTIME:-8.3}' + dockerfile: Dockerfile + args: + WWWGROUP: '${WWWGROUP:-www-data}' + image: hackgreenville + container_name: hackgreenville-queue + env_file: + - .env + volumes: + - './:/var/www/html' + command: 'php artisan queue:work' + restart: always + depends_on: + - hackgreenville-db + - hackgreenville-redis From 6aebdeb3af2ca2e0567bf0ca049109f776b4440f Mon Sep 17 00:00:00 2001 From: oliviasculley Date: Thu, 10 Jul 2025 01:03:31 +0000 Subject: [PATCH 15/19] run composer update again --- composer.lock | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/composer.lock b/composer.lock index d0aac59e..53e7499d 100644 --- a/composer.lock +++ b/composer.lock @@ -3203,7 +3203,7 @@ "dist": { "type": "path", "url": "app-modules/slack-events-bot", - "reference": "533e449e7f78748d5fdebf266814961c02184ac1" + "reference": "91a773ff075dc9917f87512e7ca55d7929ca7800" }, "type": "library", "extra": { @@ -3215,7 +3215,8 @@ }, "autoload": { "psr-4": { - "HackGreenville\\SlackEventsBot\\": "src/" + "HackGreenville\\SlackEventsBot\\": "src/", + "HackGreenville\\SlackEventsBot\\Database\\Factories\\": "./database/factories/" } }, "autoload-dev": { From da0908de8f912fa58a03ecdc8ae5129439fabe63 Mon Sep 17 00:00:00 2001 From: oliviasculley Date: Sun, 12 Oct 2025 20:21:20 +0000 Subject: [PATCH 16/19] move docs to docs folder --- EVENTS_API.md => docs/EVENTS_API.md | 0 ORGS_API.md => docs/ORGS_API.md | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename EVENTS_API.md => docs/EVENTS_API.md (100%) rename ORGS_API.md => docs/ORGS_API.md (100%) diff --git a/EVENTS_API.md b/docs/EVENTS_API.md similarity index 100% rename from EVENTS_API.md rename to docs/EVENTS_API.md diff --git a/ORGS_API.md b/docs/ORGS_API.md similarity index 100% rename from ORGS_API.md rename to docs/ORGS_API.md From 7de2c92a28f594b36cafb6b8ecde96495d33adce Mon Sep 17 00:00:00 2001 From: oliviasculley Date: Sun, 12 Oct 2025 20:35:26 +0000 Subject: [PATCH 17/19] add more env vars and better docs --- .env.ci | 7 +- .env.docker | 5 ++ .env.example | 7 +- .env.testing | 7 +- .gitignore | 3 +- app-modules/slack-events-bot/README.md | 2 +- .../slack-events-bot/slackbot-manifest.json | 2 +- docs/SLACK_BOT_API.md | 76 +++++++++++++++++++ 8 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 docs/SLACK_BOT_API.md diff --git a/.env.ci b/.env.ci index cf2b0fa4..e5008277 100644 --- a/.env.ci +++ b/.env.ci @@ -47,4 +47,9 @@ HCAPTCHA_SITEKEY= HCAPTCHA_SECRET= SLACK_CONTACT_WEBHOOK= -EVENT_DEBUG_LOGGING_ENABLED=1 \ No newline at end of file +SLACK_BOT_TOKEN= +SLACK_SIGNING_SECRET= +SLACK_CLIENT_ID= +SLACK_CLIENT_SECRET= + +EVENT_DEBUG_LOGGING_ENABLED=1 diff --git a/.env.docker b/.env.docker index c36b7c7c..6feb83b1 100644 --- a/.env.docker +++ b/.env.docker @@ -61,6 +61,11 @@ HCAPTCHA_SITEKEY= HCAPTCHA_SECRET= SLACK_CONTACT_WEBHOOK= +SLACK_BOT_TOKEN= +SLACK_SIGNING_SECRET= +SLACK_CLIENT_ID= +SLACK_CLIENT_SECRET= + # The private Eventbrite API key / token for importing events EVENTBRITE_PRIVATE_TOKEN= diff --git a/.env.example b/.env.example index f1320222..a879f8de 100644 --- a/.env.example +++ b/.env.example @@ -42,7 +42,7 @@ REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 -# using Mailtrap (a fake SMTP) will not actually send emails +# using Mailtrap (a fake SMTP) will not actually send emails MAIL_DRIVER=smtp MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 @@ -57,6 +57,11 @@ ORGS_API_DOMAIN='https://data.openupstate.org' GOOGLE_TAG_MANAGER= +SLACK_BOT_TOKEN= +SLACK_SIGNING_SECRET= +SLACK_CLIENT_ID= +SLACK_CLIENT_SECRET= + HCAPTCHA_SITEKEY= HCAPTCHA_SECRET= SLACK_CONTACT_WEBHOOK= diff --git a/.env.testing b/.env.testing index 18cd309e..b19e14e3 100644 --- a/.env.testing +++ b/.env.testing @@ -47,4 +47,9 @@ HCAPTCHA_SITEKEY= HCAPTCHA_SECRET= SLACK_CONTACT_WEBHOOK= -EVENT_DEBUG_LOGGING_ENABLED=1 \ No newline at end of file +SLACK_BOT_TOKEN= +SLACK_SIGNING_SECRET= +SLACK_CLIENT_ID= +SLACK_CLIENT_SECRET= + +EVENT_DEBUG_LOGGING_ENABLED=1 diff --git a/.gitignore b/.gitignore index a6df0c15..6ada9b35 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ npm-debug.log yarn-error.log .env database/database.sqlite* +app-modules/slack-events-bot/slackbot-manifest.dev.json # IDE helper files. These can be auto generated when running composer update /_ide_helper.php @@ -34,4 +35,4 @@ public/build/ *.pem # Scribe cache and temporary storage -.scribe \ No newline at end of file +.scribe diff --git a/app-modules/slack-events-bot/README.md b/app-modules/slack-events-bot/README.md index 7a8d84b6..34af7545 100644 --- a/app-modules/slack-events-bot/README.md +++ b/app-modules/slack-events-bot/README.md @@ -21,7 +21,7 @@ SLACK_CLIENT_SECRET=your-client-secret To obtain the necessary Slack credentials for testing (SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET), you'll need to create a Slack app and configure it. Follow these steps: -1. Run `cp app-modules/slack-events-bot/slackbot-manifest.json app-modules/slack-events-bot/slackbot-manifest.dev.json` to create a copy version of the manifest. Modify the values in `slackbot-manifest.dev.json` to match your public endpoint. If you don't have a public endpoint, you may need to create one with [ngrok](https://ngrok.com/). +1. Run `cp app-modules/slack-events-bot/slackbot-manifest.json app-modules/slack-events-bot/slackbot-manifest.dev.json` to create a copy version of the manifest. More information about Slack bot manifests can be found on their documentation [here](https://docs.slack.dev/app-manifests/). Modify the values in `slackbot-manifest.dev.json` to match your public endpoint. If you don't have a public endpoint, you may need to create one with [ngrok](https://ngrok.com/). 2. Go to [api.slack.com/apps](https://api.slack.com/apps) and click "Create New App". Then, upload your development manifest to get a head start. diff --git a/app-modules/slack-events-bot/slackbot-manifest.json b/app-modules/slack-events-bot/slackbot-manifest.json index 82a7b584..814358a1 100644 --- a/app-modules/slack-events-bot/slackbot-manifest.json +++ b/app-modules/slack-events-bot/slackbot-manifest.json @@ -3,7 +3,7 @@ "name": "HackGreenvilleAPIBot", "description": "Allow Greenville APIs and tools to interact with the HackGreenville Slack.", "background_color": "#006341", - "long_description": "Posts invite requests, contact form, and events from the HackGreenville site and local API / tools to HG Slack channels. See https://github.com/hackgvl and the slack-events-bot repository for more details." + "long_description": "Posts invite requests, contact form, and events from the HackGreenville site and local API / tools to HG Slack channels. See https://github.com/hackgvl for more details." }, "features": { "app_home": { diff --git a/docs/SLACK_BOT_API.md b/docs/SLACK_BOT_API.md new file mode 100644 index 00000000..91540c06 --- /dev/null +++ b/docs/SLACK_BOT_API.md @@ -0,0 +1,76 @@ +# Slack Events Bot Module +A Laravel module that posts HackGreenville events from the database to configured Slack channels. + +## Installation +This module is automatically loaded as it's in the `app-modules` directory. + +## Configuration +More information regarding the configuration of the Slack bot API can be found in the readme located at `app-modules/slack-events-bot/README.md` + +## Running Migrations +The migrations will run automatically with: +```bash +php artisan migrate +``` + +## Available Commands +```bash +# Manually check for events and update Slack messages +php artisan slack:check-events +# Delete old messages (default: 90 days) +php artisan slack:delete-old-messages +php artisan slack:delete-old-messages --days=60 +``` + +## Scheduled Tasks +The module automatically schedules: +- Event check: Every hour +- Old message cleanup: Daily +Make sure your Laravel scheduler is running: +```bash +php artisan schedule:work +``` + +## Slack Commands +The bot supports the following slash commands: +- `/add_channel` - Add the current channel to receive event updates (admin only) +- `/remove_channel` - Remove the current channel from receiving updates (admin only) +- `/check_api` - Manually trigger an event check (rate limited to once per 15 minutes per workspace) + +## Routes +- `GET /slack/install` - Display Slack installation button +- `GET /slack/auth` - OAuth callback for Slack +- `POST /slack/events` - Webhook endpoint for Slack events and commands + +## Features +- Posts weekly event summaries to configured Slack channels +- Automatically updates messages when events change +- Handles message chunking for large event lists +- Rate limiting for manual checks +- Admin-only channel management +- OAuth installation flow +- Automatic cleanup of old messages +- Direct database integration (no API calls needed) + +## How It Works +1. The bot queries the Event model directly every hour for new/updated events +2. Events are filtered to show published events from 1 day ago to 14 days ahead +3. Events are grouped by week (Sunday to Saturday) +4. Messages are posted/updated in configured Slack channels +5. If a week has many events, they're split across multiple messages +6. Messages for the current week and next week (5 days early) are maintained + +## Configuration Options +The module can be configured in `config/slack-events-bot.php`: +- `days_to_look_back` - How many days in the past to include events (default: 1) +- `days_to_look_ahead` - How many days in the future to include events (default: 14) +- `max_message_character_length` - Maximum characters per Slack message (default: 3000) +- `check_api_cooldown_minutes` - Cooldown period for manual checks (default: 15) +- `old_messages_retention_days` - Days to keep old messages (default: 90) + +## Migration from Python +This module is a Laravel port of the original Python slack-events-bot, now refactored to use the Event model directly instead of making API calls. This provides: +- Better performance (no HTTP overhead) +- Real-time data (no API caching delays) +- Tighter integration with the application +- Easier maintenance and debugging From df68940fcb67c11e381cc20efa7eb012b486703d Mon Sep 17 00:00:00 2001 From: oliviasculley Date: Sun, 12 Oct 2025 20:37:29 +0000 Subject: [PATCH 18/19] remove extra spacing --- .../slack-events-bot/tests/Services/BotServiceTest.php | 4 ---- config/scribe.php | 1 - 2 files changed, 5 deletions(-) diff --git a/app-modules/slack-events-bot/tests/Services/BotServiceTest.php b/app-modules/slack-events-bot/tests/Services/BotServiceTest.php index b29d42a4..b5246cf7 100644 --- a/app-modules/slack-events-bot/tests/Services/BotServiceTest.php +++ b/app-modules/slack-events-bot/tests/Services/BotServiceTest.php @@ -48,10 +48,6 @@ public function test_handle_posting_to_slack_no_events() { $this->refreshApplication(); - - - - $this->botService = Mockery::mock(BotService::class . '[getEventsForWeek, deleteMessagesForWeek]', [ $this->databaseServiceMock, $this->messageBuilderServiceMock diff --git a/config/scribe.php b/config/scribe.php index fc7fa5e1..f8df707a 100644 --- a/config/scribe.php +++ b/config/scribe.php @@ -125,7 +125,6 @@ 'extra_info' => 'You can retrieve your token by visiting your dashboard and clicking Generate API token.', ], - // Example requests for each endpoint will be shown in each of these languages. // Supported options are: bash, javascript, php, python // To add a language of your own, see https://scribe.knuckles.wtf/laravel/advanced/example-requests From aa2d97045478432be418f153f987861ad5954bd2 Mon Sep 17 00:00:00 2001 From: oliviasculley <88074048+oliviasculley@users.noreply.github.com> Date: Sun, 12 Oct 2025 20:50:56 +0000 Subject: [PATCH 19/19] Fix styling --- app-modules/slack-events-bot/src/Services/EventService.php | 2 +- .../slack-events-bot/src/Services/MessageBuilderService.php | 2 +- app/Models/Venue.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app-modules/slack-events-bot/src/Services/EventService.php b/app-modules/slack-events-bot/src/Services/EventService.php index a2eecfcb..c630c9a4 100644 --- a/app-modules/slack-events-bot/src/Services/EventService.php +++ b/app-modules/slack-events-bot/src/Services/EventService.php @@ -9,7 +9,7 @@ class EventService { public function generateBlocks(Event $event): array { - $eventName = trim((string) $event->event_name); + $eventName = mb_trim((string) $event->event_name); if (empty($eventName)) { $eventName = 'Untitled Event'; } diff --git a/app-modules/slack-events-bot/src/Services/MessageBuilderService.php b/app-modules/slack-events-bot/src/Services/MessageBuilderService.php index 2e5ab550..43a41e9b 100644 --- a/app-modules/slack-events-bot/src/Services/MessageBuilderService.php +++ b/app-modules/slack-events-bot/src/Services/MessageBuilderService.php @@ -114,7 +114,7 @@ private function buildSingleEventBlock(Event $event): ?array { $text = $this->eventService->generateText($event) . "\n\n"; - if (empty(trim($text))) { // Check if text is empty or just whitespace + if (empty(mb_trim($text))) { // Check if text is empty or just whitespace return null; } diff --git a/app/Models/Venue.php b/app/Models/Venue.php index df77c16a..3e88b45b 100644 --- a/app/Models/Venue.php +++ b/app/Models/Venue.php @@ -77,7 +77,7 @@ public function fullAddress() return collect([ "{$this->name} - {$this->address}", - trim("{$location} {$this->zipcode}"), + mb_trim("{$location} {$this->zipcode}"), ])->filter()->join(' '); }