├── .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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
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 | 
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 <<
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 |
--------------------------------------------------------------------------------