├── .github └── workflows │ └── continuous-integration.yml ├── .gitignore ├── .php-cs-fixer.php ├── LICENSE ├── README.md ├── composer.json ├── composer.lock ├── phpunit.xml ├── src ├── App.php ├── AppConfig.php ├── AppServer.php ├── Application.php ├── Auth │ ├── AppCredentials.php │ ├── AppCredentialsStore.php │ ├── AuthContext.php │ ├── AuthException.php │ ├── OAuthLink.php │ ├── SingleAppCredentialsStore.php │ ├── SingleTeamTokenStore.php │ └── TokenStore.php ├── BaseApp.php ├── Clients │ ├── ApiClient.php │ ├── OAuthClient.php │ ├── RespondClient.php │ ├── SendsHttpRequests.php │ ├── SimpleApiClient.php │ └── SimpleRespondClient.php ├── Coerce.php ├── Commands │ ├── ArgDefinition.php │ ├── CommandListener.php │ ├── CommandRouter.php │ ├── Definition.php │ ├── DefinitionBuilder.php │ ├── Input.php │ ├── OptDefinition.php │ ├── Parser.php │ ├── ParsingException.php │ └── Token.php ├── Context.php ├── Contexts │ ├── Blocks.php │ ├── ClassContainer.php │ ├── DataBag.php │ ├── Error.php │ ├── HasData.php │ ├── Home.php │ ├── InputState.php │ ├── Modals.php │ ├── Payload.php │ ├── PayloadType.php │ ├── PrivateMetadata.php │ └── View.php ├── Deferral │ ├── DeferredContextCliServer.php │ ├── PreAckDeferrer.php │ └── ShellExecDeferrer.php ├── Deferrer.php ├── Env.php ├── Exception.php ├── Http │ ├── AppHandler.php │ ├── AuthMiddleware.php │ ├── EchoResponseEmitter.php │ ├── HttpException.php │ ├── HttpServer.php │ ├── MultiTenantHttpServer.php │ ├── ResponseEmitter.php │ └── Util.php ├── Interceptor.php ├── Interceptors │ ├── Chain.php │ ├── Filter.php │ ├── Filters │ │ ├── CallbackFilter.php │ │ └── FieldFilter.php │ ├── Lazy.php │ ├── Tap.php │ └── UrlVerification.php ├── Listener.php ├── Listeners │ ├── Ack.php │ ├── Async.php │ ├── Base.php │ ├── Callback.php │ ├── ClassResolver.php │ ├── FieldSwitch.php │ ├── Intercepted.php │ ├── Undefined.php │ └── WIP.php ├── Route.php ├── Router.php ├── SlackLogger.php └── StderrLogger.php └── tests ├── Fakes └── FakeResponseEmitter.php └── Integration ├── Apps ├── AnyApp.php └── any-app.php ├── CommandTest.php ├── DeferredContextCliServerTest.php ├── IntegTestCase.php ├── MultiTenantHttpServerTest.php └── ShortcutTest.php /.github/workflows/continuous-integration.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions Documentation: https://docs.github.com/en/actions 2 | 3 | name: "build" 4 | 5 | on: 6 | push: 7 | branches: 8 | - "main" 9 | tags: 10 | - "*" 11 | pull_request: 12 | branches: 13 | - "main" 14 | 15 | # Cancels all previous workflow runs for the same branch that have not yet completed. 16 | concurrency: 17 | # The concurrency group contains the workflow name and the branch name. 18 | group: ${{ github.workflow }}-${{ github.ref }} 19 | cancel-in-progress: true 20 | 21 | env: 22 | COMPOSER_ROOT_VERSION: "1.99.99" 23 | 24 | jobs: 25 | coding-standards: 26 | name: "Coding standards" 27 | runs-on: "ubuntu-latest" 28 | steps: 29 | - name: "Checkout repository" 30 | uses: "actions/checkout@v2" 31 | 32 | - name: "Install PHP" 33 | uses: "shivammathur/setup-php@v2" 34 | with: 35 | php-version: "7.4" 36 | coverage: none 37 | 38 | - name: "Install dependencies (Composer)" 39 | uses: "ramsey/composer-install@v2" 40 | 41 | - name: "Check coding standards (PHP CS Fixer)" 42 | shell: "bash" 43 | run: "composer style-lint" 44 | 45 | static-analysis: 46 | name: "Static analysis" 47 | runs-on: "ubuntu-latest" 48 | steps: 49 | - name: "Checkout repository" 50 | uses: "actions/checkout@v2" 51 | 52 | - name: "Install PHP" 53 | uses: "shivammathur/setup-php@v2" 54 | with: 55 | php-version: "7.4" 56 | coverage: "none" 57 | 58 | - name: "Install dependencies (Composer)" 59 | uses: "ramsey/composer-install@v2" 60 | 61 | - name: "Statically analyze code (PHPStan)" 62 | shell: "bash" 63 | run: "composer stan" 64 | 65 | unit-tests: 66 | name: "Unit tests" 67 | runs-on: "ubuntu-latest" 68 | steps: 69 | - name: "Checkout repository" 70 | uses: "actions/checkout@v2" 71 | 72 | - name: "Install PHP" 73 | uses: "shivammathur/setup-php@v2" 74 | with: 75 | php-version: 7.4" 76 | ini-values: "memory_limit=-1" 77 | 78 | - name: "Install dependencies (Composer)" 79 | uses: "ramsey/composer-install@v2" 80 | 81 | - name: "Run unit tests (PHPUnit)" 82 | shell: "bash" 83 | run: "composer test" 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /.idea/ 3 | /coverage/ 4 | /.phpunit.result.cache 5 | /.php_cs.cache 6 | /build 7 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | in(__DIR__ . '/src') 5 | ; 6 | 7 | $config = new PhpCsFixer\Config(); 8 | return $config->setRules([ 9 | '@PSR12' => true, 10 | 'array_syntax' => ['syntax' => 'short'], 11 | 'single_import_per_statement' => false, 12 | 'global_namespace_import' => [ 13 | 'import_constants' => true, 14 | 'import_functions' => true, 15 | 'import_classes' => true, 16 | ], 17 | 'no_unused_imports' => true, 18 | 'fully_qualified_strict_types' => true, 19 | 'operator_linebreak' => ['position' => 'beginning'], 20 | ]) 21 | ->setFinder($finder) 22 | ; 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jeremy Lindblom 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Slack App Framework for PHP

3 |

By Jeremy Lindblom (@jeremeamia)

4 |
5 | 6 |

7 | Slack PHP logo written in PHP's font 8 |

9 | 10 |

11 | 12 | Coded in PHP 7.4 13 | 14 | 15 | Packagist Version 16 | 17 | 18 | Build Status 19 | 20 |

21 | 22 | --- 23 | 24 | # Introduction 25 | 26 | A PHP framework for building Slack apps. It takes inspiration from Slack's Bolt frameworks. 27 | 28 | If you are new to Slack app development, you will want to learn about it on 29 | [Slack's website](https://api.slack.com/start). This library is only useful if you already understand the basics of 30 | building Slack applications. 31 | 32 | ## Installation 33 | 34 | - Requires PHP 7.4+ 35 | - Use Composer to install: `composer require slack-php/slack-app-framework` 36 | 37 | ## General Usage 38 | 39 | ### Quick Warning 40 | 41 | This library has been heavily dogfooded, but the project is severely lacking in test coverage and documentation, so 42 | use it at your own risk, as the MIT license advises. 43 | 44 | - Contributions welcome (especially for documentation and tests). 45 | - For questions, feedback, suggestions, etc., use [Discussions][]. 46 | - For issues or concerns, use [Issues][]. 47 | 48 | ### Development Patterns 49 | 50 | When creating an app, you can configure your app from the Slack website. The framework is designed to recieve requests 51 | from all of your app's interaction points, so you should configure all of the URLs (e.g., in **Slash Commands**, 52 | **Interactivity & Shortcuts** (don't forget the _Select Menus_ section), and **Event Subscriptions**) to point to the 53 | root URL of your deployed app code. 54 | 55 | When developing the app code, you declare one or more `Listener`s using the `App`'s routing methods that correspond to 56 | the different types of app interaction. `Listener`s can be declared as closures, or as objects and class names of type 57 | `SlackPhp\Framework\Listener`. A `Listener` receives a `Context` object, which contains the payload data provided by 58 | Slack to the app and provides methods for all the actions you can take to interact with or communicate back to Slack. 59 | 60 | ## Quick Example 61 | 62 | This small app responds to the `/cool` slash command. 63 | 64 | > Assumptions: 65 | > 66 | > - You have required the Composer autoloader to enable autoloading of the framework files. 67 | > - You have set `SLACK_SIGNING_KEY` in the environment (e.g., `putenv("SLACK_SIGNING_KEY=foo");`) 68 | 69 | ```php 70 | command('cool', function (Context $ctx) { 77 | $ctx->ack(':thumbsup: That is so cool!'); 78 | }) 79 | ->run(); 80 | ``` 81 | 82 | ## Example Application 83 | 84 | The "Hello World" app says hello to you, by utilizing every type of app interactions, including: slash commands, block 85 | actions, block suggestions (i.e., options for menus), shortcuts (both global and message level), modals, events, and 86 | the app home page. 87 | 88 |
89 | "Hello World" app code 90 | 91 | > Assumptions: 92 | > 93 | > - You have required the Composer autoloader to enable autoloading of the framework files. 94 | > - You have set `SLACK_SIGNING_KEY` in the environment (e.g., `putenv("SLACK_SIGNING_KEY=foo");`) 95 | > - You have set `SLACK_BOT_TOKEN` in the environment (e.g., `putenv("SLACK_BOT_TOKEN=bar");`) 96 | 97 | ```php 98 | title('Choose a Greeting') 109 | ->submit('Submit') 110 | ->callbackId('hello-form') 111 | ->notifyOnClose(true) 112 | ->tap(function (Modal $modal) { 113 | $modal->newInput('greeting-block') 114 | ->label('Which Greeting?') 115 | ->newSelectMenu('greeting') 116 | ->forExternalOptions() 117 | ->placeholder('Choose a greeting...'); 118 | }); 119 | }; 120 | 121 | App::new() 122 | // Handles the `/hello` slash command. 123 | ->command('hello', function (Context $ctx) { 124 | $ctx->ack(Message::new()->tap(function (Message $msg) { 125 | $msg->newSection() 126 | ->mrkdwnText(':wave: Hello world!') 127 | ->newButtonAccessory('open-form') 128 | ->text('Choose a Greeting'); 129 | })); 130 | }) 131 | // Handles the "open-form" button click. 132 | ->blockAction('open-form', function (Context $ctx) use ($createModal) { 133 | $ctx->modals()->open($createModal()); 134 | }) 135 | // Handles when the "greeting" select menu needs its options. 136 | ->blockSuggestion('greeting', function (Context $ctx) { 137 | $ctx->options(['Hello', 'Howdy', 'Good Morning', 'Hey']); 138 | }) 139 | // Handles when the "hello-form" modal is submitted. 140 | ->viewSubmission('hello-form', function (Context $ctx) { 141 | $state = $ctx->payload()->getState(); 142 | $greeting = $state->get('greeting-block.greeting.selected_option.value'); 143 | $ctx->view()->update(":wave: {$greeting} world!"); 144 | }) 145 | // Handles when the "hello-form" modal is closed without submitting. 146 | ->viewClosed('hello-form', function (Context $ctx) { 147 | $ctx->logger()->notice('User closed hello-form modal early.'); 148 | }) 149 | // Handles when the "hello-global" global shortcut is triggered from the lightning menu. 150 | ->globalShortcut('hello-global', function (Context $ctx) use ($createModal) { 151 | $ctx->modals()->open($createModal()); 152 | }) 153 | // Handles when the "hello-message" message shortcut is triggered from a message context menu. 154 | ->messageShortcut('hello-message', function (Context $ctx) { 155 | $user = $ctx->fmt()->user($ctx->payload()->get('message.user')); 156 | $ctx->say(":wave: Hello {$user}!", null, $ctx->payload()->get('message.ts')); 157 | }) 158 | // Handles when the Hello World app "home" is accessed. 159 | ->event('app_home_opened', function (Context $ctx) { 160 | $user = $ctx->fmt()->user($ctx->payload()->get('event.user')); 161 | $ctx->home(":wave: Hello {$user}!"); 162 | }) 163 | // Handles when any public message contains the word "hello". 164 | ->event('message', Route::filter( 165 | ['event.channel_type' => 'channel', 'event.text' => 'regex:/^.*hello.*$/i'], 166 | function (Context $ctx) { 167 | $user = $ctx->fmt()->user($ctx->payload()->get('event.user')); 168 | $ctx->say(":wave: Hello {$user}!"); 169 | }) 170 | ) 171 | // Run that app to process the incoming Slack request. 172 | ->run(); 173 | ``` 174 | 175 |
176 | 177 | ### Object-Oriented Version 178 | 179 | You can alternatively create your App and Listeners as a set of classes. I recommend this approach if you have more than 180 | a few listeners or if your listeners are complicated. Here is an example of how the "Hello World" app would look when 181 | developed in this way. 182 | 183 |
184 | "Hello World" app code 185 | 186 | `App.php` 187 | ```php 188 | command('hello', Listeners\HelloCommand::class) 202 | ->blockAction('open-form', Listeners\OpenFormButtonClick::class) 203 | ->blockSuggestion('greeting', Listeners\GreetingOptions::class) 204 | ->viewSubmission('hello-form', Listeners\FormSubmission::class) 205 | ->viewClosed('hello-form', Listeners\FormClosed::class) 206 | ->globalShortcut('hello-global', Listeners\HelloGlobalShortcut::class) 207 | ->messageShortcut('hello-message', Listeners\HelloMessageShortcut::class) 208 | ->event('app_home_opened', Listeners\AppHome::class) 209 | ->event('message', Route::filter( 210 | ['event.channel_type' => 'channel', 'event.text' => 'regex:/^.*hello.*$/i'], 211 | Listeners\HelloMessage::class 212 | )); 213 | } 214 | } 215 | ``` 216 | 217 | `index.php` 218 | 219 | > Assumptions: 220 | > 221 | > - You have required the Composer autoloader to enable autoloading of the framework files. 222 | > - You have configured composer.json so that your `MyApp` namespaced code is autoloaded. 223 | > - You have set `SLACK_SIGNING_KEY` in the environment (e.g., `putenv("SLACK_SIGNING_KEY=foo");`) 224 | > - You have set `SLACK_BOT_TOKEN` in the environment (e.g., `putenv("SLACK_BOT_TOKEN=bar");`) 225 | 226 | ```php 227 | run(); 233 | ``` 234 | 235 |
236 | 237 | ## Handling Requests with the `Context` Object 238 | 239 | The `Context` object is the main point of interaction between your app and Slack. Here are all the things you can do 240 | with the `Context`: 241 | 242 | ``` 243 | // To respond (ack) to incoming Slack request: 244 | $ctx->ack(Message|array|string|null) // Responds to request with 200 (and optional message) 245 | $ctx->options(OptionList|array|null) // Responds to request with an options list 246 | $ctx->view(): View 247 | ->clear() // Responds to modal submission by clearing modal stack 248 | ->close() // Responds to modal submission by clearing current modal 249 | ->errors(array) // Responds to modal submission by providing form errors 250 | ->push(Modal|array|string) // Responds to modal submission by pushing new modal to stack 251 | ->update(Modal|array|string) // Responds to modal submission by updating current modal 252 | 253 | // To call Slack APIs (to send messages, open/update modals, etc.) after the ack: 254 | $ctx->respond(Message|array|string) // Responds to message. Uses payload.response_url 255 | $ctx->say(Message|array|string) // Responds in channel. Uses API and payload.channel.id 256 | $ctx->modals(): Modals 257 | ->open(Modal|array|string) // Opens a modal. Uses API and payload.trigger_id 258 | ->push(Modal|array|string) // Pushes a new modal. Uses API and payload.trigger_id 259 | ->update(Modal|array|string) // Updates a modal. Uses API and payload.view.id 260 | $ctx->home(AppHome|array|string) // Modifies App Home for user. Uses API and payload.user.id 261 | $ctx->api(string $api, array $params) // Use Slack API client for arbitrary API operations 262 | 263 | // Access payload or other contextual data: 264 | $ctx->payload(): Payload // Returns the payload of the incoming request from Slack 265 | $ctx->getAppId(): ?string // Gets the app ID, if it's known 266 | $ctx->get(string): mixed // Gets a value from the context 267 | $ctx->set(string, mixed) // Sets a value in the context 268 | $ctx->isAcknowledged(): bool // Returns true if ack has been sent 269 | $ctx->isDeferred(): bool // Returns true if additional processing will happen after the ack 270 | 271 | // Access additional helpers: 272 | $ctx->blocks(): Blocks // Returns a helper for creating Block Kit surfaces 273 | $ctx->fmt(): Formatter // Returns the "mrkdwn" formatting helper for Block Kit text 274 | $ctx->logger(): LoggerInterface // Returns an instance of the configured PSR-3 logger 275 | $ctx->container(): ContainerInterface // Returns an instance of the configured PSR-11 container 276 | ``` 277 | 278 | ## High Level Design 279 | 280 | ![UML diagram of the framework](https://yuml.me/68717414.png) 281 | 282 |
283 | YUML Source 284 |
285 | [AppServer]<>-runs>[App]
286 | [AppServer]creates->[Context]
287 | [App]<>->[AppConfig]
288 | [App]<>->[Router]
289 | [Router]-^[Listener]
290 | [Router]<>1-*>[Listener]
291 | [Listener]handles->[Context]
292 | [Context]<>->[Payload]
293 | [Context]<>->[AppConfig]
294 | [Context]<>->[_Clients_;RespondClient;ApiClient]
295 | [Context]<>->[_Helpers_;BlockKit;Modals;View]
296 | [Context]<>->[_Metadata_]
297 | [AppConfig]<>->[Logger]
298 | [AppConfig]<>->[Container]
299 | [AppConfig]<>->[_Credentials_]
300 | 
301 |
302 | 303 | ## Socket Mode 304 | 305 | [Socket mode][] support is provided by a separate package. See [slack-php/slack-php-socket-mode][]. 306 | 307 | ## Not Implemented 308 | 309 | The following features are known to be missing: 310 | 311 | - OAuth flow for handling installations to a different workspace. 312 | - Though there are some class in the `SlackPhp\Framework\Auth` namespace if you need to roll your own right now. 313 | 314 | ## Standards Used 315 | 316 | - PSR-1, PSR-12: Coding Style 317 | - PSR-3: Logger Interface 318 | - PSR-4: Autoloading 319 | - PSR-7, PSR-15, PSR-17: HTTP 320 | - PSR-11: Container Interface 321 | 322 | [Discussions]: https://github.com/slack-php/slack-php-app-framework/discussions 323 | [Issues]: https://github.com/slack-php/slack-php-app-framework/issues 324 | [Pull Request]: https://github.com/slack-php/slack-php-app-framework/pulls 325 | [Socket Mode]: https://api.slack.com/apis/connections/socket 326 | [slack-php/slack-php-socket-mode]: https://github.com/slack-php/slack-php-socket-mode 327 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slack-php/slack-app-framework", 3 | "type": "library", 4 | "license": "MIT", 5 | "description": "Provides a foundation upon which to build a Slack application in PHP", 6 | "authors": [ 7 | { 8 | "name": "Jeremy Lindblom", 9 | "email": "jeremeamia@gmail.com" 10 | } 11 | ], 12 | "config": { 13 | "sort-packages": true, 14 | "platform": { 15 | "php": "7.4" 16 | } 17 | }, 18 | "require": { 19 | "php": ">=7.4", 20 | "ext-ctype": "*", 21 | "ext-json": "*", 22 | "slack-php/slack-block-kit": "^0.19.0 || ^1.0.0", 23 | "nyholm/psr7": "^1.3", 24 | "nyholm/psr7-server": "^1.0", 25 | "psr/container": "^1.0", 26 | "psr/http-factory": "^1.0", 27 | "psr/http-message": "^1.0", 28 | "psr/http-server-handler": "^1.0", 29 | "psr/http-server-middleware": "^1.0", 30 | "psr/log": "^1.1" 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "SlackPhp\\Framework\\": "src/" 35 | } 36 | }, 37 | "require-dev": { 38 | "friendsofphp/php-cs-fixer": "^2.18", 39 | "phpstan/phpstan": "^0.12.77", 40 | "phpunit/phpunit": "^9.5" 41 | }, 42 | "autoload-dev": { 43 | "psr-4": { 44 | "SlackPhp\\Framework\\Tests\\": "tests/" 45 | } 46 | }, 47 | "scripts": { 48 | "style-lint": "php-cs-fixer fix --dry-run --verbose --show-progress=none", 49 | "style-fix": "php-cs-fixer fix", 50 | "stan": "phpstan analyse --level=5 src tests", 51 | "test": "phpunit --bootstrap=vendor/autoload.php --no-coverage tests", 52 | "test-coverage": "XDEBUG_MODE=coverage phpunit --bootstrap=vendor/autoload.php --coverage-html=build/coverage --whitelist=src tests", 53 | "test-debug": "XDEBUG_MODE=debug phpunit --bootstrap=vendor/autoload.php --no-coverage --debug tests", 54 | "test-all": [ 55 | "@style-lint", 56 | "@stan", 57 | "@test-coverage" 58 | ] 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | src/ 6 | 7 | 8 | src/ 9 | 10 | 11 | 12 | 13 | tests/Integration 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/AppServer.php: -------------------------------------------------------------------------------- 1 | init(); 45 | } 46 | 47 | /** 48 | * @param Application|Listener|callable(Context): void|class-string $app 49 | * @return $this 50 | */ 51 | public function withApp($app): self 52 | { 53 | $this->app = Coerce::application($app); 54 | 55 | // If the Server has no logger, use the application's logger. 56 | if (!isset($this->logger)) { 57 | $this->logger = $this->app->getConfig()->getLogger()->unwrap(); 58 | } 59 | 60 | return $this; 61 | } 62 | 63 | /** 64 | * Gets the application being run by the Server 65 | * 66 | * @return Application 67 | */ 68 | protected function getApp(): Application 69 | { 70 | if (!isset($this->app)) { 71 | $this->withApp(new Application()); 72 | } 73 | 74 | // If a logger for the Server is configured, use it as the app's logger. 75 | if (isset($this->logger)) { 76 | $this->app->getConfig()->getLogger()->withInternalLogger($this->logger); 77 | } 78 | 79 | return $this->app; 80 | } 81 | 82 | /** 83 | * Sets the app credentials store for the Server. 84 | * 85 | * @param AppCredentialsStore $appCredentialsStore 86 | * @return $this 87 | */ 88 | public function withAppCredentialsStore(AppCredentialsStore $appCredentialsStore): self 89 | { 90 | $this->appCredentialsStore = $appCredentialsStore; 91 | 92 | return $this; 93 | } 94 | 95 | /** 96 | * Gets the app credentials to use for authenticating the app being run by the Server. 97 | * 98 | * If app credentials are not provided in the AppConfig, the app credentials store will be used to fetch them. 99 | * 100 | * @return AppCredentials 101 | */ 102 | protected function getAppCredentials(): AppCredentials 103 | { 104 | $config = $this->getApp()->getConfig(); 105 | $credentials = $config->getAppCredentials(); 106 | 107 | if (!$credentials->supportsAnyAuth() && isset($this->appCredentialsStore)) { 108 | $credentials = $this->appCredentialsStore->getAppCredentials($config->getId()); 109 | $config->withAppCredentials($credentials); 110 | } 111 | 112 | return $credentials; 113 | } 114 | 115 | /** 116 | * Sets the logger for the Server. 117 | * 118 | * @param LoggerInterface $logger 119 | * @return $this 120 | */ 121 | public function withLogger(LoggerInterface $logger): self 122 | { 123 | $this->logger = $logger; 124 | 125 | return $this; 126 | } 127 | 128 | /** 129 | * Gets the logger for the Server. 130 | * 131 | * @return LoggerInterface 132 | */ 133 | protected function getLogger(): LoggerInterface 134 | { 135 | $this->logger ??= new NullLogger(); 136 | 137 | return isset($this->app) 138 | ? $this->app->getConfig()->getLogger() 139 | : $this->logger; 140 | } 141 | 142 | /** 143 | * Initializes a server. Called at the time of construction. 144 | * 145 | * Implementations MAY override. 146 | */ 147 | protected function init(): void 148 | { 149 | // Do nothing by default. 150 | } 151 | 152 | /** 153 | * Starts receiving and processing requests from Slack. 154 | */ 155 | abstract public function start(): void; 156 | 157 | /** 158 | * Stops receiving requests from Slack. 159 | * 160 | * Implementations MAY override. 161 | */ 162 | public function stop(): void 163 | { 164 | // Do nothing by default. 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/Application.php: -------------------------------------------------------------------------------- 1 | listener = $listener ?? new Listeners\Ack(); 20 | $this->config = $config ?? new AppConfig(); 21 | } 22 | 23 | public function getConfig(): AppConfig 24 | { 25 | return $this->config; 26 | } 27 | 28 | public function handle(Context $context): void 29 | { 30 | $context->withAppConfig($this->config); 31 | $this->listener->handle($context); 32 | if (!$context->isAcknowledged()) { 33 | $context->ack(); 34 | } 35 | } 36 | 37 | public function run(?AppServer $server = null): void 38 | { 39 | // Default to the basic HTTP server which gets data from superglobals. 40 | $server ??= new Http\HttpServer(); 41 | 42 | // Start the server to run the app. 43 | $server->withApp($this)->start(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Auth/AppCredentials.php: -------------------------------------------------------------------------------- 1 | */ 15 | private array $customSecrets; 16 | 17 | private ?string $appToken; 18 | private ?string $clientId; 19 | private ?string $clientSecret; 20 | private ?string $defaultBotToken; 21 | private ?string $signingKey; 22 | private ?string $stateSecret; 23 | 24 | public static function fromEnv(?string $prefix = null): self 25 | { 26 | $env = Env::vars($prefix); 27 | return new self( 28 | $env->getSigningKey(), 29 | $env->getBotToken(), 30 | $env->getClientId(), 31 | $env->getClientSecret(), 32 | $env->getStateSecret(), 33 | $env->getAppToken() 34 | ); 35 | } 36 | 37 | public static function new(): self 38 | { 39 | return new self(); 40 | } 41 | 42 | /** 43 | * @param string|null $signingKey 44 | * @param string|null $defaultBotToken 45 | * @param string|null $clientId 46 | * @param string|null $clientSecret 47 | * @param string|null $stateSecret 48 | * @param string|null $appToken 49 | * @param array $customSecrets 50 | */ 51 | public function __construct( 52 | ?string $signingKey = null, 53 | ?string $defaultBotToken = null, 54 | ?string $clientId = null, 55 | ?string $clientSecret = null, 56 | ?string $stateSecret = null, 57 | ?string $appToken = null, 58 | array $customSecrets = [] 59 | ) { 60 | $this->signingKey = $signingKey; 61 | $this->defaultBotToken = $defaultBotToken; 62 | $this->clientId = $clientId; 63 | $this->clientSecret = $clientSecret; 64 | $this->stateSecret = $stateSecret; 65 | $this->appToken = $appToken; 66 | $this->customSecrets = $customSecrets; 67 | } 68 | 69 | /** 70 | * @param string $appToken 71 | * @return $this 72 | */ 73 | public function withAppToken(string $appToken): self 74 | { 75 | $this->appToken = $appToken; 76 | 77 | return $this; 78 | } 79 | 80 | /** 81 | * @param string $clientId 82 | * @return $this 83 | */ 84 | public function withClientId(string $clientId): self 85 | { 86 | $this->clientId = $clientId; 87 | 88 | return $this; 89 | } 90 | 91 | /** 92 | * @param string $clientSecret 93 | * @return $this 94 | */ 95 | public function withClientSecret(string $clientSecret): self 96 | { 97 | $this->clientSecret = $clientSecret; 98 | 99 | return $this; 100 | } 101 | 102 | /** 103 | * @param array $customSecrets 104 | * @return $this 105 | */ 106 | public function withCustomSecrets(array $customSecrets): self 107 | { 108 | $this->customSecrets = $customSecrets; 109 | 110 | return $this; 111 | } 112 | 113 | /** 114 | * @param string $defaultBotToken 115 | * @return $this 116 | */ 117 | public function withDefaultBotToken(string $defaultBotToken): self 118 | { 119 | $this->defaultBotToken = $defaultBotToken; 120 | 121 | return $this; 122 | } 123 | 124 | /** 125 | * @param string $signingKey 126 | * @return $this 127 | */ 128 | public function withSigningKey(string $signingKey): self 129 | { 130 | $this->signingKey = $signingKey; 131 | 132 | return $this; 133 | } 134 | 135 | /** 136 | * @param string $stateSecret 137 | * @return $this 138 | */ 139 | public function withStateSecret(string $stateSecret): self 140 | { 141 | $this->stateSecret = $stateSecret; 142 | 143 | return $this; 144 | } 145 | 146 | public function supportsHttpAuth(): bool 147 | { 148 | return isset($this->signingKey); 149 | } 150 | 151 | public function supportsSocketAuth(): bool 152 | { 153 | return isset($this->appToken); 154 | } 155 | 156 | public function supportsApiAuth(): bool 157 | { 158 | return isset($this->defaultBotToken); 159 | } 160 | 161 | public function supportsInstallAuth(): bool 162 | { 163 | return isset($this->clientId, $this->clientSecret); 164 | } 165 | 166 | public function supportsAnyAuth(): bool 167 | { 168 | return $this->supportsHttpAuth() 169 | || $this->supportsApiAuth() 170 | || $this->supportsInstallAuth() 171 | || $this->supportsSocketAuth(); 172 | } 173 | 174 | public function getAppToken(): ?string 175 | { 176 | return $this->appToken; 177 | } 178 | 179 | public function getClientId(): ?string 180 | { 181 | return $this->clientId; 182 | } 183 | 184 | public function getClientSecret(): ?string 185 | { 186 | return $this->clientSecret; 187 | } 188 | 189 | /** 190 | * @return array 191 | */ 192 | public function getCustomSecrets(): array 193 | { 194 | return $this->customSecrets; 195 | } 196 | 197 | public function getDefaultBotToken(): ?string 198 | { 199 | return $this->defaultBotToken; 200 | } 201 | 202 | public function getSigningKey(): ?string 203 | { 204 | return $this->signingKey; 205 | } 206 | 207 | public function getStateSecret(): ?string 208 | { 209 | return $this->stateSecret; 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/Auth/AppCredentialsStore.php: -------------------------------------------------------------------------------- 1 | signature = $signature; 34 | $this->timestamp = $timestamp; 35 | $this->bodyContent = $bodyContent; 36 | $this->maxClockSkew = $maxClockSkew; 37 | } 38 | 39 | public function validate(string $signingKey): void 40 | { 41 | if (abs(time() - $this->timestamp) > $this->maxClockSkew) { 42 | throw new AuthException('Timestamp is too old or too new.'); 43 | } 44 | 45 | if (substr($this->signature, 0, 3) !== self::SIGNATURE_PREFIX) { 46 | throw new AuthException('Missing or unsupported signature version'); 47 | } 48 | 49 | $stringToSign = sprintf('%s:%d:%s', self::SIGNATURE_VERSION, $this->timestamp, $this->bodyContent); 50 | $expectedSignature = self::SIGNATURE_PREFIX . hash_hmac(self::HASHING_ALGO, $stringToSign, $signingKey); 51 | 52 | if (!hash_equals($this->signature, $expectedSignature)) { 53 | throw new AuthException('Signature (v0) failed validation'); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Auth/AuthException.php: -------------------------------------------------------------------------------- 1 | clientId = $clientId; 34 | } 35 | 36 | public function withClientId(string $clientId): self 37 | { 38 | $this->clientId = $clientId; 39 | 40 | return $this; 41 | } 42 | 43 | public function withScopes(array $scopes): self 44 | { 45 | $this->scopes = $scopes; 46 | 47 | return $this; 48 | } 49 | 50 | public function withUserScopes(array $userScopes): self 51 | { 52 | $this->userScopes = $userScopes; 53 | 54 | return $this; 55 | } 56 | 57 | public function setState(string $state): self 58 | { 59 | $this->state = $state; 60 | 61 | return $this; 62 | } 63 | 64 | public function setRedirectUri(string $redirectUri): self 65 | { 66 | $this->redirectUri = $redirectUri; 67 | 68 | return $this; 69 | } 70 | 71 | public function createUrl(): string 72 | { 73 | if (!isset($this->clientId)) { 74 | throw new Exception('Must provide client ID'); 75 | } 76 | 77 | if (empty($this->scopes) && empty($this->userScopes)) { 78 | throw new Exception('Must provide scopes and/or user scopes'); 79 | } 80 | 81 | $query = http_build_query(array_filter([ 82 | 'scope' => implode(',', $this->scopes), 83 | 'user_scope' => implode(',', $this->userScopes), 84 | 'client_id' => $this->clientId, 85 | 'state' => $this->state, 86 | 'redirect_uri' => $this->redirectUri, 87 | ])); 88 | 89 | return 'https://slack.com/oauth/v2/authorize?' . $query; 90 | } 91 | 92 | public function createLink(): string 93 | { 94 | return <<Add to Slack 99 | HTML; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Auth/SingleAppCredentialsStore.php: -------------------------------------------------------------------------------- 1 | getSigningKey(); 27 | if ($signingKey === null) { 28 | throw new Exception('Signing key not set for App'); 29 | } 30 | 31 | $this->appCredentials = new AppCredentials( 32 | $signingKey, 33 | $defaultBotToken ?? $env->getBotToken(), 34 | $clientId ?? $env->getClientId(), 35 | $clientSecret ?? $env->getClientSecret(), 36 | ); 37 | } 38 | 39 | /** 40 | * @param string $appId 41 | * @return AppCredentials 42 | * @throws Exception if bot app credentials cannot be retrieved 43 | */ 44 | public function getAppCredentials(string $appId): AppCredentials 45 | { 46 | return $this->appCredentials; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Auth/SingleTeamTokenStore.php: -------------------------------------------------------------------------------- 1 | token = $token ?? Env::vars()->getBotToken(); 16 | } 17 | 18 | public function get(?string $teamId, ?string $enterpriseId): string 19 | { 20 | if ($this->token === null) { 21 | throw new Exception('No bot token available: Bot token is null or is missing from environment'); 22 | } 23 | 24 | return $this->token; 25 | } 26 | 27 | public function set(?string $teamId, ?string $enterpriseId, string $token): void 28 | { 29 | throw new Exception('Cannot change bot token in SingleTokenStore'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Auth/TokenStore.php: -------------------------------------------------------------------------------- 1 | router = new Router(); 17 | parent::__construct($this->router); 18 | $this->prepareConfig($this->config); 19 | $this->prepareRouter($this->router); 20 | } 21 | 22 | /** 23 | * Prepares the application's router. 24 | * 25 | * Implementations MUST override this method to configure the Router. 26 | * 27 | * @param Router $router 28 | */ 29 | abstract protected function prepareRouter(Router $router): void; 30 | 31 | /** 32 | * Prepares the application's config. 33 | * 34 | * Implementations SHOULD override this method to configure the Application. 35 | * 36 | * @param AppConfig $config 37 | */ 38 | protected function prepareConfig(AppConfig $config): void 39 | { 40 | // Does nothing by default. 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Clients/ApiClient.php: -------------------------------------------------------------------------------- 1 | $params Associative array of input parameters. 22 | * @return array JSON-decoded response data. 23 | * @throws Exception If the API call is not successful. 24 | * @see https://api.slack.com/methods 25 | */ 26 | public function call(string $api, array $params): array; 27 | } 28 | -------------------------------------------------------------------------------- /src/Clients/OAuthClient.php: -------------------------------------------------------------------------------- 1 | apiClient = $apiClient ?? new SimpleApiClient(null); 18 | } 19 | 20 | /** 21 | * @param string $clientId 22 | * @param string $clientSecret 23 | * @param string $temporaryAccessCode 24 | * @param string|null $redirectUri 25 | * @return array Includes access_token, team.id, and enterprise.id fields 26 | * @throws Exception 27 | */ 28 | public function createAccessToken( 29 | string $clientId, 30 | string $clientSecret, 31 | string $temporaryAccessCode, 32 | ?string $redirectUri = null 33 | ): array { 34 | return $this->apiClient->call('oauth.v2.access', array_filter([ 35 | 'code' => $temporaryAccessCode, 36 | 'client_id' => $clientId, 37 | 'client_secret' => $clientSecret, 38 | 'redirect_uri' => $redirectUri, 39 | ])); 40 | } 41 | 42 | /** 43 | * @param string $accessToken 44 | * @param bool|null $test 45 | * @return array 46 | * @throws Exception 47 | */ 48 | public function revokeAccessToken(string $accessToken, ?bool $test = null): array 49 | { 50 | return $this->apiClient->call('auth.revoke', [ 51 | 'token' => $accessToken, 52 | 'test' => (int) $test, 53 | ]); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Clients/RespondClient.php: -------------------------------------------------------------------------------- 1 | true, 24 | 'protocol_version' => 1.1, 25 | 'timeout' => 5, 26 | 'user_agent' => 'slack-php/slack-app-framework', 27 | ]; 28 | 29 | private static array $errorMessages = [ 30 | 'network' => 'Slack API request could not be completed', 31 | 'unexpected' => 'Slack API request experienced an unexpected error: %s', 32 | 'unsuccessful' => 'Slack API response was unsuccessful: %s', 33 | 'json_decode' => 'Slack API response contained invalid JSON: %s', 34 | 'json_encode' => 'Slack API request content contained invalid JSON: %s', 35 | ]; 36 | 37 | private function sendJsonRequest(string $method, string $url, array $input): array 38 | { 39 | $header = ''; 40 | if (isset($input['token'])) { 41 | $header .= "Authorization: Bearer {$input['token']}\r\n"; 42 | unset($input['token']); 43 | } 44 | 45 | try { 46 | $content = json_encode($input, JSON_THROW_ON_ERROR); 47 | } catch (JsonException $jsonErr) { 48 | throw $this->createException('json_encode', compact('method', 'url'), $jsonErr); 49 | } 50 | 51 | $length = strlen($content); 52 | $header .= "Content-Type: application/json\r\nContent-Length: {$length}\r\n"; 53 | 54 | return $this->sendHttpRequest($method, $url, $header, $content); 55 | } 56 | 57 | private function sendFormRequest(string $method, string $url, array $input): array 58 | { 59 | $content = http_build_query($input); 60 | $length = strlen($content); 61 | $header = "Content-Type: application/x-www-form-urlencoded\r\nContent-Length: {$length}\r\n"; 62 | 63 | return $this->sendHttpRequest($method, $url, $header, $content); 64 | } 65 | 66 | /** 67 | * @param string $method 68 | * @param string $url 69 | * @param string $header 70 | * @param string $content 71 | * @return array 72 | */ 73 | private function sendHttpRequest(string $method, string $url, string $header, string $content): array 74 | { 75 | $errorContext = compact('method', 'url'); 76 | 77 | try { 78 | $httpOptions = self::$baseOptions + compact('method', 'header', 'content'); 79 | $responseBody = file_get_contents($url, false, stream_context_create(['http' => $httpOptions])); 80 | $responseHeader = $http_response_header ?? []; 81 | $errorContext += $responseHeader; 82 | 83 | if (empty($responseBody) || empty($responseHeader)) { 84 | throw $this->createException('network', $errorContext); 85 | } elseif ($responseBody === 'ok') { 86 | return ['ok' => true]; 87 | } 88 | 89 | $data = json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR); 90 | if (isset($data['ok']) && $data['ok'] === true) { 91 | return $data; 92 | } else { 93 | throw $this->createException('unsuccessful', $errorContext, new Exception($data['error'] ?? 'Unknown')); 94 | } 95 | } catch (Exception $frameworkErr) { 96 | throw $frameworkErr; 97 | } catch (JsonException $jsonErr) { 98 | throw $this->createException('json_decode', $errorContext, $jsonErr); 99 | } catch (Throwable $otherErr) { 100 | throw $this->createException('unexpected', $errorContext, $otherErr); 101 | } 102 | } 103 | 104 | private function createException(string $messageKey, array $context = [], ?Throwable $previous = null): Exception 105 | { 106 | $prevMsg = $previous ? $previous->getMessage() : null; 107 | $message = sprintf(self::$errorMessages[$messageKey] ?? 'Unknown error', $prevMsg); 108 | 109 | return new Exception($message, 0, $previous, $context); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Clients/SimpleApiClient.php: -------------------------------------------------------------------------------- 1 | apiToken = $apiToken; 132 | } 133 | 134 | public function call(string $api, array $params): array 135 | { 136 | if (!isset($params['token']) && isset($this->apiToken)) { 137 | $params['token'] = $this->apiToken; 138 | } 139 | 140 | $url = self::BASE_API . $api; 141 | 142 | return in_array($api, self::SUPPORTS_JSON, true) 143 | ? $this->sendJsonRequest('POST', $url, $params) 144 | : $this->sendFormRequest('POST', $url, $params); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/Clients/SimpleRespondClient.php: -------------------------------------------------------------------------------- 1 | sendJsonRequest('POST', $responseUrl, $message->toArray()); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Coerce.php: -------------------------------------------------------------------------------- 1 | text($message); 31 | } elseif (is_array($message)) { 32 | return Surfaces\Message::fromArray($message); 33 | } 34 | 35 | throw new Exception('Invalid message content'); 36 | } 37 | 38 | /** 39 | * Coerces a Modal-like value to an actual Modal surface. 40 | * 41 | * @param Surfaces\Modal|array|string|callable(): Surfaces\Modal $modal 42 | * @return Surfaces\Modal 43 | * @internal 44 | */ 45 | public static function modal($modal): Surfaces\Modal 46 | { 47 | if (is_callable($modal)) { 48 | $modal = $modal(); 49 | } 50 | 51 | if ($modal instanceof Surfaces\Modal) { 52 | return $modal; 53 | } elseif (is_string($modal)) { 54 | return Surfaces\Modal::new()->title('Thanks')->text($modal); 55 | } elseif (is_array($modal)) { 56 | return Surfaces\Modal::fromArray($modal); 57 | } 58 | 59 | throw new Exception('Invalid modal content'); 60 | } 61 | 62 | /** 63 | * Coerces an "App Home"-like value to an actual App Home surface. 64 | * 65 | * @param Surfaces\AppHome|array|string|callable(): Surfaces\AppHome $appHome 66 | * @return Surfaces\AppHome 67 | * @internal 68 | */ 69 | public static function appHome($appHome): Surfaces\AppHome 70 | { 71 | if (is_callable($appHome)) { 72 | $appHome = $appHome(); 73 | } 74 | 75 | if ($appHome instanceof Surfaces\AppHome) { 76 | return $appHome; 77 | } elseif (is_string($appHome)) { 78 | return Surfaces\AppHome::new()->text($appHome); 79 | } elseif (is_array($appHome)) { 80 | return Surfaces\AppHome::fromArray($appHome); 81 | } 82 | 83 | throw new Exception('Invalid app home content'); 84 | } 85 | 86 | /** 87 | * Coerces a Listener-like value to an actual Listener. 88 | * 89 | * @param Listener|callable(Context): void|class-string $listener 90 | * @return Listener 91 | * @internal 92 | */ 93 | public static function listener($listener): Listener 94 | { 95 | if ($listener instanceof Listener) { 96 | return $listener; 97 | } elseif (is_string($listener)) { 98 | return new Listeners\ClassResolver($listener); 99 | } elseif (is_callable($listener)) { 100 | return new Listeners\Callback($listener); 101 | } 102 | 103 | throw new Exception('Invalid listener'); 104 | } 105 | 106 | /** 107 | * Coerces an Interceptor-like value to an actual Interceptor. 108 | * 109 | * @param Interceptor|callable(): Interceptor|array $interceptor 110 | * @return Interceptor 111 | * @internal 112 | */ 113 | public static function interceptor($interceptor): Interceptor 114 | { 115 | if ($interceptor instanceof Interceptor) { 116 | return $interceptor; 117 | } elseif (is_array($interceptor)) { 118 | return new Interceptors\Chain($interceptor); 119 | } elseif (is_callable($interceptor)) { 120 | return new Interceptors\Lazy($interceptor); 121 | } 122 | 123 | throw new Exception('Invalid interceptor'); 124 | } 125 | 126 | /** 127 | * Coerces an Application-like value to an actual Application. 128 | * 129 | * @param Application|Listener|callable(Context): void|class-string $application 130 | * @return Application 131 | */ 132 | public static function application($application): Application 133 | { 134 | if ($application instanceof Application) { 135 | return $application; 136 | } 137 | 138 | return new Application(self::listener($application)); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Commands/ArgDefinition.php: -------------------------------------------------------------------------------- 1 | name = $name; 29 | $this->type = $type; 30 | $this->required = $required; 31 | $this->description = $description; 32 | } 33 | 34 | /** 35 | * @return string 36 | */ 37 | public function getName(): string 38 | { 39 | return $this->name; 40 | } 41 | 42 | /** 43 | * @return bool 44 | */ 45 | public function isRequired(): bool 46 | { 47 | return $this->required; 48 | } 49 | 50 | /** 51 | * @return string 52 | */ 53 | public function getDescription(): string 54 | { 55 | return $this->description; 56 | } 57 | 58 | /** 59 | * @return string 60 | */ 61 | public function getType(): string 62 | { 63 | return $this->type; 64 | } 65 | 66 | public function getFormat(): string 67 | { 68 | $format = "<{$this->name}:{$this->type}>"; 69 | if (!$this->required) { 70 | $format = "[{$format}]"; 71 | } 72 | 73 | return $format; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Commands/CommandListener.php: -------------------------------------------------------------------------------- 1 | */ 12 | private static array $definitions = []; 13 | 14 | abstract protected static function buildDefinition(DefinitionBuilder $builder): DefinitionBuilder; 15 | 16 | public static function getDefinition(): Definition 17 | { 18 | if (!isset(self::$definitions[static::class])) { 19 | self::$definitions[static::class] = static::buildDefinition(new DefinitionBuilder())->build(); 20 | } 21 | 22 | return self::$definitions[static::class]; 23 | } 24 | 25 | abstract protected function listenToCommand(Context $context, Input $input): void; 26 | 27 | public function handle(Context $context): void 28 | { 29 | $definition = $this->getDefinition(); 30 | 31 | try { 32 | $input = new Input($context->payload()->get('text'), $definition); 33 | $this->listenToCommand($context, $input); 34 | } catch (ParsingException $ex) { 35 | $message = $definition->getHelpMessage($ex->getMessage()); 36 | if ($context->isAcknowledged()) { 37 | $context->respond($message); 38 | } else { 39 | $context->ack($message); 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Commands/CommandRouter.php: -------------------------------------------------------------------------------- 1 | */ 29 | private array $routes; 30 | 31 | public static function new(): self 32 | { 33 | return new self(); 34 | } 35 | 36 | public function __construct(array $routes = []) 37 | { 38 | $this->routes = []; 39 | $this->description = ''; 40 | $this->maxLevels = 1; 41 | $this->add('help', Closure::fromCallable([$this, 'showHelp'])); 42 | foreach ($routes as $subCommand => $listener) { 43 | $this->add($subCommand, $listener); 44 | } 45 | } 46 | 47 | /** 48 | * @param string $subCommand 49 | * @param Listener|callable(Context): void|class-string $listener 50 | * @return $this 51 | */ 52 | public function add(string $subCommand, $listener): self 53 | { 54 | $listener = Coerce::listener($listener); 55 | if ($subCommand === '*') { 56 | $this->default = $listener; 57 | } else { 58 | $this->routes[$subCommand] = $listener; 59 | $this->maxLevels = max($this->maxLevels, count(explode(' ', $subCommand))); 60 | } 61 | 62 | return $this; 63 | } 64 | 65 | /** 66 | * @param Listener|callable(Context): void|class-string $listener 67 | * @return $this 68 | */ 69 | public function withDefault($listener): self 70 | { 71 | $this->default = Coerce::listener($listener); 72 | 73 | return $this; 74 | } 75 | 76 | /** 77 | * @param string $description 78 | * @return self 79 | */ 80 | public function withDescription(string $description): self 81 | { 82 | $this->description = $description; 83 | 84 | return $this; 85 | } 86 | 87 | public function handle(Context $context): void 88 | { 89 | $command = $context->payload()->get('command'); 90 | $text = trim($context->payload()->get('text')); 91 | $nameArgs = array_slice(explode(' ', $text), 0, $this->maxLevels); 92 | 93 | // Match on the most specific (i.e., deepest) sub-command first, and then work backwards to the most generic. 94 | while (!empty($nameArgs)) { 95 | $subCommand = implode(' ', $nameArgs); 96 | if (isset($this->routes[$subCommand])) { 97 | $context->logger()->debug("CommandRouter routing to sub-command: \"{$command} {$subCommand}\""); 98 | $context->set('remaining_text', substr($text, strlen($subCommand) + 1)); 99 | $this->routes[$subCommand]->handle($context); 100 | return; 101 | } 102 | array_pop($nameArgs); 103 | } 104 | 105 | if (isset($this->default)) { 106 | $this->default->handle($context); 107 | } else { 108 | $this->showHelp($context); 109 | } 110 | } 111 | 112 | private function showHelp(Context $context): void 113 | { 114 | $cmd = $context->payload()->get('command'); 115 | $fmt = $context->fmt(); 116 | $msg = $context->blocks()->message(); 117 | 118 | $msg->header("The {$cmd} Command"); 119 | if ($this->description) { 120 | $msg->text($this->description); 121 | } 122 | 123 | $routes = array_keys($this->routes); 124 | natsort($routes); 125 | $msg->newSection()->mrkdwnText($fmt->lines([ 126 | '*Available sub-commands*:', 127 | $fmt->bulletedList(array_map(fn (string $subCommand) => $fmt->code("{$cmd} {$subCommand}"), $routes)) 128 | ])); 129 | 130 | if ($context->isAcknowledged()) { 131 | $context->respond($msg); 132 | } else { 133 | $context->ack($msg); 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Commands/Definition.php: -------------------------------------------------------------------------------- 1 | name = $name; 29 | $this->subCommand = $subCommand; 30 | $this->description = $description; 31 | $this->args = $args; 32 | $this->opts = $opts; 33 | } 34 | 35 | /** 36 | * @return string 37 | */ 38 | public function getName(): string 39 | { 40 | return $this->name; 41 | } 42 | 43 | /** 44 | * @return string|null 45 | */ 46 | public function getSubCommand(): ?string 47 | { 48 | return $this->subCommand; 49 | } 50 | 51 | /** 52 | * @return string 53 | */ 54 | public function getDescription(): string 55 | { 56 | return $this->description; 57 | } 58 | 59 | /** 60 | * @return ArgDefinition[] 61 | */ 62 | public function getArgs(): array 63 | { 64 | return $this->args; 65 | } 66 | 67 | /** 68 | * @return OptDefinition[] 69 | */ 70 | public function getOpts(): array 71 | { 72 | return $this->opts; 73 | } 74 | 75 | public function getHelpMessage(?string $error = null): Message 76 | { 77 | return Message::new()->ephemeral()->tap(function (Message $msg) use ($error) { 78 | if ($error) { 79 | $msg->header(':warning: Command Error')->text("> {$error}"); 80 | } 81 | 82 | $msg->text("*Command Usage*: ```{$this->getCommandFormat()}```"); 83 | }); 84 | } 85 | 86 | public function getCommandFormat(): string 87 | { 88 | $parts = []; 89 | 90 | $parts[] = "/{$this->name}"; 91 | 92 | if ($this->subCommand !== null) { 93 | $parts[] = $this->subCommand; 94 | } 95 | 96 | foreach ($this->args as $arg) { 97 | $parts[] = $arg->getFormat(); 98 | } 99 | 100 | $opts = ''; 101 | foreach ($this->opts as $opt) { 102 | $opts .= "\n {$opt->getFormat()}"; 103 | } 104 | 105 | return implode(' ', $parts) . $opts; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Commands/DefinitionBuilder.php: -------------------------------------------------------------------------------- 1 | name = $commandName; 29 | 30 | return $this; 31 | } 32 | 33 | public function subCommand(string $subCommandName): self 34 | { 35 | $this->subCommand = $subCommandName; 36 | 37 | return $this; 38 | } 39 | 40 | public function description(string $description): self 41 | { 42 | $this->description = $description; 43 | 44 | return $this; 45 | } 46 | 47 | public function arg( 48 | string $name, 49 | string $type = ArgDefinition::TYPE_STRING, 50 | bool $required = true, 51 | string $description = '' 52 | ): self { 53 | $this->args[] = new ArgDefinition($name, $type, $required, $description); 54 | 55 | return $this; 56 | } 57 | 58 | public function opt( 59 | string $name, 60 | string $type = OptDefinition::TYPE_BOOL, 61 | ?string $shortName = null, 62 | string $description = '' 63 | ): self { 64 | $this->opts[] = new OptDefinition($name, $type, $shortName, $description); 65 | 66 | return $this; 67 | } 68 | 69 | public function build(): Definition 70 | { 71 | if ($this->name === null) { 72 | throw new Exception('Cannot build command without name'); 73 | } 74 | 75 | return new Definition($this->name, $this->subCommand, $this->description, $this->args, $this->opts); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Commands/Input.php: -------------------------------------------------------------------------------- 1 | definition = $definition; 18 | $parser = new Parser($this->definition); 19 | $this->setData($parser->parse($commandText)); 20 | } 21 | 22 | /** 23 | * @return Definition 24 | */ 25 | public function getDefinition(): Definition 26 | { 27 | return $this->definition; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Commands/OptDefinition.php: -------------------------------------------------------------------------------- 1 | name = $name; 29 | $this->type = $type; 30 | $this->shortName = $shortName; 31 | $this->description = $description; 32 | } 33 | 34 | /** 35 | * @return string 36 | */ 37 | public function getName(): string 38 | { 39 | return $this->name; 40 | } 41 | 42 | /** 43 | * @return string|null 44 | */ 45 | public function getShortName(): ?string 46 | { 47 | return $this->shortName; 48 | } 49 | 50 | /** 51 | * @return string 52 | */ 53 | public function getDescription(): string 54 | { 55 | return $this->description; 56 | } 57 | 58 | /** 59 | * @return string 60 | */ 61 | public function getType(): string 62 | { 63 | return $this->type; 64 | } 65 | 66 | /** 67 | * @return bool 68 | */ 69 | public function isArray(): bool 70 | { 71 | return substr($this->type, -2) === '[]'; 72 | } 73 | 74 | public function getFormat(): string 75 | { 76 | $format = "[--{$this->name}"; 77 | if ($this->shortName !== null) { 78 | $format .= "|-{$this->shortName}"; 79 | } 80 | 81 | if (in_array($this->type, [self::TYPE_STRING, self::TYPE_INT, self::TYPE_FLOAT])) { 82 | $format .= " <{$this->type}>]"; 83 | } elseif (in_array($this->type, [self::TYPE_STRING_ARRAY, self::TYPE_INT_ARRAY, self::TYPE_FLOAT_ARRAY])) { 84 | $type = rtrim($this->type, '[]'); 85 | $format .= " <{$type}>]..."; 86 | } else { 87 | $format .= ']'; 88 | } 89 | 90 | return $format; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Commands/Parser.php: -------------------------------------------------------------------------------- 1 | [^=]+)(?:=(?P.+))?$/'; 18 | 19 | private Definition $definition; 20 | 21 | /** @var ArgDefinition[] */ 22 | private array $remainingArgs; 23 | 24 | /** @var OptDefinition[] */ 25 | private array $optMap; 26 | 27 | public function __construct(Definition $definition) 28 | { 29 | $this->definition = $definition; 30 | $this->remainingArgs = $this->definition->getArgs(); 31 | $this->optMap = []; 32 | foreach ($this->definition->getOpts() as $opt) { 33 | $this->optMap[$opt->getName()] = $opt; 34 | if ($opt->getShortName()) { 35 | $this->optMap[$opt->getShortName()] = $opt; 36 | } 37 | } 38 | } 39 | 40 | /** 41 | * @param string $input 42 | * @return array 43 | * @throws ParsingException if command is invalid. 44 | */ 45 | public function parse(string $input): array 46 | { 47 | $subCommand = $this->definition->getSubCommand(); 48 | if ($subCommand !== null) { 49 | if (strpos($input, $subCommand) === 0) { 50 | $input = ltrim(substr($input, strlen($subCommand))); 51 | } else { 52 | throw new ParsingException('Input does not match the defined sub-command: `%s`', [$subCommand]); 53 | } 54 | } 55 | 56 | $data = []; 57 | foreach ($this->getArgsAndOpts($input) as [$key, $value]) { 58 | $data[$key] = is_array($value) 59 | ? (isset($data[$key]) ? array_merge($data[$key], $value) : $value) 60 | : $value; 61 | } 62 | 63 | foreach ($this->remainingArgs as $arg) { 64 | if ($arg->isRequired()) { 65 | throw new ParsingException('Missing required arg: `%s`', [$arg->getName()]); 66 | } 67 | } 68 | 69 | foreach ($this->optMap as $name => $opt) { 70 | if (!isset($data[$name]) && $opt->getType() === OptDefinition::TYPE_BOOL) { 71 | $data[$name] = false; 72 | } 73 | } 74 | 75 | return $data; 76 | } 77 | 78 | /** 79 | * @param string $input 80 | * @return iterable 81 | * @throws ParsingException for invalid args/opts 82 | */ 83 | private function getArgsAndOpts(string $input): iterable 84 | { 85 | /** @var Token|null $incomplete */ 86 | $incomplete = null; 87 | $argIndex = 0; 88 | foreach ($this->tokenize($input) as $token) { 89 | if ($incomplete) { 90 | if ($token->isOpt()) { 91 | throw new ParsingException('Expected value for `%s`, but received new opt: `%s`', [ 92 | $incomplete->getKey(), 93 | $token->getKey() 94 | ]); 95 | } else { 96 | $incomplete->resolveValue($token->getValue()); 97 | $token = $incomplete; 98 | $incomplete = null; 99 | } 100 | } 101 | 102 | if ($token->isOpt()) { 103 | $optData = $this->createOpt($token); 104 | if ($optData === null) { 105 | $incomplete = $token; 106 | } else { 107 | yield $optData; 108 | } 109 | } else { 110 | yield $this->createArg($argIndex, $token); 111 | $argIndex++; 112 | } 113 | } 114 | 115 | if ($incomplete) { 116 | $incomplete->resolveValue('true'); 117 | yield $this->createOpt($incomplete); 118 | } 119 | } 120 | 121 | /** 122 | * Tokenizes an input args string. 123 | * 124 | * Borrowed, with gratitude, from the Symfony Console component. 125 | * 126 | * @param string $input 127 | * @return iterable 128 | * @see https://github.com/symfony/console/blob/5.x/Input/StringInput.php 129 | */ 130 | private function tokenize(string $input): iterable 131 | { 132 | // Convert smart quotes to regular ones. 133 | $input = strtr($input, ['“' => '"', '”' => '"', "‘" => "'", "’" => "'"]); 134 | 135 | $length = strlen($input); 136 | $cursor = 0; 137 | while ($cursor < $length) { 138 | if (preg_match('/\s+/A', $input, $match, 0, $cursor)) { 139 | // Skip whitespace. 140 | } elseif (preg_match('/([^="\'\s]+?)(=?)(' . self::QUOTED_STRING . '+)/A', $input, $match, 0, $cursor)) { 141 | $value = str_replace(['"\'', '\'"', '\'\'', '""'], '', substr($match[3], 1, strlen($match[3]) - 2)); 142 | yield new Token($match[1] . $match[2] . stripcslashes($value)); 143 | } elseif (preg_match('/' . self::QUOTED_STRING . '/A', $input, $match, 0, $cursor)) { 144 | yield new Token(stripcslashes(substr($match[0], 1, strlen($match[0]) - 2))); 145 | } elseif (preg_match('/' . self::NORMAL_STRING . '/A', $input, $match, 0, $cursor)) { 146 | yield new Token(stripcslashes($match[1])); 147 | } else { 148 | // Should never happen (according to Symfony Console devs). 149 | throw new ParsingException('Unable to parse input near `... %s ...`', [substr($input, $cursor, 10)]); 150 | } 151 | 152 | $cursor += strlen($match[0]); 153 | } 154 | } 155 | 156 | /** 157 | * @param int $index 158 | * @param Token $token 159 | * @return array 160 | */ 161 | private function createArg(int $index, Token $token): array 162 | { 163 | // Make sure the definition supports this arg. 164 | $argDef = $this->remainingArgs[$index] ?? null; 165 | unset($this->remainingArgs[$index]); 166 | if ($argDef === null) { 167 | throw new ParsingException('Too many args provided than defined'); 168 | } 169 | 170 | // Validate and coerce the value to the correct type. 171 | $finalValue = $this->validateAndCoerceValueType($token->getValue(), $argDef->getType()); 172 | if ($finalValue === null) { 173 | throw new ParsingException('Invalid value (`%s`) for arg `%s`; should be type: `%s`', [ 174 | $token->getValue(), 175 | $argDef->getName(), 176 | $argDef->getType(), 177 | ]); 178 | } 179 | 180 | // Return the name-value tuple. Parser ultimately converts this to key-value pairs. 181 | return [$argDef->getName(), $finalValue]; 182 | } 183 | 184 | /** 185 | * @param Token $token 186 | * @return array|null 187 | * @throws ParsingException 188 | */ 189 | private function createOpt(Token $token): ?array 190 | { 191 | // Make sure the definition supports this opt. 192 | $optDef = $this->optMap[$token->getKey()] ?? null; 193 | if ($optDef === null) { 194 | throw new ParsingException('Invalid opt provided: `%s`', [$token->getKey()]); 195 | } 196 | 197 | // Make sure we have a value for the opt. 198 | if ($token->getValue() === null) { 199 | if ($optDef->getType() === OptDefinition::TYPE_BOOL) { 200 | // No value needed for bool flag. Set to true. 201 | $token->resolveValue('true'); 202 | } else { 203 | // Value needed, but not yet known. Return null and let parsing continue. 204 | return null; 205 | } 206 | } 207 | 208 | // Validate and coerce the value to the correct type. 209 | $type = rtrim($optDef->getType(), '[]'); 210 | $finalValue = $this->validateAndCoerceValueType($token->getValue(), $type); 211 | if ($finalValue === null) { 212 | throw new ParsingException('Invalid value (`%s`) for opt `%s`; should be type: `%s`', [ 213 | $token->getValue(), 214 | $token->getKey(), 215 | $type, 216 | ]); 217 | } 218 | 219 | // Wrap array types in array to be merged with any previous/future values. 220 | $finalValue = $optDef->isArray() ? [$finalValue] : $finalValue; 221 | 222 | // Return the name-value tuple. Parser ultimately converts this to key-value pairs. 223 | return [$optDef->getName(), $finalValue]; 224 | } 225 | 226 | /** 227 | * @param string $value 228 | * @param string $type 229 | * @return mixed 230 | */ 231 | private function validateAndCoerceValueType(string $value, string $type) 232 | { 233 | switch ($type) { 234 | case ArgDefinition::TYPE_BOOL: 235 | $value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); 236 | return ($value === null) ? null : (bool) $value; 237 | case ArgDefinition::TYPE_INT: 238 | $value = filter_var($value, FILTER_VALIDATE_INT); 239 | return ($value === false) ? null : (int) $value; 240 | case ArgDefinition::TYPE_FLOAT: 241 | $value = filter_var($value, FILTER_VALIDATE_FLOAT); 242 | return ($value === false) ? null : (float) $value; 243 | default: 244 | return (string) $value; 245 | } 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/Commands/ParsingException.php: -------------------------------------------------------------------------------- 1 | = 2 && substr($token, 0, 2) === '--') { 17 | $this->isOpt = true; 18 | [$this->key, $this->value] = array_pad(explode('=', substr($token, 2), 2), 2, null); 19 | } elseif ($len > 1 && $token[0] === '-') { 20 | $this->isOpt = true; 21 | $this->key = $token[1]; 22 | $this->value = ($len > 2) ? substr($token, 2) : null; 23 | if ($this->value && $this->value[0] === '=') { 24 | $this->value = substr($this->value, 1); 25 | } 26 | } else { 27 | $this->isOpt = false; 28 | $this->value = $token; 29 | } 30 | } 31 | 32 | public function resolveValue($value): void 33 | { 34 | $this->value = $value; 35 | } 36 | 37 | /** 38 | * @return string|null 39 | */ 40 | public function getValue(): ?string 41 | { 42 | return $this->value; 43 | } 44 | 45 | /** 46 | * @return string|null 47 | */ 48 | public function getKey(): ?string 49 | { 50 | return $this->key; 51 | } 52 | 53 | /** 54 | * @return bool 55 | */ 56 | public function isOpt(): bool 57 | { 58 | return $this->isOpt; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Contexts/Blocks.php: -------------------------------------------------------------------------------- 1 | has($id)) { 17 | throw new Exception("Class does not exist: {$id}"); 18 | } 19 | 20 | return new $id(); 21 | } 22 | 23 | public function has($id): bool 24 | { 25 | return class_exists($id); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Contexts/DataBag.php: -------------------------------------------------------------------------------- 1 | context = $context; 23 | $this->exception = $exception; 24 | $this->explanation = 'An error occurred in the application.'; 25 | $this->additionalContext = []; 26 | } 27 | 28 | /** 29 | * Adds a human-readable explanation of the error that's safe to provide to the application user. 30 | * 31 | * @param string $explanation 32 | * @return $this 33 | */ 34 | public function addExplanation(string $explanation): self 35 | { 36 | $this->explanation = $explanation; 37 | 38 | return $this; 39 | } 40 | 41 | /** 42 | * Adds additional context to the error that will be included in the logs. 43 | * 44 | * @param array $additionalContext 45 | * @return $this 46 | */ 47 | public function addAdditionalContext(array $additionalContext): self 48 | { 49 | $this->additionalContext += $additionalContext; 50 | 51 | return $this; 52 | } 53 | 54 | /** 55 | * Logs error and displays error on the App Home. 56 | */ 57 | public function appHome(): void 58 | { 59 | $this->log(); 60 | $appHomeFactory = $this->context->getAppConfig()->getErrorAppHomeFactory(); 61 | $this->context->appHome()->update($appHomeFactory($this->explanation)); 62 | } 63 | 64 | /** 65 | * Logs error and displays error in a modal. 66 | */ 67 | public function modal(): void 68 | { 69 | $this->log(); 70 | $modalFactory = $this->context->getAppConfig()->getErrorModalFactory(); 71 | $modal = $modalFactory($this->explanation); 72 | if ($this->context->payload()->get('view.type') === 'modal') { 73 | $this->context->modals()->push($modal); 74 | } else { 75 | $this->context->modals()->open($modal); 76 | } 77 | } 78 | 79 | /** 80 | * Logs error and sends error to the user as a message. 81 | */ 82 | public function message(): void 83 | { 84 | $this->log(); 85 | $messageFactory = $this->context->getAppConfig()->getErrorMessageFactory(); 86 | $message = $messageFactory($this->explanation)->ephemeral(); 87 | $responseUrl = $this->context->payload()->getResponseUrl(); 88 | if ($responseUrl !== null) { 89 | $this->context->respond($message, $responseUrl); 90 | } else { 91 | $this->context->say($message, $this->context->payload()->getUserId()); 92 | } 93 | } 94 | 95 | /** 96 | * Rethrows the error as a new exception. 97 | * 98 | * @throws Exception 99 | */ 100 | public function reThrow(): void 101 | { 102 | $exception = $this->exception instanceof Exception 103 | ? $this->exception 104 | : new Exception($this->explanation, 0, $this->exception); 105 | 106 | $exception->addContext($this->additionalContext); 107 | 108 | throw $exception; 109 | } 110 | 111 | /** 112 | * Logs error rethrows it as a new exception. 113 | * 114 | * @throws Exception 115 | */ 116 | public function logAndReThrow(): void 117 | { 118 | $this->log(); 119 | $this->reThrow(); 120 | } 121 | 122 | private function log(): void 123 | { 124 | $context = $this->additionalContext + ['exception' => $this->exception]; 125 | $this->context->logger()->error($this->explanation, $context); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Contexts/HasData.php: -------------------------------------------------------------------------------- 1 | */ 12 | protected array $data = []; 13 | 14 | /** 15 | * This constructor can (and likely should be) be overridden by trait users. 16 | * 17 | * @param array $data 18 | */ 19 | public function __construct(array $data = []) 20 | { 21 | $this->setData($data); 22 | } 23 | 24 | private function setData(array $data): void 25 | { 26 | foreach ($data as $key => $value) { 27 | if ($value === null) { 28 | continue; 29 | } 30 | 31 | $this->data[$key] = $value; 32 | } 33 | } 34 | 35 | /** 36 | * Get a value from the data. 37 | * 38 | * @param string $key Key or dot-separated path to value in data. 39 | * @param bool $required Whether to throw an exception if the value is not set. 40 | * @return mixed 41 | */ 42 | public function get(string $key, bool $required = false) 43 | { 44 | $value = $this->getDeep(explode('.', $key), $this->data); 45 | if ($required && $value === null) { 46 | $class = static::class; 47 | throw new Exception("Missing required value from {$class}: \"{$key}\"."); 48 | } 49 | 50 | return $value; 51 | } 52 | 53 | /** 54 | * @param string[] $keys 55 | * @param bool $required Whether to throw an exception if none of the values are set. 56 | * @return mixed 57 | */ 58 | public function getOneOf(array $keys, bool $required = false) 59 | { 60 | foreach ($keys as $key) { 61 | $value = $this->get($key); 62 | if ($value !== null) { 63 | return $value; 64 | } 65 | } 66 | 67 | if ($required) { 68 | $class = static::class; 69 | $list = implode(', ', array_map(fn (string $key) => "\"{$key}\"", $keys)); 70 | 71 | throw new Exception("Missing required value from {$class}: one of {$list}."); 72 | } 73 | 74 | return null; 75 | } 76 | 77 | /** 78 | * @param string[] $keys 79 | * @param bool $required Whether to throw an exception if any of the values are set. 80 | * @return array 81 | */ 82 | public function getAllOf(array $keys, bool $required = false): array 83 | { 84 | $values = []; 85 | $missing = []; 86 | foreach ($keys as $key) { 87 | $value = $this->get($key); 88 | if ($value === null) { 89 | $missing[] = $key; 90 | } else { 91 | $values[$key] = $value; 92 | } 93 | } 94 | 95 | if ($required && !empty($missing)) { 96 | $class = static::class; 97 | $list = implode(', ', array_map(fn (string $key) => "\"{$key}\"", $missing)); 98 | 99 | throw new Exception("Missing required values from {$class}: all of {$list}."); 100 | } 101 | 102 | return $values; 103 | } 104 | 105 | /** 106 | * @param array $keys 107 | * @param array $data 108 | * @return mixed 109 | */ 110 | private function getDeep(array $keys, array &$data) 111 | { 112 | // Try the first key segment. 113 | $key = array_shift($keys); 114 | $value = $data[$key] ?? null; 115 | if ($value === null) { 116 | return null; 117 | } 118 | 119 | // If no more key segments, then it's are done. Don't recurse. 120 | if (empty($keys)) { 121 | return $value; 122 | } 123 | 124 | // If there is nothing to recurse into, don't recurse. 125 | if (!is_array($value)) { 126 | return null; 127 | } 128 | 129 | // Recurse into the next layer of the data with the remaining key segments. 130 | return $this->getDeep($keys, $value); 131 | } 132 | 133 | /** 134 | * Get all data as an associative array. 135 | * 136 | * Scrubs any sensitive keys. 137 | * 138 | * @return array 139 | */ 140 | public function toArray(): array 141 | { 142 | return $this->data; 143 | } 144 | 145 | public function jsonSerialize() 146 | { 147 | return $this->toArray(); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/Contexts/Home.php: -------------------------------------------------------------------------------- 1 | context = $context; 21 | } 22 | 23 | /** 24 | * Updates the user's App Home without a hash check. 25 | * 26 | * This is essentially a force update. 27 | * 28 | * @param AppHome|array|string|callable(): AppHome $appHome App Home content. 29 | * @param string|null $userId The ID for the user that will have their App Home updated. Defaults to current user. 30 | * @return bool 31 | */ 32 | public function update($appHome, ?string $userId = null): bool 33 | { 34 | return $this->callViewsPublishApi(Coerce::appHome($appHome), $userId, null); 35 | } 36 | 37 | /** 38 | * Updates the user's App Home using a hash check. 39 | * 40 | * This includes a hash check, which means that if the App Home is updated in another process first, then this API 41 | * call will fail due to the hash not matching. 42 | * 43 | * @param AppHome|array|string|callable(): AppHome $appHome App Home content. 44 | * @param string|null $userId The ID for the user that will have their App Home updated. Defaults to current user. 45 | * @param string|null $hash The hash for Slack to verify for conditional updates. 46 | * @return bool 47 | */ 48 | public function safeUpdate($appHome, ?string $userId = null, ?string $hash = null): bool 49 | { 50 | $hash = $hash ?? $this->context->payload()->get('view.hash', true); 51 | 52 | return $this->callViewsPublishApi(Coerce::appHome($appHome), $userId, $hash); 53 | } 54 | 55 | /** 56 | * Updates the user's App Home using a hash check, but does not throw an exception is the hash check fails. 57 | * 58 | * This is a "best effort" approach where the hash check still occurs, but failing to update the App Home is not 59 | * considered an error state. 60 | * 61 | * @param AppHome|array|string|callable(): AppHome $appHome App Home content. 62 | * @param string|null $userId The ID for the user that will have their App Home updated. Defaults to current user. 63 | * @param string|null $hash The hash for Slack to verify for conditional updates. 64 | * @return bool 65 | */ 66 | public function updateIfSafe($appHome, ?string $userId = null, ?string $hash = null): bool 67 | { 68 | try { 69 | return $this->safeUpdate($appHome, $userId, $hash); 70 | } catch (Throwable $ex) { 71 | return false; 72 | } 73 | } 74 | 75 | /** 76 | * Makes the API call to "views.publish" for updating an App Home. 77 | * 78 | * @param AppHome $appHome App Home content. 79 | * @param string|null $userId The ID for the user that will have their App Home updated. Defaults to current user. 80 | * @param string|null $hash The hash for Slack to verify for conditional updates. 81 | * @return bool 82 | */ 83 | private function callViewsPublishApi(AppHome $appHome, ?string $userId, ?string $hash): bool 84 | { 85 | $payload = $this->context->payload(); 86 | 87 | try { 88 | $params = [ 89 | 'user_id' => $userId ?? $payload->getUserId(), 90 | 'view' => $appHome->toArray(), 91 | ]; 92 | 93 | if ($hash !== null) { 94 | $params['hash'] = $hash; 95 | } 96 | 97 | $result = $this->context->api('views.publish', $params); 98 | 99 | return (bool) ($result['ok'] ?? false); 100 | } catch (Throwable $ex) { 101 | throw new Exception('API call to `views.publish` failed', 0, $ex); 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Contexts/InputState.php: -------------------------------------------------------------------------------- 1 | payload()->getState()->toArray()); 26 | } 27 | 28 | public function __construct(array $stateData) 29 | { 30 | $data = []; 31 | foreach ($stateData as $blockId => $input) { 32 | $elem = reset($input); 33 | $data[$blockId] = $elem['value'] 34 | ?? $elem['selected_option']['value'] 35 | ?? $elem['selected_date'] 36 | ?? $elem['selected_time'] 37 | ?? $elem['selected_user'] 38 | ?? $elem['selected_conversation'] 39 | ?? $elem['selected_channel'] 40 | ?? null; 41 | if ($data[$blockId] !== null) { 42 | continue; 43 | } 44 | 45 | if (isset($elem['selected_options'])) { 46 | $data[$blockId] = array_column($elem['selected_options'], 'value'); 47 | continue; 48 | } 49 | 50 | $data[$blockId] = $elem['selected_users'] 51 | ?? $elem['selected_conversations'] 52 | ?? $elem['selected_channels'] 53 | ?? null; 54 | } 55 | 56 | $this->setData($data); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Contexts/Modals.php: -------------------------------------------------------------------------------- 1 | context = $context; 21 | } 22 | 23 | /** 24 | * Opens a new modal. 25 | * 26 | * @param Modal|array|string|callable(): Modal $modal Modal content. 27 | * @param string|null $triggerId Non-expired trigger ID. Defaults to the trigger ID from the current payload. 28 | * @return array 29 | */ 30 | public function open($modal, ?string $triggerId = null): array 31 | { 32 | try { 33 | $triggerId ??= (string) $this->context->payload()->get('trigger_id', true); 34 | $result = $this->context->api('views.open', [ 35 | 'trigger_id' => $triggerId, 36 | 'view' => Coerce::modal($modal)->toArray(), 37 | ]); 38 | 39 | return $result['view'] ?? []; 40 | } catch (Throwable $ex) { 41 | throw new Exception('Slack API call to `views.open` failed', 0, $ex); 42 | } 43 | } 44 | 45 | /** 46 | * Pushes a new modal onto the modal stack. 47 | * 48 | * Note: A modal stack can have up to 3 modals. 49 | * 50 | * @param Modal|array|string|callable(): Modal $modal Modal content. 51 | * @param string|null $triggerId Non-expired trigger ID. Defaults to the trigger ID from the current payload. 52 | * @return array 53 | */ 54 | public function push($modal, ?string $triggerId = null): array 55 | { 56 | try { 57 | $triggerId ??= (string) $this->context->payload()->get('trigger_id', true); 58 | $result = $this->context->api('views.push', [ 59 | 'trigger_id' => $triggerId, 60 | 'view' => Coerce::modal($modal)->toArray(), 61 | ]); 62 | 63 | return $result['view'] ?? []; 64 | } catch (Throwable $ex) { 65 | throw new Exception('Slack API call to `views.push` failed', 0, $ex); 66 | } 67 | } 68 | 69 | /** 70 | * Updates an existing modal without a hash check. 71 | * 72 | * This is essentially a force update. 73 | * 74 | * @param Modal|array|string|callable(): Modal $modal Modal content. 75 | * @param string|null $viewId The modal's ID. Defaults to the view ID from the current payload. 76 | * @param string|null $extId The custom external ID for the modal, if one was assigned. 77 | * @return array 78 | */ 79 | public function update($modal, ?string $viewId = null, ?string $extId = null): array 80 | { 81 | return $this->callViewsUpdateApi(Coerce::modal($modal), $viewId, null, $extId); 82 | } 83 | 84 | /** 85 | * Updates an existing modal using a hash check. 86 | * 87 | * This includes a hash check, which means that if the modal is updated in another process first, then this API call 88 | * will fail due to the hash not matching. 89 | * 90 | * @param Modal|array|string|callable(): Modal $modal Modal content. 91 | * @param string|null $viewId The modal's ID. Defaults to the view ID from the current payload. 92 | * @param string|null $hash The hash for Slack to verify for conditional updates. 93 | * @param string|null $extId The custom external ID for the modal, if one was assigned. 94 | * @return array 95 | */ 96 | public function safeUpdate($modal, ?string $viewId = null, ?string $hash = null, ?string $extId = null): array 97 | { 98 | $hash = $hash ?? $this->context->payload()->get('view.hash', true); 99 | 100 | return $this->callViewsUpdateApi(Coerce::modal($modal), $viewId, $hash, $extId); 101 | } 102 | 103 | /** 104 | * Updates an existing modal using a hash check, but does not throw an exception is the hash check fails. 105 | * 106 | * This is a "best effort" approach where the hash check still occurs, but failing to update the modal is not 107 | * considered an error state. 108 | * 109 | * @param Modal|array|string|callable(): Modal $modal Modal content. 110 | * @param string|null $viewId The modal's ID. Defaults to the view ID from the current payload. 111 | * @param string|null $hash The hash for Slack to verify for conditional updates. 112 | * @param string|null $extId The custom external ID for the modal, if one was assigned. 113 | * @return array 114 | */ 115 | public function updateIfSafe($modal, ?string $viewId = null, ?string $hash = null, ?string $extId = null): array 116 | { 117 | try { 118 | return $this->safeUpdate($modal, $viewId, $hash, $extId); 119 | } catch (Throwable $ex) { 120 | return []; 121 | } 122 | } 123 | 124 | /** 125 | * Makes the API call to "views.update" for updating a modal. 126 | * 127 | * @param Modal $modal Modal content. 128 | * @param string|null $viewId The modal's ID. Defaults to the view ID from the current payload. 129 | * @param string|null $hash The hash for Slack to verify for conditional updates. 130 | * @param string|null $externalId The custom external ID for the modal, if one was assigned. 131 | * @return array 132 | */ 133 | private function callViewsUpdateApi(Modal $modal, ?string $viewId, ?string $hash, ?string $externalId): array 134 | { 135 | $payload = $this->context->payload(); 136 | 137 | try { 138 | if ($externalId !== null) { 139 | $viewId = null; 140 | } else { 141 | $viewId ??= (string) $payload->get('view.id', true); 142 | } 143 | 144 | $result = $this->context->api('views.update', array_filter([ 145 | 'view_id' => $viewId, 146 | 'external_id' => $externalId, 147 | 'view' => $modal->toArray(), 148 | 'hash' => $hash, 149 | ])); 150 | 151 | return $result['view'] ?? []; 152 | } catch (Throwable $ex) { 153 | throw new Exception('Slack API call to `views.update` failed', 0, $ex); 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/Contexts/Payload.php: -------------------------------------------------------------------------------- 1 | type = PayloadType::withValue($data['type']); 54 | } elseif (isset($data['command'])) { 55 | $this->type = PayloadType::command(); 56 | } else { 57 | $this->type = PayloadType::unknown(); 58 | } 59 | 60 | $this->setData($data); 61 | } 62 | 63 | public function getType(): PayloadType 64 | { 65 | return $this->type; 66 | } 67 | 68 | public function isType(PayloadType $type): bool 69 | { 70 | return $this->type === $type; 71 | } 72 | 73 | /** 74 | * Returns the main ID/name/type for the payload type used for route indexing. 75 | * 76 | * @return string|null 77 | */ 78 | public function getTypeId(): ?string 79 | { 80 | $field = $this->type->idField(); 81 | $id = $field ? $this->get($field) : null; 82 | if ($id !== null) { 83 | $id = ltrim($id, '/'); 84 | } 85 | 86 | return $id; 87 | } 88 | 89 | /** 90 | * Returns the api_api_id property of the payload, common to almost all payload types. 91 | * 92 | * @return string|null 93 | */ 94 | public function getAppId(): ?string 95 | { 96 | return $this->get('api_app_id'); 97 | } 98 | 99 | /** 100 | * Get the enterprise ID for the payload. 101 | * 102 | * @return string|null 103 | */ 104 | public function getEnterpriseId(): ?string 105 | { 106 | return $this->getOneOf([ 107 | 'authorizations.0.enterprise_id', 108 | 'enterprise.id', 109 | 'enterprise_id', 110 | 'team.enterprise_id', 111 | 'event.enterprise', 112 | 'event.enterprise_id', 113 | ]); 114 | } 115 | 116 | /** 117 | * Get the team/workspace ID for the payload. 118 | * 119 | * @return string|null 120 | */ 121 | public function getTeamId(): ?string 122 | { 123 | return $this->getOneOf(['authorizations.0.team_id', 'team.id', 'team_id', 'event.team', 'user.team_id']); 124 | } 125 | 126 | /** 127 | * Get the channel ID for the payload. 128 | * 129 | * @return string|null 130 | */ 131 | public function getChannelId(): ?string 132 | { 133 | return $this->getOneOf(['channel.id', 'channel_id', 'event.channel', 'event.item.channel']); 134 | } 135 | 136 | /** 137 | * Get the user ID for the payload. 138 | * 139 | * @return string|null 140 | */ 141 | public function getUserId(): ?string 142 | { 143 | return $this->getOneOf(['user.id', 'user_id', 'event.user']); 144 | } 145 | 146 | /** 147 | * Check if the payload is from and enterprise installation. 148 | * 149 | * @return bool 150 | */ 151 | public function isEnterpriseInstall(): bool 152 | { 153 | $value = $this->getOneOf(['authorizations.0.is_enterprise_install', 'is_enterprise_install']); 154 | 155 | return $value === true || $value === 'true'; 156 | } 157 | 158 | /** 159 | * Get the submitted state from the payload, if present. 160 | * 161 | * Can be present for view_submission and some view_closed and block_action requests. 162 | * 163 | * @return DataBag 164 | */ 165 | public function getState(): DataBag 166 | { 167 | return new DataBag($this->getOneOf(['view.state.values', 'state.values']) ?? []); 168 | } 169 | 170 | /** 171 | * Get the private metadata from the payload, if present. 172 | * 173 | * Can be present for view_submission and some view_closed requests. 174 | * 175 | * @return PrivateMetadata 176 | */ 177 | public function getMetadata(): PrivateMetadata 178 | { 179 | $data = $this->getOneOf(['view.private_metadata', 'event.view.private_metadata']); 180 | if ($data === null) { 181 | return new PrivateMetadata(); 182 | } 183 | 184 | return PrivateMetadata::decode($data); 185 | } 186 | 187 | /** 188 | * Get the response URL from the payload, if present. 189 | * 190 | * Is present for anything that has a conversation context. 191 | * 192 | * @return string|null 193 | */ 194 | public function getResponseUrl(): ?string 195 | { 196 | $responseUrl = $this->getOneOf(['response_url', 'response_urls.0.response_url']) 197 | ?? $this->getMetadata()->get('response_url'); 198 | 199 | return $responseUrl === null ? null : (string) $responseUrl; 200 | } 201 | 202 | /** 203 | * Gets indentifying information about the payload for the purposes of logging/debugging. 204 | * 205 | * @return array 206 | */ 207 | public function getSummary(): array 208 | { 209 | return [ 210 | 'payload_type' => $this->getType()->value(), 211 | 'payload_id_field' => $this->getType()->idField(), 212 | 'payload_id_value' => $this->getTypeId(), 213 | ]; 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/Contexts/PayloadType.php: -------------------------------------------------------------------------------- 1 | */ 10 | private static array $instances = [ 11 | 'app_rate_limited' => null, 12 | 'block_actions' => null, 13 | 'block_suggestion' => null, 14 | 'command' => null, 15 | 'event_callback' => null, 16 | 'interactive_message' => null, 17 | 'message_action' => null, 18 | 'shortcut' => null, 19 | 'unknown' => null, 20 | 'url_verification' => null, 21 | 'view_closed' => null, 22 | 'view_submission' => null, 23 | 'workflow_step_edit' => null, 24 | ]; 25 | 26 | /** @var array */ 27 | private static array $idFields = [ 28 | 'block_actions' => 'actions.0.action_id', 29 | 'block_suggestion' => 'action_id', 30 | 'command' => 'command', 31 | 'event_callback' => 'event.type', 32 | 'message_action' => 'callback_id', 33 | 'shortcut' => 'callback_id', 34 | 'view_closed' => 'view.callback_id', 35 | 'view_submission' => 'view.callback_id', 36 | 'workflow_step_edit' => 'callback_id', 37 | ]; 38 | 39 | private string $value; 40 | 41 | /** 42 | * Get the instance of the enum with the provided value. 43 | * 44 | * @param string $value 45 | * @return self 46 | */ 47 | public static function withValue(string $value): self 48 | { 49 | if (!array_key_exists($value, self::$instances)) { 50 | $value = 'unknown'; 51 | } 52 | 53 | if (!isset(self::$instances[$value])) { 54 | self::$instances[$value] = new self($value); 55 | } 56 | 57 | return self::$instances[$value]; 58 | } 59 | 60 | public static function appRateLimited(): self 61 | { 62 | return self::withValue('app_rate_limited'); 63 | } 64 | 65 | public static function blockActions(): self 66 | { 67 | return self::withValue('block_actions'); 68 | } 69 | 70 | public static function blockSuggestion(): self 71 | { 72 | return self::withValue('block_suggestion'); 73 | } 74 | 75 | public static function command(): self 76 | { 77 | return self::withValue('command'); 78 | } 79 | 80 | public static function eventCallback(): self 81 | { 82 | return self::withValue('event_callback'); 83 | } 84 | 85 | public static function interactiveMessage(): self 86 | { 87 | return self::withValue('interactive_message'); 88 | } 89 | 90 | public static function messageAction(): self 91 | { 92 | return self::withValue('message_action'); 93 | } 94 | 95 | public static function shortcut(): self 96 | { 97 | return self::withValue('shortcut'); 98 | } 99 | 100 | public static function unknown(): self 101 | { 102 | return self::withValue('unknown'); 103 | } 104 | 105 | public static function urlVerification(): self 106 | { 107 | return self::withValue('url_verification'); 108 | } 109 | 110 | public static function viewClosed(): self 111 | { 112 | return self::withValue('view_closed'); 113 | } 114 | 115 | public static function viewSubmission(): self 116 | { 117 | return self::withValue('view_submission'); 118 | } 119 | 120 | public static function workflowStepEdit(): self 121 | { 122 | return self::withValue('workflow_step_edit'); 123 | } 124 | 125 | private function __construct($value) 126 | { 127 | $this->value = $value; 128 | } 129 | 130 | public function __toString(): string 131 | { 132 | return $this->value; 133 | } 134 | 135 | public function value(): ?string 136 | { 137 | return $this->value; 138 | } 139 | 140 | public function idField(): ?string 141 | { 142 | return self::$idFields[$this->value] ?? null; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/Contexts/PrivateMetadata.php: -------------------------------------------------------------------------------- 1 | data[$offset]); 29 | } 30 | 31 | public function offsetGet($offset) 32 | { 33 | return $this->data[$offset] ?? null; 34 | } 35 | 36 | public function offsetSet($offset, $value) 37 | { 38 | $this->data[$offset] = $value; 39 | } 40 | 41 | public function offsetUnset($offset) 42 | { 43 | unset($this->data[$offset]); 44 | } 45 | 46 | public function __toString(): string 47 | { 48 | return self::encode($this->data); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Contexts/View.php: -------------------------------------------------------------------------------- 1 | context = $context; 17 | } 18 | 19 | public function clear(): void 20 | { 21 | $this->context->ack([ 22 | 'response_action' => 'clear', 23 | ]); 24 | } 25 | 26 | public function close(): void 27 | { 28 | $this->context->ack(); 29 | } 30 | 31 | public function errors(array $errors): void 32 | { 33 | $this->context->ack([ 34 | 'response_action' => 'errors', 35 | 'errors' => $errors, 36 | ]); 37 | } 38 | 39 | /** 40 | * @param Modal|array|string|callable(): Modal $modal 41 | */ 42 | public function push($modal): void 43 | { 44 | $this->context->ack([ 45 | 'response_action' => 'push', 46 | 'view' => Coerce::modal($modal), 47 | ]); 48 | } 49 | 50 | /** 51 | * @param Modal|array|string|callable(): Modal $modal 52 | */ 53 | public function update($modal): void 54 | { 55 | $this->context->ack([ 56 | 'response_action' => 'update', 57 | 'view' => Coerce::modal($modal), 58 | ]); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Deferral/DeferredContextCliServer.php: -------------------------------------------------------------------------------- 1 | args = $args; 28 | 29 | return $this; 30 | } 31 | 32 | /** 33 | * @param callable(string): Context $deserializeCallback 34 | * @return $this 35 | */ 36 | public function withDeserializeCallback(callable $deserializeCallback): self 37 | { 38 | $this->deserializeCallback = Closure::fromCallable($deserializeCallback); 39 | 40 | return $this; 41 | } 42 | 43 | protected function init(): void 44 | { 45 | global $argv; 46 | $this->args = $argv ?? []; 47 | } 48 | 49 | public function start(): void 50 | { 51 | // Process args. 52 | $serializedContext = $this->args[1] ?? ''; 53 | $softExit = ($this->args[2] ?? '') === '--soft-exit'; 54 | 55 | // Run the app. 56 | try { 57 | $this->getLogger()->debug('Started processing of deferred context'); 58 | $context = $this->deserializeContext($serializedContext); 59 | $this->getAppCredentials(); 60 | $this->getApp()->handle($context); 61 | $this->getLogger()->debug('Completed processing of deferred context'); 62 | } catch (Throwable $exception) { 63 | $this->getLogger()->error('Error occurred during processing of deferred context', compact('exception')); 64 | $this->exitCode = 1; 65 | } 66 | 67 | if (!$softExit) { 68 | $this->stop(); 69 | } 70 | } 71 | 72 | /** 73 | * @return never-returns 74 | */ 75 | public function stop(): void 76 | { 77 | exit($this->exitCode); 78 | } 79 | 80 | private function deserializeContext(string $serializedContext): Context 81 | { 82 | $fn = $this->deserializeCallback ?? function (string $serializedContext): Context { 83 | if (strlen($serializedContext) === 0) { 84 | throw new Exception('No context provided'); 85 | } 86 | 87 | $data = json_decode(base64_decode($serializedContext), true); 88 | if (empty($data)) { 89 | throw new Exception('Invalid context data'); 90 | } 91 | 92 | $context = Context::fromArray($data); 93 | if (!($context->isAcknowledged() && $context->isDeferred())) { 94 | throw new Exception('Context was not deferred'); 95 | } 96 | 97 | return $context; 98 | }; 99 | 100 | return $fn($serializedContext); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Deferral/PreAckDeferrer.php: -------------------------------------------------------------------------------- 1 | listener = $listener; 27 | } 28 | 29 | public function defer(Context $context): void 30 | { 31 | // Run the Slack context through the app/listener again, but this time with `isAcknowledged` set to `true`. 32 | $context->logger()->debug('Handling deferred processing before the ack response (synchronously)'); 33 | $this->listener->handle($context); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Deferral/ShellExecDeferrer.php: -------------------------------------------------------------------------------- 1 | script = $script; 32 | $this->dir = $dir; 33 | if (!is_dir($this->dir)) { 34 | throw new Exception('Invalid dir for deferrer script'); 35 | } 36 | 37 | $this->serializeCallback = $serializeCallback ? Closure::fromCallable($serializeCallback) : null; 38 | } 39 | 40 | public function defer(Context $context): void 41 | { 42 | $context->logger()->debug('Deferring processing by running a command with shell_exec in the background'); 43 | $data = escapeshellarg($this->serializeContext($context)); 44 | $command = "cd {$this->dir};nohup {$this->script} {$data} > /dev/null &"; 45 | shell_exec($command); 46 | } 47 | 48 | private function serializeContext(Context $context): string 49 | { 50 | $fn = $this->serializeCallback ?? fn (Context $ctx): string => base64_encode(json_encode($ctx->toArray())); 51 | 52 | return $fn($context); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Deferrer.php: -------------------------------------------------------------------------------- 1 | */ 26 | private static array $instances = []; 27 | 28 | private string $prefix; 29 | 30 | /** 31 | * Returns an instance of Env for the given prefix (default: SLACK) 32 | * 33 | * @param string|null $prefix 34 | * @return static 35 | */ 36 | public static function vars(?string $prefix = null): self 37 | { 38 | $prefix = strtoupper($prefix ?? self::DEFAULT_PREFIX); 39 | 40 | return self::$instances[$prefix] ??= new self($prefix); 41 | } 42 | 43 | public function __construct(string $prefix) 44 | { 45 | $this->prefix = $prefix; 46 | } 47 | 48 | /** 49 | * Gets the "app token" from the environment, which the app uses to establish a connection in Socket Mode. 50 | * 51 | * @return string|null 52 | */ 53 | public function getAppToken(): ?string 54 | { 55 | return $this->get(self::APP_TOKEN); 56 | } 57 | 58 | /** 59 | * Gets the app ID from the environment, which the app uses to identify itself. 60 | * 61 | * @return string|null 62 | */ 63 | public function getAppId(): ?string 64 | { 65 | return $this->get(self::APP_ID); 66 | } 67 | 68 | /** 69 | * Gets the "bot token" from the environment, which the app uses to call Slack APIs for the default workspace. 70 | * 71 | * @return string|null 72 | */ 73 | public function getBotToken(): ?string 74 | { 75 | return $this->get(self::BOT_TOKEN); 76 | } 77 | 78 | /** 79 | * Gets the client ID from the environment, which the app uses in the OAuth flow when installed to a workspace. 80 | * 81 | * @return string|null 82 | */ 83 | public function getClientId(): ?string 84 | { 85 | return $this->get(self::CLIENT_ID); 86 | } 87 | 88 | /** 89 | * Gets the client secret from the environment, which the app uses in the OAuth flow when installed to a workspace. 90 | * 91 | * @return string|null 92 | */ 93 | public function getClientSecret(): ?string 94 | { 95 | return $this->get(self::CLIENT_SECRET); 96 | } 97 | 98 | /** 99 | * Gets the scopes from the environment, which the app uses in the OAuth flow when installed to a workspace. 100 | * 101 | * @return array 102 | */ 103 | public function getScopes(): array 104 | { 105 | $value = $this->get(self::SCOPES); 106 | 107 | return $value ? explode(',', $value) : []; 108 | } 109 | 110 | /** 111 | * Gets the signing key from the environment, which the app uses to validate incoming requests. 112 | * 113 | * @return string|null 114 | */ 115 | public function getSigningKey(): ?string 116 | { 117 | return $this->get(self::SIGNING_KEY); 118 | } 119 | 120 | /** 121 | * Gets the state secret from the environment, which the app uses in the OAuth flow when installed to a workspace. 122 | * 123 | * @return string|null 124 | */ 125 | public function getStateSecret(): ?string 126 | { 127 | return $this->get(self::STATE_SECRET); 128 | } 129 | 130 | /** 131 | * Gets an environment variable value by its name. 132 | * 133 | * @param string $key 134 | * @return string|null 135 | */ 136 | public function get(string $key): ?string 137 | { 138 | $key = "{$this->prefix}_{$key}"; 139 | 140 | return getenv($key, true) ?: getenv($key) ?: null; 141 | } 142 | 143 | /** 144 | * Gets the maximum allowed clock skew from the environment, which the app uses to validate incoming requests. 145 | * 146 | * @return int 147 | */ 148 | public static function getMaxClockSkew(): int 149 | { 150 | $value = self::vars('SLACKPHP')->get(self::MAX_CLOCK_SKEW); 151 | 152 | return $value ? (int) $value : self::FIVE_MINUTES; 153 | } 154 | 155 | /** 156 | * Gets the skip auth flag from the environment, which the app uses to determine whether to bypass authentication. 157 | * 158 | * @return bool 159 | */ 160 | public static function getSkipAuth(): bool 161 | { 162 | return (bool) self::vars('SLACKPHP')->get(self::SKIP_AUTH); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/Exception.php: -------------------------------------------------------------------------------- 1 | context = $context; 17 | parent::__construct($message, $code, $previous); 18 | } 19 | 20 | public function addContext(array $context): self 21 | { 22 | $this->context = $context + $this->context; 23 | 24 | return $this; 25 | } 26 | 27 | public function getContext(): array 28 | { 29 | return $this->context; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Http/AppHandler.php: -------------------------------------------------------------------------------- 1 | app = $app; 25 | $this->deferrer = $deferrer ?? new PreAckDeferrer($app); 26 | } 27 | 28 | public function handle(ServerRequestInterface $request): ResponseInterface 29 | { 30 | // Prepare the app context for the listener(s). 31 | $context = Util::createContextFromRequest($request); 32 | 33 | // Delegate to the listener(s) for handling the app context. 34 | $this->app->handle($context); 35 | if ($context->isDeferred()) { 36 | $this->deferrer->defer($context); 37 | } 38 | 39 | return $this->createResponseFromContext($context); 40 | } 41 | 42 | public function createResponseFromContext(Context $context): ResponseInterface 43 | { 44 | if (!$context->isAcknowledged()) { 45 | throw new HttpException('No ack provided by the app'); 46 | } 47 | 48 | $ack = $context->getAck(); 49 | if ($ack === null) { 50 | return new Response(200); 51 | } 52 | 53 | return new Response(200, [ 54 | 'Content-Type' => 'application/json', 55 | 'Content-Length' => strlen($ack), 56 | ], $ack); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Http/AuthMiddleware.php: -------------------------------------------------------------------------------- 1 | appCredentials = $appCredentials; 22 | } 23 | 24 | /** 25 | * Authenticates the incoming request from Slack by validating the signature. 26 | * 27 | * Currently, there is only one implementation: v0. In the future, there could be multiple. The Signature version 28 | * is included in the signature header, which is formatted like this: `X-Slack-Signature: {version}={signature}` 29 | * 30 | * @param ServerRequestInterface $request 31 | * @param RequestHandlerInterface $handler 32 | * @return ResponseInterface 33 | */ 34 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 35 | { 36 | // Authentication can be disabled via env var `SLACKPHP_SKIP_AUTH=1` (for testing purposes only). 37 | if (Env::getSkipAuth()) { 38 | return $handler->handle($request); 39 | } 40 | 41 | // Ensure the necessary credentials have been supplied. 42 | if (!$this->appCredentials->supportsHttpAuth()) { 43 | throw new AuthException('No signing key provided', 401); 44 | } 45 | 46 | // Validate the signature. 47 | $this->getAuthContext($request)->validate($this->appCredentials->getSigningKey()); 48 | 49 | return $handler->handle($request); 50 | } 51 | 52 | /** 53 | * Creates an authentication context in which a signature can be validated for the request. 54 | * 55 | * @param ServerRequestInterface $request 56 | * @return AuthContext 57 | */ 58 | private function getAuthContext(ServerRequestInterface $request): AuthContext 59 | { 60 | if (!$request->hasHeader(self::HEADER_TIMESTAMP) || !$request->hasHeader(self::HEADER_SIGNATURE)) { 61 | throw new AuthException('Missing required headers for authentication'); 62 | } 63 | 64 | return new AuthContext( 65 | $request->getHeaderLine(self::HEADER_SIGNATURE), 66 | (int) $request->getHeaderLine(self::HEADER_TIMESTAMP), 67 | Util::readRequestBody($request), 68 | Env::getMaxClockSkew() 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Http/EchoResponseEmitter.php: -------------------------------------------------------------------------------- 1 | assertNoPreviousOutput(); 37 | $this->emitHeaders($response); 38 | $this->emitStatusLine($response); 39 | echo $response->getBody(); 40 | } 41 | 42 | /** 43 | * Checks to see if content has previously been sent. 44 | * 45 | * If either headers have been sent or the output buffer contains content, 46 | * raises an exception. 47 | * 48 | * @throws HttpException if headers have already been sent. 49 | * @throws HttpException if output is present in the output buffer. 50 | */ 51 | private function assertNoPreviousOutput() 52 | { 53 | if (headers_sent()) { 54 | throw new HttpException('HTTP Error: Headers already sent'); 55 | } 56 | 57 | if (ob_get_level() > 0 && ob_get_length() > 0) { 58 | throw new HttpException('HTTP Error: Output buffer is not empty'); 59 | } 60 | } 61 | 62 | /** 63 | * Emit the status line. 64 | * 65 | * Emits the status line using the protocol version and status code from 66 | * the response; if a reason phrase is available, it, too, is emitted. 67 | * 68 | * It is important to mention that this method should be called after 69 | * `emitHeaders()` in order to prevent PHP from changing the status code of 70 | * the emitted response. 71 | * 72 | * @param ResponseInterface $response 73 | */ 74 | private function emitStatusLine(ResponseInterface $response): void 75 | { 76 | $reasonPhrase = $response->getReasonPhrase(); 77 | $statusCode = $response->getStatusCode(); 78 | 79 | header(sprintf( 80 | 'HTTP/%s %d%s', 81 | $response->getProtocolVersion(), 82 | $statusCode, 83 | ($reasonPhrase ? ' ' . $reasonPhrase : '') 84 | ), true, $statusCode); 85 | } 86 | 87 | /** 88 | * Emit response headers. 89 | * 90 | * Loops through each header, emitting each; if the header value 91 | * is an array with multiple values, ensures that each is sent 92 | * in such a way as to create aggregate headers (instead of replace 93 | * the previous). 94 | * 95 | * @param ResponseInterface $response 96 | */ 97 | private function emitHeaders(ResponseInterface $response): void 98 | { 99 | $statusCode = $response->getStatusCode(); 100 | 101 | foreach ($response->getHeaders() as $header => $values) { 102 | $name = ucwords($header, '-'); 103 | $first = $name === 'Set-Cookie' ? false : true; 104 | foreach ($values as $value) { 105 | header(sprintf( 106 | '%s: %s', 107 | $name, 108 | $value 109 | ), $first, $statusCode); 110 | $first = false; 111 | } 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Http/HttpException.php: -------------------------------------------------------------------------------- 1 | deferrer = $deferrer; 28 | 29 | return $this; 30 | } 31 | 32 | /** 33 | * @param ServerRequestInterface $request 34 | * @return $this 35 | */ 36 | public function withRequest(ServerRequestInterface $request): self 37 | { 38 | $this->request = $request; 39 | 40 | return $this; 41 | } 42 | 43 | /** 44 | * @param ResponseEmitter $emitter 45 | * @return $this 46 | */ 47 | public function withResponseEmitter(ResponseEmitter $emitter): self 48 | { 49 | $this->emitter = $emitter; 50 | 51 | return $this; 52 | } 53 | 54 | /** 55 | * Starts receiving and processing requests from Slack. 56 | */ 57 | public function start(): void 58 | { 59 | try { 60 | $request = $this->getRequest(); 61 | $response = $this->getHandler()->handle($request); 62 | } catch (Throwable $exception) { 63 | $response = new Response($exception->getCode() ?: 500); 64 | $this->getLogger()->error('Error responding to incoming Slack request', compact('exception')); 65 | } 66 | 67 | $this->emitResponse($response); 68 | } 69 | 70 | /** 71 | * Gets a representation of the request data from super globals. 72 | * 73 | * @return ServerRequestInterface 74 | */ 75 | protected function getRequest(): ServerRequestInterface 76 | { 77 | if (!isset($this->request)) { 78 | try { 79 | $httpFactory = new Psr17Factory(); 80 | $requestFactory = new ServerRequestCreator($httpFactory, $httpFactory, $httpFactory, $httpFactory); 81 | $this->request = $requestFactory->fromGlobals(); 82 | } catch (Throwable $ex) { 83 | throw new HttpException('Invalid Slack request', 400, $ex); 84 | } 85 | } 86 | 87 | return $this->request; 88 | } 89 | 90 | protected function emitResponse(ResponseInterface $response): void 91 | { 92 | $emitter = $this->emitter ?? new EchoResponseEmitter(); 93 | $emitter->emit($response); 94 | } 95 | 96 | /** 97 | * Gets a request handler for the Slack app. 98 | * 99 | * @return HandlerInterface 100 | */ 101 | protected function getHandler(): HandlerInterface 102 | { 103 | $handler = new AppHandler($this->getApp(), $this->deferrer ?? null); 104 | 105 | return Util::applyMiddleware($handler, [new AuthMiddleware($this->getAppCredentials())]); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Http/MultiTenantHttpServer.php: -------------------------------------------------------------------------------- 1 | */ 16 | private array $apps = []; 17 | private ?Closure $appIdDetector; 18 | 19 | /** 20 | * Register an app by app ID to be routed to. 21 | * 22 | * @param string $appId 23 | * @param string|callable(): Application $appFactory App class name, include file, or factory callback. 24 | * @return $this 25 | */ 26 | public function registerApp(string $appId, $appFactory): self 27 | { 28 | $this->apps[$appId] = $appFactory; 29 | 30 | return $this; 31 | } 32 | 33 | /** 34 | * @param callable(ServerRequestInterface $request): ?string $appIdDetector 35 | * @return $this 36 | */ 37 | public function withAppIdDetector(callable $appIdDetector): self 38 | { 39 | $this->appIdDetector = $appIdDetector instanceof Closure 40 | ? $appIdDetector 41 | : Closure::fromCallable($appIdDetector); 42 | 43 | return $this; 44 | } 45 | 46 | protected function getApp(): Application 47 | { 48 | // Get the app ID from the request. 49 | $appId = $this->getAppIdDetector()($this->getRequest()); 50 | if ($appId === null) { 51 | throw new HttpException('Cannot determine app ID'); 52 | } 53 | 54 | // Create the app for the app ID. 55 | $app = $this->instantiateApp($appId); 56 | 57 | // Reconcile the registered app ID with the App's configured ID. 58 | $configuredId = $app->getConfig()->getId(); 59 | if ($configuredId === null) { 60 | $app->getConfig()->withId($appId); 61 | } elseif ($configuredId !== $appId) { 62 | throw new HttpException("ID mismatch for app ID: {$appId}"); 63 | } 64 | 65 | // Set the App to the Server. 66 | $this->withApp($app); 67 | 68 | return parent::getApp(); 69 | } 70 | 71 | /** 72 | * @param string $appId ID for the application 73 | * @return Application 74 | * @noinspection PhpIncludeInspection 75 | */ 76 | private function instantiateApp(string $appId): Application 77 | { 78 | // Create the app from its configured factory, and make sure it's valid. 79 | $factory = $this->apps[$appId] ?? null; 80 | if (is_null($factory)) { 81 | throw new HttpException("No app registered for app ID: {$appId}"); 82 | } elseif (is_string($factory) && class_exists($factory)) { 83 | $app = new $factory(); 84 | } elseif (is_string($factory) && is_file($factory)) { 85 | $app = require $factory; 86 | } elseif (is_callable($factory)) { 87 | $app = $factory(); 88 | } else { 89 | throw new HttpException("Invalid application for app ID: {$appId}"); 90 | } 91 | 92 | return Coerce::application($app); 93 | } 94 | 95 | private function getAppIdDetector(): Closure 96 | { 97 | return $this->appIdDetector ?? function (ServerRequestInterface $request): ?string { 98 | return $request->getQueryParams()[self::APP_ID_KEY] ?? null; 99 | }; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Http/ResponseEmitter.php: -------------------------------------------------------------------------------- 1 | handler = $handler; 41 | $this->middleware = $middleware; 42 | } 43 | 44 | public function handle(ServerRequestInterface $request): ResponseInterface 45 | { 46 | return $this->middleware->process($request, $this->handler); 47 | } 48 | }; 49 | } 50 | 51 | return $handler; 52 | } 53 | 54 | /** 55 | * @param ServerRequestInterface $request 56 | * @return Context 57 | * @throws HttpException if context cannot be created. 58 | */ 59 | public static function createContextFromRequest(ServerRequestInterface $request): Context 60 | { 61 | $payload = Util::parseRequestBody($request); 62 | 63 | // Create the context with data from the HTTP request. 64 | return new Context($payload, $request->getAttributes() + [ 65 | 'timestamp' => (int) $request->getHeaderLine('X-Slack-Request-Timestamp'), 66 | 'http' => [ 67 | 'query' => $request->getQueryParams(), 68 | 'headers' => array_filter( 69 | $request->getHeaders(), 70 | fn (string $key) => strpos($key, 'X-Slack') === 0, 71 | ARRAY_FILTER_USE_KEY 72 | ), 73 | ], 74 | ]); 75 | } 76 | 77 | /** 78 | * @param ServerRequestInterface $request 79 | * @return Payload 80 | * @throws HttpException if payload cannot be parsed. 81 | */ 82 | public static function parseRequestBody(ServerRequestInterface $request): Payload 83 | { 84 | try { 85 | $body = self::readRequestBody($request); 86 | $contentType = $request->getHeaderLine('Content-Type'); 87 | return Payload::fromHttpRequest($body, $contentType); 88 | } catch (JsonException $ex) { 89 | throw new HttpException('Could not parse json in request body', 400, $ex); 90 | } catch (Throwable $ex) { 91 | throw new HttpException('Could not parse payload from request body', 400, $ex); 92 | } 93 | } 94 | 95 | /** 96 | * @param ServerRequestInterface $request 97 | * @return string 98 | */ 99 | public static function readRequestBody(ServerRequestInterface $request): string 100 | { 101 | if ($request->getMethod() !== 'POST') { 102 | throw new HttpException("Request method \"{$request->getMethod()}\" not allowed", 405); 103 | } 104 | 105 | $body = $request->getBody(); 106 | $bodyContent = (string) $body; 107 | $body->rewind(); 108 | 109 | if (strlen($bodyContent) === 0) { 110 | throw new HttpException('Request body is empty', 400); 111 | } 112 | 113 | return $bodyContent; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Interceptor.php: -------------------------------------------------------------------------------- 1 | addMultiple($interceptors); 26 | } 27 | 28 | public function add(Interceptor $interceptor, bool $prepend = false): self 29 | { 30 | if ($interceptor instanceof self) { 31 | return $this->addMultiple($interceptor->interceptors, $prepend); 32 | } 33 | 34 | if ($prepend) { 35 | array_unshift($this->interceptors, $interceptor); 36 | } else { 37 | $this->interceptors[] = $interceptor; 38 | } 39 | 40 | return $this; 41 | } 42 | 43 | public function addMultiple(array $interceptors, bool $prepend = false): self 44 | { 45 | foreach ($interceptors as $interceptor) { 46 | $this->add($interceptor, $prepend); 47 | } 48 | 49 | return $this; 50 | } 51 | 52 | public function intercept(Context $context, Listener $listener): void 53 | { 54 | $interceptors = $this->interceptors; 55 | while ($interceptor = array_pop($interceptors)) { 56 | $listener = new Intercepted($interceptor, $listener); 57 | } 58 | 59 | $listener->handle($context); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Interceptors/Filter.php: -------------------------------------------------------------------------------- 1 | defaultListener = $defaultListener ? Coerce::listener($defaultListener) : new Undefined(); 20 | } 21 | 22 | public function intercept(Context $context, Listener $listener): void 23 | { 24 | $matched = $this->matches($context); 25 | $context->logger()->addContext(['filter:' . static::class => $matched ? 'match' : 'not-match']); 26 | 27 | if (!$matched) { 28 | $listener = $this->defaultListener; 29 | } 30 | 31 | $listener->handle($context); 32 | } 33 | 34 | /** 35 | * @param Context $context 36 | * @return bool 37 | */ 38 | abstract public function matches(Context $context): bool; 39 | } 40 | -------------------------------------------------------------------------------- /src/Interceptors/Filters/CallbackFilter.php: -------------------------------------------------------------------------------- 1 | filterFn = $filterFn instanceof Closure ? $filterFn : Closure::fromCallable($filterFn); 23 | parent::__construct($defaultListener); 24 | } 25 | 26 | public function matches(Context $context): bool 27 | { 28 | return ($this->filterFn)($context); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Interceptors/Filters/FieldFilter.php: -------------------------------------------------------------------------------- 1 | */ 13 | private $fields; 14 | 15 | /** 16 | * @param array $fields 17 | * @param Listener|callable|class-string|null $defaultListener 18 | */ 19 | public function __construct(array $fields, $defaultListener = null) 20 | { 21 | parent::__construct($defaultListener); 22 | $this->fields = $fields; 23 | } 24 | 25 | public function matches(Context $context): bool 26 | { 27 | foreach ($this->fields as $field => $value) { 28 | $matched = substr($value, 0, 6) === 'regex:' 29 | ? $this->matchRegex($context, $field, substr($value, 6)) 30 | : $this->matchValue($context, $field, $value); 31 | 32 | if (!$matched) { 33 | return false; 34 | } 35 | } 36 | 37 | return true; 38 | } 39 | 40 | private function matchValue(Context $context, string $field, string $value): bool 41 | { 42 | $result = true; 43 | if (substr($value, 0, 4) === 'not:') { 44 | $result = false; 45 | $value = substr($value, 4); 46 | } 47 | 48 | return ($context->payload()->get($field) === $value) === $result; 49 | } 50 | 51 | private function matchRegex(Context $context, string $field, string $regex): bool 52 | { 53 | if (preg_match($regex, $context->payload()->get($field), $matches)) { 54 | $allMatches = $context->get('regex') ?? []; 55 | $allMatches[$field] = $matches; 56 | $context->set('regex', $allMatches); 57 | 58 | return true; 59 | } 60 | 61 | return false; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Interceptors/Lazy.php: -------------------------------------------------------------------------------- 1 | callback = $callback instanceof Closure ? $callback : Closure::fromCallable($callback); 23 | } 24 | 25 | public function intercept(Context $context, Listener $listener): void 26 | { 27 | /** @var Interceptor $interceptor */ 28 | $interceptor = ($this->callback)(); 29 | $interceptor->intercept($context, $listener); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Interceptors/Tap.php: -------------------------------------------------------------------------------- 1 | callback = $callback instanceof Closure ? $callback : Closure::fromCallable($callback); 20 | } 21 | 22 | public function intercept(Context $context, Listener $listener): void 23 | { 24 | ($this->callback)($context); 25 | $listener->handle($context); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Interceptors/UrlVerification.php: -------------------------------------------------------------------------------- 1 | payload(); 15 | if ($payload->isType(PayloadType::urlVerification())) { 16 | $challenge = (string) $payload->get('challenge', true); 17 | $context->ack(compact('challenge')); 18 | } else { 19 | $listener->handle($context); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Listener.php: -------------------------------------------------------------------------------- 1 | message = $message ? Coerce::message($message) : null; 25 | } 26 | 27 | public function handle(Context $context): void 28 | { 29 | $context->ack($this->message); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Listeners/Async.php: -------------------------------------------------------------------------------- 1 | asyncListener = $asyncListener; 27 | $this->syncListener = $syncListener ?? new Ack(); 28 | } 29 | 30 | protected function handleAck(Context $context): void 31 | { 32 | $this->syncListener->handle($context); 33 | } 34 | 35 | protected function handleAfterAck(Context $context): void 36 | { 37 | $this->asyncListener->handle($context); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Listeners/Base.php: -------------------------------------------------------------------------------- 1 | defer(false);`. 14 | */ 15 | abstract class Base implements Listener 16 | { 17 | public function handle(Context $context): void 18 | { 19 | // Handle async logic, if executed post-ack. 20 | if ($context->isAcknowledged()) { 21 | $this->handleAfterAck($context); 22 | return; 23 | } 24 | 25 | // Handle sync logic, if executed pre-ack. 26 | $context->defer(true); 27 | $this->handleAck($context); 28 | if (!$context->isAcknowledged()) { 29 | $context->ack(); 30 | } 31 | } 32 | 33 | /** 34 | * Handles application logic that must be preformed prior to the "ack" and Slack's 3-second timeout. 35 | * 36 | * By default, this does nothing. You should override this method with your own implementation. 37 | * 38 | * @param Context $context 39 | */ 40 | protected function handleAck(Context $context): void 41 | { 42 | // No-op. Override as needed. 43 | } 44 | 45 | /** 46 | * Handles application logic that can or must happen after the "ack" and is not subject to Slack's 3-second timeout. 47 | * 48 | * By default, this does nothing. You should override this method with your own implementation. 49 | * 50 | * @param Context $context 51 | */ 52 | protected function handleAfterAck(Context $context): void 53 | { 54 | // No-op. Override as needed. 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Listeners/Callback.php: -------------------------------------------------------------------------------- 1 | handle($context); 41 | }); 42 | } 43 | 44 | /** 45 | * @param callable(Context): void $callback Callback to be used as the Listener. 46 | */ 47 | public function __construct(callable $callback) 48 | { 49 | $this->callback = $callback instanceof Closure ? $callback : Closure::fromCallable($callback); 50 | } 51 | 52 | public function handle(Context $context): void 53 | { 54 | ($this->callback)($context); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Listeners/ClassResolver.php: -------------------------------------------------------------------------------- 1 | class = $class; 20 | } 21 | 22 | public function handle(Context $context): void 23 | { 24 | try { 25 | $listener = $context->container()->get($this->class); 26 | } catch (Throwable $ex) { 27 | throw new Exception('Could not resolve class name to Listener', 0, $ex); 28 | } 29 | 30 | if (!$listener instanceof Listener) { 31 | throw new Exception('Resolved class name to a non-Listener'); 32 | } 33 | 34 | $context->logger()->addContext(['listener' => $this->class]); 35 | 36 | $listener->handle($context); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Listeners/FieldSwitch.php: -------------------------------------------------------------------------------- 1 | */ 13 | private array $cases; 14 | 15 | private ?Listener $default; 16 | private string $field; 17 | 18 | public function __construct(string $field, array $cases, $default = null) 19 | { 20 | $default ??= $cases['*'] ?? null; 21 | if ($default !== null) { 22 | $this->default = Coerce::listener($default); 23 | unset($cases['*']); 24 | } 25 | 26 | $this->field = $field; 27 | $this->cases = array_map(Closure::fromCallable([Coerce::class, 'listener']), $cases); 28 | } 29 | 30 | public function handle(Context $context): void 31 | { 32 | $value = $context->payload()->get($this->field); 33 | $listener = $this->cases[$value] ?? $this->default ?? new Undefined(); 34 | $listener->handle($context); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Listeners/Intercepted.php: -------------------------------------------------------------------------------- 1 | interceptor = $interceptor; 21 | $this->listener = $listener; 22 | } 23 | 24 | public function handle(Context $context): void 25 | { 26 | $this->interceptor->intercept($context, $this->listener); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Listeners/Undefined.php: -------------------------------------------------------------------------------- 1 | logger()->error('No listener matching payload'); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Listeners/WIP.php: -------------------------------------------------------------------------------- 1 | getAppConfig()->getAppCredentials()->supportsApiAuth(); 18 | $data = $context->payload(); 19 | 20 | $message = 'Work in progress'; 21 | if ($data->isType(PayloadType::viewSubmission())) { 22 | $context->view()->push($message); 23 | } elseif ($data->getResponseUrl()) { 24 | $context->respond($message); 25 | } elseif ($hasApi && $data->isType(PayloadType::eventCallback()) && $data->getTypeId() === 'app_home_opened') { 26 | $context->appHome()->update($message); 27 | } elseif ($hasApi && $data->get('trigger_id')) { 28 | // If a modal is already open, push a new one on the stack, otherwise, open a new stack. 29 | if ($data->get('view.type') === 'modal') { 30 | $context->modals()->push($message); 31 | } else { 32 | $context->modals()->open($message); 33 | } 34 | } else { 35 | $context->logger()->debug($message); 36 | } 37 | 38 | if (!$context->isAcknowledged()) { 39 | $context->ack(); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Route.php: -------------------------------------------------------------------------------- 1 | |callable $filter 28 | * @param Listener|callable(Context): void|class-string $listener 29 | * @return Listener 30 | */ 31 | public static function filter($filter, $listener): Listener 32 | { 33 | if (is_callable($filter)) { 34 | $interceptor = new Interceptors\Filters\CallbackFilter($filter); 35 | } elseif (is_array($filter)) { 36 | $interceptor = new Interceptors\Filters\FieldFilter($filter); 37 | } else { 38 | throw new Exception('Invalid listener filter'); 39 | } 40 | 41 | return new Listeners\Intercepted($interceptor, Coerce::listener($listener)); 42 | } 43 | 44 | /** 45 | * @param Interceptor|callable(): Interceptor|array $interceptor 46 | * @param Listener|callable(Context): void|class-string $listener 47 | * @return Listener 48 | */ 49 | public static function intercept($interceptor, $listener): Listener 50 | { 51 | return new Listeners\Intercepted(Coerce::interceptor($interceptor), Coerce::listener($listener)); 52 | } 53 | 54 | /** 55 | * @param string $field 56 | * @param array $listeners 57 | * @return Listener 58 | */ 59 | public static function switch(string $field, array $listeners): Listener 60 | { 61 | return new Listeners\FieldSwitch($field, $listeners); 62 | } 63 | 64 | /** 65 | * @param callable(Context): void $callback 66 | * @param Listener|callable(Context): void|class-string $listener 67 | * @return Listener 68 | */ 69 | public static function tap(callable $callback, $listener): Listener 70 | { 71 | return new Listeners\Intercepted(new Interceptors\Tap($callback), Coerce::listener($listener)); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Router.php: -------------------------------------------------------------------------------- 1 | > */ 18 | private array $listeners; 19 | 20 | private ?Listener $commandAck = null; 21 | private Interceptors\Chain $interceptors; 22 | private bool $urlVerificationAdded = false; 23 | 24 | /** 25 | * @return static 26 | */ 27 | public static function new(): self 28 | { 29 | return new static(); 30 | } 31 | 32 | final public function __construct() 33 | { 34 | $this->listeners = []; 35 | $this->interceptors = Interceptors\Chain::new(); 36 | } 37 | 38 | /** 39 | * Sets an "ack" message used for async commands to inform the user to wait for the result (e.g., "processing..."). 40 | * 41 | * @param JsonSerializable|array|string $ack 42 | * @return $this 43 | */ 44 | public function withCommandAck($ack): self 45 | { 46 | $this->commandAck = new Listeners\Ack($ack); 47 | 48 | return $this; 49 | } 50 | 51 | /** 52 | * Enables an interceptor to handle incoming "url_verification" requests and respond with the "challenge" value. 53 | * 54 | * @return $this 55 | */ 56 | public function withUrlVerification(): self 57 | { 58 | if (!$this->urlVerificationAdded) { 59 | $this->interceptors->add(new Interceptors\UrlVerification(), true); 60 | $this->urlVerificationAdded = true; 61 | } 62 | 63 | return $this; 64 | } 65 | 66 | /** 67 | * Configures a listener for an incoming "command" request. 68 | * 69 | * @param string $name 70 | * @param Listener|callable(Context): void|class-string $listener 71 | * @return $this 72 | */ 73 | public function command(string $name, $listener): self 74 | { 75 | return $this->register(PayloadType::command(), $name, $listener); 76 | } 77 | 78 | /** 79 | * Configures an async listener for an incoming "command" request. 80 | * 81 | * @param string $name 82 | * @param Listener|callable(Context): void|class-string $listener 83 | * @return $this 84 | */ 85 | public function commandAsync(string $name, $listener): self 86 | { 87 | return $this->command($name, Route::async($listener, $this->commandAck)); 88 | } 89 | 90 | /** 91 | * Configures listeners for an incoming "command" request, based on sub-commands in the text. 92 | * 93 | * @param string $name 94 | * @param array $subCommands 95 | * @return $this 96 | */ 97 | public function commandGroup(string $name, array $subCommands): self 98 | { 99 | return $this->register(PayloadType::command(), $name, new Commands\CommandRouter($subCommands)); 100 | } 101 | 102 | /** 103 | * Configures async listeners for an incoming "command" request, based on sub-commands in the text. 104 | * 105 | * @param string $name 106 | * @param array $subCommands 107 | * @return $this 108 | */ 109 | public function commandGroupAsync(string $name, array $subCommands): self 110 | { 111 | return $this->register(PayloadType::command(), $name, Route::async( 112 | new Commands\CommandRouter($subCommands), 113 | $this->commandAck 114 | )); 115 | } 116 | 117 | /** 118 | * Configures a listener for an incoming "event" request. 119 | * 120 | * @param string $name 121 | * @param Listener|callable(Context): void|class-string $listener 122 | * @return $this 123 | */ 124 | public function event(string $name, $listener): self 125 | { 126 | return $this->withUrlVerification()->register(PayloadType::eventCallback(), $name, $listener); 127 | } 128 | 129 | /** 130 | * Configures an async listener for an incoming "event" request. 131 | * 132 | * @param string $name 133 | * @param Listener|callable(Context): void|class-string $listener 134 | * @return $this 135 | */ 136 | public function eventAsync(string $name, $listener): self 137 | { 138 | return $this->event($name, Route::async($listener)); 139 | } 140 | 141 | /** 142 | * Configures a listener for an incoming (global) "shortcut" request. 143 | * 144 | * @param string $callbackId 145 | * @param Listener|callable(Context): void|class-string $listener 146 | * @return $this 147 | */ 148 | public function globalShortcut(string $callbackId, $listener): self 149 | { 150 | return $this->register(PayloadType::shortcut(), $callbackId, $listener); 151 | } 152 | 153 | /** 154 | * Configures an async listener for an incoming (global) "shortcut" request. 155 | * 156 | * @param string $callbackId 157 | * @param Listener|callable(Context): void|class-string $listener 158 | * @return $this 159 | */ 160 | public function globalShortcutAsync(string $callbackId, $listener): self 161 | { 162 | return $this->globalShortcut($callbackId, Route::async($listener)); 163 | } 164 | 165 | /** 166 | * Configures a listener for an incoming "message_action" (aka message shortcut) request. 167 | * 168 | * @param string $callbackId 169 | * @param Listener|callable(Context): void|class-string $listener 170 | * @return $this 171 | */ 172 | public function messageShortcut(string $callbackId, $listener): self 173 | { 174 | return $this->register(PayloadType::messageAction(), $callbackId, $listener); 175 | } 176 | 177 | /** 178 | * Configures an async listener for an incoming "message_action" (aka message shortcut) request. 179 | * 180 | * @param string $callbackId 181 | * @param Listener|callable(Context): void|class-string $listener 182 | * @return $this 183 | */ 184 | public function messageShortcutAsync(string $callbackId, $listener): self 185 | { 186 | return $this->messageShortcut($callbackId, Route::async($listener)); 187 | } 188 | 189 | /** 190 | * Configures a listener for an incoming "block_actions" request. 191 | * 192 | * @param string $actionId 193 | * @param Listener|callable(Context): void|class-string $listener 194 | * @return $this 195 | */ 196 | public function blockAction(string $actionId, $listener): self 197 | { 198 | return $this->register(PayloadType::blockActions(), $actionId, $listener); 199 | } 200 | 201 | /** 202 | * Configures an async listener for an incoming "block_actions" request. 203 | * 204 | * @param string $actionId 205 | * @param Listener|callable(Context): void|class-string $listener 206 | * @return $this 207 | */ 208 | public function blockActionAsync(string $actionId, $listener): self 209 | { 210 | return $this->blockAction($actionId, Route::async($listener)); 211 | } 212 | 213 | /** 214 | * Configures a listener for an incoming "block_suggestion" request. 215 | * 216 | * @param string $actionId 217 | * @param Listener|callable(Context): void|class-string $listener 218 | * @return $this 219 | */ 220 | public function blockSuggestion(string $actionId, $listener): self 221 | { 222 | return $this->register(PayloadType::blockSuggestion(), $actionId, $listener); 223 | } 224 | 225 | /** 226 | * Configures a listener for an incoming "view_submission" request. 227 | * 228 | * @param string $callbackId 229 | * @param Listener|callable(Context): void|class-string $listener 230 | * @return $this 231 | */ 232 | public function viewSubmission(string $callbackId, $listener): self 233 | { 234 | return $this->register(PayloadType::viewSubmission(), $callbackId, $listener); 235 | } 236 | 237 | /** 238 | * Configures an async listener for an incoming "view_submission" request. 239 | * 240 | * @param string $callbackId 241 | * @param Listener|callable(Context): void|class-string $listener 242 | * @return $this 243 | */ 244 | public function viewSubmissionAsync(string $callbackId, $listener): self 245 | { 246 | return $this->viewSubmission($callbackId, Route::async($listener)); 247 | } 248 | 249 | /** 250 | * Configures a listener for an incoming "view_closed" request. 251 | * 252 | * @param string $callbackId 253 | * @param Listener|callable(Context): void|class-string $listener 254 | * @return $this 255 | */ 256 | public function viewClosed(string $callbackId, $listener): self 257 | { 258 | return $this->register(PayloadType::viewClosed(), $callbackId, $listener); 259 | } 260 | 261 | /** 262 | * Configures an async listener for an incoming "view_closed" request. 263 | * 264 | * @param string $callbackId 265 | * @param Listener|callable(Context): void|class-string $listener 266 | * @return $this 267 | */ 268 | public function viewClosedAsync(string $callbackId, $listener): self 269 | { 270 | return $this->viewClosed($callbackId, Route::async($listener)); 271 | } 272 | 273 | /** 274 | * Configures a listener for an incoming "workflow_step_edit" request. 275 | * 276 | * @param string $callbackId 277 | * @param Listener|callable(Context): void|class-string $listener 278 | * @return $this 279 | */ 280 | public function workflowStepEdit(string $callbackId, $listener): self 281 | { 282 | return $this->register(PayloadType::workflowStepEdit(), $callbackId, $listener); 283 | } 284 | 285 | /** 286 | * Configures an async listener for an incoming "workflow_step_edit" request. 287 | * 288 | * @param string $callbackId 289 | * @param Listener|callable(Context): void|class-string $listener 290 | * @return $this 291 | */ 292 | public function workflowStepEditAsync(string $callbackId, $listener): self 293 | { 294 | return $this->workflowStepEdit($callbackId, Route::async($listener)); 295 | } 296 | 297 | /** 298 | * Configures a listener for an incoming request of the specified type. 299 | * 300 | * @param PayloadType|string $type 301 | * @param Listener|callable(Context): void|class-string $listener 302 | * @return $this 303 | */ 304 | public function on($type, $listener): self 305 | { 306 | return $this->register($type, self::DEFAULT, $listener); 307 | } 308 | 309 | /** 310 | * Configures an async listener for an incoming request of the specified type. 311 | * 312 | * @param PayloadType|string $type 313 | * @param Listener|callable(Context): void|class-string $listener 314 | * @return $this 315 | */ 316 | public function onAsync($type, $listener): self 317 | { 318 | return $this->on($type, Route::async($listener)); 319 | } 320 | 321 | /** 322 | * Configures a catch-all listener for an incoming request. 323 | * 324 | * @param Listener|callable(Context): void|class-string $listener 325 | * @return $this 326 | */ 327 | public function any($listener): self 328 | { 329 | return $this->register(self::DEFAULT, self::DEFAULT, $listener); 330 | } 331 | 332 | /** 333 | * Configures an async catch-all listener for an incoming request. 334 | * 335 | * @param Listener|callable(Context): void|class-string $listener 336 | * @return $this 337 | */ 338 | public function anyAsync($listener): self 339 | { 340 | return $this->any(Route::async($listener)); 341 | } 342 | 343 | /** 344 | * Adds a tap interceptor, which executes a callback with the Context. 345 | * 346 | * @param callable(Context): void $callback 347 | * @return $this 348 | */ 349 | public function tap(callable $callback): self 350 | { 351 | return $this->use(new Interceptors\Tap($callback)); 352 | } 353 | 354 | /** 355 | * Adds an interceptor that applies to all listeners in the Router. 356 | * 357 | * @param Interceptor $interceptor 358 | * @return $this 359 | */ 360 | public function use(Interceptor $interceptor): self 361 | { 362 | $this->interceptors->add($interceptor); 363 | 364 | return $this; 365 | } 366 | 367 | public function handle(Context $context): void 368 | { 369 | $this->getListener($context)->handle($context); 370 | } 371 | 372 | /** 373 | * @param Context $context 374 | * @return Listener 375 | */ 376 | public function getListener(Context $context): Listener 377 | { 378 | $type = (string) $context->payload()->getType(); 379 | $id = $context->payload()->getTypeId() ?? self::DEFAULT; 380 | $listener = $this->listeners[$type][$id] 381 | ?? $this->listeners[$type][self::DEFAULT] 382 | ?? $this->listeners[self::DEFAULT][self::DEFAULT] 383 | ?? new Listeners\Undefined(); 384 | 385 | return new Listeners\Intercepted($this->interceptors, $listener); 386 | } 387 | 388 | /** 389 | * @param PayloadType|string $type 390 | * @param string $name 391 | * @param Listener|callable|class-string|null $listener 392 | * @return $this 393 | */ 394 | private function register($type, string $name, $listener): self 395 | { 396 | $type = (string) $type; 397 | $name = trim($name, '/ '); 398 | $this->listeners[$type][$name] = Coerce::listener($listener); 399 | 400 | return $this; 401 | } 402 | } 403 | -------------------------------------------------------------------------------- /src/SlackLogger.php: -------------------------------------------------------------------------------- 1 | logger = $logger ?? new NullLogger(); 34 | $this->name = 'App'; 35 | $this->context = []; 36 | } 37 | 38 | public function unwrap(): LoggerInterface 39 | { 40 | return $this->logger; 41 | } 42 | 43 | public function withInternalLogger(LoggerInterface $logger): self 44 | { 45 | if ($logger instanceof self) { 46 | $logger = $logger->unwrap(); 47 | } 48 | 49 | $this->logger = $logger; 50 | 51 | return $this; 52 | } 53 | 54 | public function withName(?string $name): self 55 | { 56 | $this->name = $name ?? 'App'; 57 | 58 | return $this; 59 | } 60 | 61 | /** 62 | * @deprecated use addContext() instead 63 | * @param array $context 64 | * @return $this 65 | */ 66 | public function withData(array $context): self 67 | { 68 | $this->context = $context + $this->context; 69 | 70 | return $this; 71 | } 72 | 73 | public function addContext(array $context): self 74 | { 75 | $this->context = $context + $this->context; 76 | 77 | return $this; 78 | } 79 | 80 | public function log($level, $message, array $context = []): void 81 | { 82 | $this->logger->log($level, "[{$this->name}] {$message}", $context + $this->context); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/StderrLogger.php: -------------------------------------------------------------------------------- 1 | 0, 15 | LogLevel::INFO => 1, 16 | LogLevel::NOTICE => 2, 17 | LogLevel::WARNING => 3, 18 | LogLevel::ERROR => 4, 19 | LogLevel::CRITICAL => 5, 20 | LogLevel::ALERT => 6, 21 | LogLevel::EMERGENCY => 7, 22 | ]; 23 | 24 | private int $minLevel; 25 | 26 | /** @var resource */ 27 | private $stream; 28 | 29 | public function __construct(string $minLevel = LogLevel::WARNING, $stream = 'php://stderr') 30 | { 31 | if (!isset(self::LOG_LEVEL_MAP[$minLevel])) { 32 | throw new InvalidArgumentException("Invalid log level: {$minLevel}"); 33 | } 34 | 35 | $this->minLevel = self::LOG_LEVEL_MAP[$minLevel]; 36 | 37 | if (is_resource($stream)) { 38 | $this->stream = $stream; 39 | } elseif (is_string($stream)) { 40 | $this->stream = fopen($stream, 'a'); 41 | if (!$this->stream) { 42 | throw new Exception('Unable to open stream: ' . $stream); 43 | } 44 | } else { 45 | throw new InvalidArgumentException('A stream must either be a resource or a string'); 46 | } 47 | } 48 | 49 | public function log($level, $message, array $context = []) 50 | { 51 | if (!isset(self::LOG_LEVEL_MAP[$level])) { 52 | throw new InvalidArgumentException("Invalid log level: {$level}"); 53 | } 54 | 55 | // Don't report logs for log levels less than the min level. 56 | if (self::LOG_LEVEL_MAP[$level] < $this->minLevel) { 57 | return; 58 | } 59 | 60 | // Apply special formatting for "exception" fields. 61 | if (isset($context['exception'])) { 62 | $exception = $context['exception']; 63 | if ($exception instanceof Exception) { 64 | $context = $exception->getContext() + $context; 65 | } 66 | 67 | $context['exception'] = explode("\n", (string) $exception); 68 | } 69 | 70 | fwrite($this->stream, json_encode(compact('level', 'message', 'context')) . "\n"); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/Fakes/FakeResponseEmitter.php: -------------------------------------------------------------------------------- 1 | fn = $fn ? Closure::fromCallable($fn) : null; 17 | $this->lastResponse = null; 18 | } 19 | 20 | public function emit(ResponseInterface $response): void 21 | { 22 | $this->lastResponse = $response; 23 | if ($this->fn !== null) { 24 | ($this->fn)($response); 25 | } 26 | } 27 | 28 | public function getLastResponse(): ?ResponseInterface 29 | { 30 | return $this->lastResponse; 31 | } 32 | 33 | public function getLastResponseData(): array 34 | { 35 | if ($this->lastResponse === null) { 36 | return []; 37 | } 38 | 39 | return json_decode((string) $this->lastResponse->getBody(), true) ?? []; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/Integration/Apps/AnyApp.php: -------------------------------------------------------------------------------- 1 | any(fn (Context $ctx) => $ctx->ack('hello')); 12 | } 13 | } 14 | 15 | -------------------------------------------------------------------------------- /tests/Integration/Apps/any-app.php: -------------------------------------------------------------------------------- 1 | any(fn (Context $ctx) => $ctx->ack('hello')); 6 | -------------------------------------------------------------------------------- /tests/Integration/CommandTest.php: -------------------------------------------------------------------------------- 1 | failOnLoggedErrors(); 16 | 17 | $request = $this->createCommandRequest([ 18 | 'command' => '/test', 19 | 'text' => 'hello', 20 | ]); 21 | 22 | $listener = function (Context $ctx) { 23 | $payload = $ctx->payload(); 24 | $ctx->ack("{$payload->get('command')} {$payload->get('text')}"); 25 | }; 26 | 27 | App::new() 28 | ->command('test', $listener) 29 | ->run($this->createHttpServer($request)); 30 | 31 | $result = $this->parseResponse(); 32 | $this->assertEquals('/test hello', $result->get('blocks.0.text.text')); 33 | } 34 | 35 | public function testCanHandleSubCommandRequest(): void 36 | { 37 | $this->failOnLoggedErrors(); 38 | $request = $this->createCommandRequest([ 39 | 'command' => '/test', 40 | 'text' => 'hello Jeremy --caps', 41 | ]); 42 | 43 | $listener = new class() extends CommandListener { 44 | protected static function buildDefinition(DefinitionBuilder $builder): DefinitionBuilder 45 | { 46 | return $builder->name('test')->subCommand('hello')->arg('name')->opt('caps'); 47 | } 48 | 49 | protected function listenToCommand(Context $context, Input $input): void 50 | { 51 | $text = "Hello, {$input->get('name')}"; 52 | $context->ack($input->get('caps') ? strtoupper($text) : $text); 53 | } 54 | }; 55 | 56 | App::new() 57 | ->commandGroup('test', ['hello' => $listener]) 58 | ->run($this->createHttpServer($request)); 59 | 60 | $result = $this->parseResponse(); 61 | $this->assertEquals('HELLO, JEREMY', $result->get('blocks.0.text.text')); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/Integration/DeferredContextCliServerTest.php: -------------------------------------------------------------------------------- 1 | true, 18 | '_deferred' => true, 19 | '_payload' => [ 20 | 'command' => '/foo', 21 | 'response_url' => 'https://example.org', 22 | ], 23 | ])); 24 | 25 | $respondClient = $this->createMock(RespondClient::class); 26 | $respondClient->expects($this->once()) 27 | ->method('respond') 28 | ->with( 29 | 'https://example.org', 30 | $this->callback(fn ($v): bool => $v instanceof Message && strpos($v->toJson(), 'bar') !== false) 31 | ); 32 | 33 | $app = App::new() 34 | ->commandAsync('foo', fn (Context $ctx) => $ctx->respond('bar')) 35 | ->tap(function (Context $ctx) use ($respondClient) { 36 | $ctx->withRespondClient($respondClient); 37 | }); 38 | DeferredContextCliServer::new() 39 | ->withApp($app) 40 | ->withArgs(['script', $serializedContext, '--soft-exit']) 41 | ->start(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/Integration/IntegTestCase.php: -------------------------------------------------------------------------------- 1 | httpFactory = new Psr17Factory(); 41 | $this->logger = $this->createMock(LoggerInterface::class); 42 | $this->responseEmitter = new FakeResponseEmitter(); 43 | } 44 | 45 | protected function parseResponse(?ResponseInterface $response = null): DataBag 46 | { 47 | $response = $response ?? $this->responseEmitter->getLastResponse(); 48 | 49 | $content = (string) $response->getBody(); 50 | if ($content === '') { 51 | return new DataBag(['ack' => true]); 52 | } 53 | 54 | try { 55 | return new DataBag(\json_decode($content, true, 512, \JSON_THROW_ON_ERROR)); 56 | } catch (\JsonException $exception) { 57 | $this->fail('Could not parse response JSON: ' . $exception->getMessage()); 58 | } 59 | } 60 | 61 | protected function createCommandRequest(array $data, ?int $timestamp = null): ServerRequestInterface 62 | { 63 | return $this->createRequest(http_build_query($data), 'application/x-www-form-urlencoded', $timestamp); 64 | } 65 | 66 | protected function createInteractiveRequest(array $data, ?int $timestamp = null): ServerRequestInterface 67 | { 68 | return $this->createRequest( 69 | http_build_query(['payload' => json_encode($data)]), 70 | 'application/x-www-form-urlencoded', 71 | $timestamp 72 | ); 73 | } 74 | 75 | protected function createEventRequest(array $data, ?int $timestamp = null): ServerRequestInterface 76 | { 77 | return $this->createRequest(json_encode($data), 'application/json', $timestamp); 78 | } 79 | 80 | private function createRequest(string $content, string $contentType, ?int $timestamp = null): ServerRequestInterface 81 | { 82 | // Create signature 83 | $timestamp = $timestamp ?? time(); 84 | $stringToSign = sprintf('v0:%d:%s', $timestamp, $content); 85 | $signature = 'v0=' . hash_hmac('sha256', $stringToSign, self::SIGNING_KEY); 86 | 87 | return $this->httpFactory->createServerRequest('POST', '/') 88 | ->withHeader(self::HEADER_TIMESTAMP, (string) $timestamp) 89 | ->withHeader(self::HEADER_SIGNATURE, $signature) 90 | ->withHeader('Content-Type', $contentType) 91 | ->withHeader('Content-Length', (string) strlen($content)) 92 | ->withBody($this->httpFactory->createStream($content)); 93 | } 94 | 95 | protected function createHttpServer(ServerRequestInterface $request): HttpServer 96 | { 97 | return HttpServer::new() 98 | ->withLogger($this->logger) 99 | ->withRequest($request) 100 | ->withResponseEmitter($this->responseEmitter); 101 | } 102 | 103 | protected function failOnLoggedErrors(): void 104 | { 105 | $this->logger->method('error')->willReturnCallback(function (string $message, array $context) { 106 | $message = "Logged an error: {$message}\nContext:\n"; 107 | foreach ($context as $key => $value) { 108 | $message .= "- {$key}: {$value}\n"; 109 | } 110 | 111 | $this->fail($message); 112 | }); 113 | } 114 | 115 | /** 116 | * @param mixed $result 117 | */ 118 | protected function assertIsAck($result): void 119 | { 120 | if (!$result instanceof DataBag) { 121 | $this->fail('Tried to assertIsAck on invalid value'); 122 | } 123 | 124 | if ($result->get('ack') !== true) { 125 | $this->fail('Result was not an "ack"'); 126 | } 127 | } 128 | 129 | protected function interceptApiCall(string $api, callable $handler): Interceptor 130 | { 131 | $apiClient = $this->createMock(ApiClient::class); 132 | $apiClient->expects($this->once()) 133 | ->method('call') 134 | ->with($api, $this->anything()) 135 | ->willReturnCallback(function (string $api, array $params) use ($handler) { 136 | return $handler($params); 137 | }); 138 | 139 | return new Tap(function (Context $context) use ($apiClient) { 140 | $context->withApiClient($apiClient); 141 | }); 142 | } 143 | 144 | protected function interceptRespond(string $responseUrl): Interceptor 145 | { 146 | $respondClient = $this->createMock(RespondClient::class); 147 | $respondClient->expects($this->once()) 148 | ->method('respond') 149 | ->with($responseUrl, $this->isInstanceOf(Message::class)); 150 | 151 | return new Tap(function (Context $context) use ($respondClient) { 152 | $context->withRespondClient($respondClient); 153 | }); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /tests/Integration/MultiTenantHttpServerTest.php: -------------------------------------------------------------------------------- 1 | request = new ServerRequest('POST', '/', ['Content-Type' => 'application/json'], '{}'); 22 | $this->responseEmitter = new FakeResponseEmitter(); 23 | $this->server = MultiTenantHttpServer::new() 24 | ->registerApp('A1', Apps\AnyApp::class) 25 | ->registerApp('A2', __DIR__ . '/Apps/any-app.php') 26 | ->registerApp('A3', fn () => new Apps\AnyApp()) 27 | ->withResponseEmitter($this->responseEmitter); 28 | } 29 | 30 | protected function tearDown(): void 31 | { 32 | putenv('SLACKPHP_SKIP_AUTH='); 33 | parent::tearDown(); 34 | } 35 | 36 | public function testCanRunAppFromClassName(): void 37 | { 38 | $this->server->withRequest($this->request->withQueryParams(['_app' => 'A1']))->start(); 39 | $this->assertArrayHasKey('blocks', $this->responseEmitter->getLastResponseData()); 40 | } 41 | 42 | public function testCanRunAppFromInclude(): void 43 | { 44 | $this->server->withRequest($this->request->withQueryParams(['_app' => 'A2']))->start(); 45 | $this->assertArrayHasKey('blocks', $this->responseEmitter->getLastResponseData()); 46 | } 47 | 48 | public function testCanRunAppFromCallback(): void 49 | { 50 | $this->server->withRequest($this->request->withQueryParams(['_app' => 'A3']))->start(); 51 | $this->assertArrayHasKey('blocks', $this->responseEmitter->getLastResponseData()); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/Integration/ShortcutTest.php: -------------------------------------------------------------------------------- 1 | failOnLoggedErrors(); 14 | 15 | $request = $this->createInteractiveRequest([ 16 | 'type' => 'shortcut', 17 | 'callback_id' => 'foobar', 18 | 'trigger_id' => 'abc123', 19 | ]); 20 | 21 | $listener = function (Context $ctx) { 22 | $view = $ctx->modals()->open('MOCK'); 23 | $this->assertEquals('xyz123', $view['id']); 24 | }; 25 | 26 | $apiMock = function (array $input): array { 27 | $this->assertEquals('abc123', $input['trigger_id']); 28 | $this->assertInstanceOf(Modal::class, Modal::fromArray($input['view'])); 29 | 30 | return [ 31 | 'ok' => true, 32 | 'view' => ['id' => 'xyz123'] 33 | ]; 34 | }; 35 | 36 | App::new() 37 | ->globalShortcut('foobar', $listener) 38 | ->use($this->interceptApiCall('views.open', $apiMock)) 39 | ->run($this->createHttpServer($request)); 40 | 41 | $this->assertIsAck($this->parseResponse()); 42 | } 43 | 44 | public function testCanHandleMessageShortcutRequest(): void 45 | { 46 | $this->failOnLoggedErrors(); 47 | 48 | $responseUrl = 'https://hooks.slack.com/abc123'; 49 | $request = $this->createInteractiveRequest([ 50 | 'type' => 'message_action', 51 | 'callback_id' => 'foobar', 52 | 'response_url' => $responseUrl, 53 | 'message' => ['text' => 'foo'] 54 | ]); 55 | 56 | $listener = function (Context $ctx) { 57 | $text = $ctx->payload()->get('message.text'); 58 | $this->assertEquals('foo', $text); 59 | $ctx->respond('bar'); 60 | }; 61 | 62 | App::new() 63 | ->messageShortcut('foobar', $listener) 64 | ->use($this->interceptRespond($responseUrl)) 65 | ->run($this->createHttpServer($request)); 66 | 67 | $this->assertIsAck($this->parseResponse()); 68 | } 69 | } 70 | --------------------------------------------------------------------------------