├── .cargo └── config ├── .dockerignore ├── .gitignore ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── crates ├── rec_lambda_commands │ ├── Cargo.toml │ └── src │ │ └── main.rs ├── rec_lambda_events │ ├── Cargo.toml │ └── src │ │ └── main.rs ├── rec_lambda_interactions │ ├── Cargo.toml │ └── src │ │ └── main.rs ├── rec_server │ ├── Cargo.toml │ ├── src │ │ └── main.rs │ └── tests │ │ ├── integration.rs │ │ └── mocks │ │ ├── chatbot_views.json │ │ └── mock_receptionist_responses.json ├── receptionist │ ├── Cargo.toml │ ├── src │ │ ├── config.rs │ │ ├── database │ │ │ ├── cloudformation.rs │ │ │ ├── dynamo_cf_template.json │ │ │ ├── dynamodb.rs │ │ │ ├── in_mem_testdb.rs │ │ │ └── mod.rs │ │ ├── lib.rs │ │ ├── manager_ui │ │ │ ├── block_actions.rs │ │ │ ├── mod.rs │ │ │ ├── router.rs │ │ │ ├── state_machine.rs │ │ │ ├── submission.rs │ │ │ └── utils.rs │ │ ├── pagerduty │ │ │ ├── client.rs │ │ │ ├── mod.rs │ │ │ └── models.rs │ │ ├── response │ │ │ ├── actions.rs │ │ │ ├── conditions.rs │ │ │ ├── listeners.rs │ │ │ ├── mod.rs │ │ │ ├── responses.rs │ │ │ └── utils.rs │ │ ├── response2 │ │ │ ├── actions.rs │ │ │ └── mod.rs │ │ ├── slack │ │ │ ├── api_calls.rs │ │ │ ├── commands_api.rs │ │ │ ├── events_api.rs │ │ │ ├── interaction_api.rs │ │ │ ├── mod.rs │ │ │ ├── state_values.rs │ │ │ ├── utils.rs │ │ │ └── verification.rs │ │ └── utils.rs │ └── tests │ │ ├── generate_previews.rs │ │ └── generated_blocks │ │ ├── attach_emoji.json │ │ ├── fwd_msg_to_channel.json │ │ ├── match_regex.json │ │ └── tag_oncall_in_thread.json └── xtask │ ├── Cargo.toml │ └── src │ └── main.rs ├── docker-compose.yml ├── dockerfiles ├── Dockerfile.aws ├── Dockerfile.local ├── Dockerfile.localstack └── localstack_dynamodb_setup │ ├── commands.sh │ └── mock-ddb-create.json ├── docs ├── architecture.md ├── deployments.md └── development.md ├── manager.png ├── manifest.yml ├── robo-laptop.png └── terraform_aws ├── config └── config.s3.tfbackend ├── remote-state ├── .terraform.lock.hcl └── main.tf ├── server ├── .terraform.lock.hcl └── main.tf └── serverless ├── .terraform.lock.hcl └── main.tf /.cargo/config: -------------------------------------------------------------------------------- 1 | [alias] 2 | xtask = "run --package xtask --" 3 | 4 | #[target.x86_64-unknown-linux-musl] 5 | #linker = "x86_64-linux-musl-gcc" 6 | 7 | 8 | #[target.aarch64-unknown-linux-musl] 9 | #linker = "aarch64-linux-musl-gcc" 10 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file 2 | .git 3 | target/ 4 | .terraform* 5 | terraform* 6 | .terraform** 7 | .tfstate 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/* 2 | *.env 3 | *.secrets 4 | secrets/** 5 | docker/* 6 | **/.DS_Store 7 | 8 | **/archives/* 9 | 10 | ### TERRAFORM ############ 11 | # Local .terraform directories 12 | **/.terraform/* 13 | 14 | # .tfstate files 15 | *.tfstate 16 | *.tfstate.* 17 | 18 | # Crash log files 19 | crash.log 20 | crash.*.log 21 | 22 | # Exclude all .tfvars files, which are likely to contain sentitive data, such as 23 | # password, private keys, and other secrets. These should not be part of version 24 | # control as they are data points which are potentially sensitive and subject 25 | # to change depending on the environment. 26 | # 27 | *.tfvars 28 | 29 | # Ignore override files as they are usually used to override resources locally and so 30 | # are not checked in 31 | override.tf 32 | override.tf.json 33 | *_override.tf 34 | *_override.tf.json 35 | 36 | # Include override files you do wish to add to version control using negated pattern 37 | # 38 | # !example_override.tf 39 | 40 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan 41 | # example: *tfplan* 42 | 43 | # Ignore CLI configuration files 44 | .terraformrc 45 | terraform.rc 46 | terraform.tfstate -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to twilio-labs/receptionist-bot-rs 2 | 3 | ## Project Structure 4 | 5 | This project is a monorepo, meaning it contains multiple crates or (packages) in one repository. It consists of of the following crates: 6 | 7 | - [`receptionist`](crates/receptionist) - The core library for making a receptionist bot implementation 8 | - [`rec_server`](crates/rec_server/) - An implementation of Receptionist Bot as a standalone server or docker container 9 | - `rec_lambda_` - An implementation of Receptionist Bot as 3 serverless cloud functions on AWS lambda: [Slash Commands](./crates/rec_lambda_commands), [Interactions API](./crates/rec_lambda_interactions), and [Events API](./crates/rec_lambda_events) 10 | - [`xtask`](crates/xtask/) - [xtask](https://github.com/matklad/cargo-xtask) exposes a small Rust CLI for build scripts related to the project, similar to a makefile or `npm run`. 11 | - [`terraform_aws`](./terraform_aws) - Terraform code for deploying either as a standalone server or serverless functions 12 | - [`docs`](./docs) - A collection of guides on [deployments](docs/deployments.md), [project architecture](docs/architecture.md), and [development](docs/development.md) 13 | 14 | --- 15 | 16 | ## Code of Conduct 17 | 18 | Please be aware that this project has a [Code of Conduct](https://github.com/twilio-labs/.github/blob/master/CODE_OF_CONDUCT.md). The tldr; is to just be excellent to each other ❤️ 19 | 20 | ## Licensing 21 | 22 | All third party contributors acknowledge that any contributions they provide will be made under the same open source license that the open source project is provided under. 23 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | 2 | [workspace] 3 | members = [ 4 | "crates/*" 5 | ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # receptionist-bot-rs 2 | 3 | Slack bot for self-servicing automation of common or predictable tasks. 4 | 5 | Receptionist bot aims to provide a no-code frontend for technical & non-technical users to easily automate away some of the tedious repetition that can permeate Slack as companies grow larger. Its scope is narrow: rather than replacing the first-party Slack Workflows system or becoming an entire ecosystem such as [SOCless](https://github.com/twilio-labs/socless), Receptionist bot bridges the gap between the promise of Slack Workflows and the current state provided to users while also providing workflow automations for free-tier communities. 6 | 7 | The app is designed to be highly extensible for new features in your workflows and is unopinionated with regards to its deployment infrastructure. 8 | 9 | 10 | - [Developing the bot in a local environment (with or without docker)](./docs/development.md#local-development) 11 | - [How to deploy to AWS as a Server using Terraform](./docs/deployments.md#deployment-to-aws-as-a-standalone-server) 12 | - [How to deploy to AWS as Serverless Functions using Terraform](./docs/deployments.md#deployment-to-aws-as-lambda-functions--api-gateway) 13 | - [Setting up the Slack App](#creating-the-slack-apps-permissions-urls-slash-commands-etc) 14 | - [Supported Features](#supported-features) 15 | - [Known Bugs & Limitations](#known-bugs--limitations) 16 | - [View UI Examples using Block Kit Builder](#View-UI-Examples-with-Block-Kit-Builder) 17 | - [Special Thanks & Shoutouts](#special-thanks--shoutouts) 18 | 19 | 20 | 21 | 22 | 23 | ## Interacting with the Receptionist Bot 24 | The app ships with a slash command `/rec-manage` that will display a UI for Creating, Editing, and Deleting Receptionist Workflow Responses 25 | 26 | --- 27 | 28 | ## Project Structure & Contributing 29 | 30 | This project is a monorepo, meaning it contains multiple crates or (packages) in one repository. It consists of of the following crates: 31 | 32 | - [`receptionist`](crates/receptionist) - The core library for making a receptionist bot implementation 33 | - [`rec_server`](crates/rec_server/) - An implementation of Receptionist Bot as a standalone server or docker container 34 | - `rec_lambda_` - An implementation of Receptionist Bot as 3 serverless cloud functions on AWS lambda: [Slash Commands](./crates/rec_lambda_commands), [Interactions API](./crates/rec_lambda_interactions), and [Events API](./crates/rec_lambda_events) 35 | - [`xtask`](crates/xtask/) - [xtask](https://github.com/matklad/cargo-xtask) exposes a small Rust CLI for build scripts related to the project, similar to a makefile or `npm run`. 36 | - [`terraform_aws`](./terraform_aws) - Terraform code for deploying either as a standalone server or serverless functions 37 | - [`docs`](./docs) - A collection of guides on [deployments](docs/deployments.md), [project architecture](docs/architecture.md), and [development](docs/development.md) 38 | - [CONTRIBUTING.md](./CONTRIBUTING.md) 39 | 40 | --- 41 | 42 | # Supported Features 43 | 44 | ## Events that can trigger a response 45 | | Event Origin | Status | Event Scope | Origin Type 46 | | ------------------------ | ------------- | ------------ | ----------- 47 | | Message sent to channel | Done ✅ | Local (channel) | Slack Message 48 | | Receptionist App mentioned in channel | Planned | Local (channel) | Slack Message 49 | | Custom slash command: `/rec-cmd ` | Planned | Global | Slash Command 50 | | Webhook sent to Receptionist Server | Not Planned | Global | Server Event 51 | 52 | ## Conditions to check before triggering a response 53 | | Condition | Status | Eligible Origin Types 54 | | ---------------- | ------------- | ---------------------- 55 | | Matches a Regex | Done ✅ | Slack Message 56 | | Matches a Phrase | Done ✅ | Slack Message 57 | | Is From a specific User | Planned | Slack Message 58 | 59 | ## Actions that a Response can take 60 | | Action | Status | Eligible Origin Types 61 | | ------------- | ------------- | ---------------------- 62 | React with Emoji (can trigger Slack Workflows) | Done ✅ | Slack Message 63 | Send Message To Thread | Done ✅ | Slack Message 64 | Tag Pagerduty oncall for team in thread | Done ✅ | Slack Message 65 | Send Message To Channel | Done ✅ | Slack Message 66 | Forward message to a channel | Done ✅ | Slack Message 67 | Tag Pagerduty oncall for team in channel | Planned | Slack Message 68 | Send Message To User | Planned | Slack Message 69 | Tag User in thread | Planned | Slack Message 70 | Tag User in Channel | Planned | Slack Message 71 | Forward message to a user | Planned | Slack Message 72 | send webhook with custom payload | Planned | Slack Message 73 | 74 | 75 | ## Known Bugs & Limitations 76 | 77 | ### Limitations 78 | - App will not scan bot messages, hidden messages, or threaded messages to help prevent infinite loops or negatively impacting the signal-to-noise ratio of the channel. 79 | - listening for bot messages may be introduced in the future but only if the message condition is scoped to that single bot (user-specific message conditions have not been written yet, which blocks this bot message feature) 80 | 81 | 82 | ### Bugs 83 | #### Listener Validation in Modal 84 | **Intent:** If listener channel is empty in the Management Modal, we return an errors object response to Slack to display an error and prevent the view closing. 85 | 86 | **Bug:** The view stays open correctly, However the error does not display. 87 | 88 | **Situation Report:** We are confident the http response is being sent correctly to Slack because if anything is incorrect, Slack will display a connection error in the modal. We encountered this previously when the content-type was incorrect or when the `errors` object contained an invalid Block_ID. Since neither of those are happening now, I am inclined to believe this is either a bug or limitation on Slack's end with the Conversations Select Section Block 89 | - https://api.slack.com/surfaces/modals/using#displaying_errors 90 | 91 | ## View UI Examples with Block Kit Builder 92 | 93 | 1. Log into [Block Kit Builder](https://api.slack.com/tools/block-kit-builder) 94 | 2. Select **Modal Preview** from the dropdown in the top left 95 | 3. Copy a json template from [./crates/receptionist/tests/generated_blocks](./crates/receptionist/tests/generated_blocks) and paste it into the Block Kit json editor 96 | 97 | ## Special Thanks & Shoutouts 98 | - [@abdolence](https://github.com/abdolence) for creating the excellent [`slack-morphism` library](https://github.com/abdolence/slack-morphism-rust). 99 | - `slack-morphism` strongly types nearly everything you need to work with Slack, including block kit models which are a huge pain point when doing highly reactive & dynamic UIs like the Receptionist Bot. This app would not have been possible without his work! 100 | - [@abdolence](https://github.com/abdolence) also [quickly responded to questions](https://github.com/abdolence/slack-morphism-rust/issues/69) with thorough answers and helpful tips 101 | - [@davidpdrsn](https://github.com/davidpdrsn) for the [Axum](https://github.com/tokio-rs/axum) web application framework. 102 | - Axum is easy to use and is built with `hyper` & `tokio`, but also uses the `Tower` ecosystem so that you can share middleware/services/utilities, between any other framework that uses `hyper` or `tonic`. You can also often share code between server-side and client side implementations. 103 | - You can apply middlewares & Layers to only affect certain routes. In this App we use a Slack Verification Middleware from `slack-morphism` to protect our routes that expect to receive traffic from Slack, but not for other routes. 104 | - [@davidpdrsn](https://github.com/davidpdrsn) also [quickly helped out when I struggled to integrate an authentication middleware](https://github.com/tokio-rs/axum/discussions/625). 105 | - [@yusuphisms](https://github.com/yusuphisms) for experimenting with the Pagerduty API to enable new bot features, helping incorporate Docker Compose & DynamoDB support, and setting up for integration tests. 106 | - [@ubaniabalogun](https://github.com/ubaniabalogun) for thoroughly designing an effective DynamoDB model to enable a wide range of queries with a single Table. 107 | - [@shadyproject](https://github.com/shadyproject) for helping load test and brainstorming deployment & database strategies. 108 | - Everyone who has worked on [`strum`](https://github.com/Peternator7/strum) which powers up the already great Rust enums 109 | - [@bryanburgers](https://github.com/bryanburgers) for his work on [https://github.com/zenlist/serde_dynamo](serde_dynamo) which makes it easier to use dynamoDB (and the alpha branch supports the brand new aws_rust_sdk!) 110 | 111 | ## Code of Conduct 112 | https://github.com/twilio-labs/.github/blob/master/CODE_OF_CONDUCT.md 113 | -------------------------------------------------------------------------------- /crates/rec_lambda_commands/Cargo.toml: -------------------------------------------------------------------------------- 1 | ["package"] 2 | name = "rec_lambda_commands" 3 | version = "0.1.0" 4 | edition = "2018" 5 | authors = ["Saxon Hunt "] 6 | 7 | # rename the binary to just `bootstrap` during terraform deployment. 8 | # if we rename it now, it will clash with other lambdas if built in parallel 9 | 10 | [features] 11 | default = ["dynamodb"] 12 | dynamodb = ["receptionist/dynamodb"] 13 | tempdb = ["receptionist/tempdb"] 14 | ansi = ["receptionist/ansi"] 15 | 16 | 17 | [dependencies] 18 | receptionist = { path = "../receptionist", default-features = false} 19 | tokio = { version = "1.17", features = ["full"] } 20 | tracing = "0.1" 21 | tracing-subscriber = { version="0.3", default-features = false, features = ["env-filter", "tracing-log", "smallvec", "fmt"] } 22 | # slack-morphism = {git = "https://github.com/noxasaxon/slack-morphism-rust", branch = "fix-permalink"} 23 | slack-morphism = "0.30" 24 | lambda_http = "0.4" -------------------------------------------------------------------------------- /crates/rec_lambda_commands/src/main.rs: -------------------------------------------------------------------------------- 1 | // may want to use this to share resources: https://github.com/awslabs/aws-lambda-rust-runtime/blob/797f0ab285fbaafe284f7a0df4cceb2ae0e3d3d4/lambda-http/examples/shared-resources-example.rs 2 | use lambda_http::{ 3 | handler, 4 | lambda_runtime::{self, Context, Error}, 5 | IntoResponse, Request, RequestExt, 6 | }; 7 | use receptionist::{handle_slack_command, SlackEventSignatureVerifier, SlackStateWorkaround}; 8 | use slack_morphism::prelude::SlackCommandEvent; 9 | use tokio::sync::OnceCell; 10 | use tracing::debug; 11 | 12 | pub static SLACK_CONFIG: OnceCell = OnceCell::const_new(); 13 | pub async fn get_or_init_slack_state() -> &'static SlackStateWorkaround { 14 | SLACK_CONFIG 15 | .get_or_init(|| async { SlackStateWorkaround::new_from_env() }) 16 | .await 17 | } 18 | 19 | #[tokio::main] 20 | async fn main() -> Result<(), lambda_runtime::Error> { 21 | // You can view the logs emitted by your app in Amazon CloudWatch. 22 | tracing_subscriber::fmt::init(); 23 | debug!("logger has been set up"); 24 | 25 | lambda_runtime::run(handler(commands_api_lambda)).await?; 26 | 27 | Ok(()) 28 | } 29 | 30 | async fn commands_api_lambda(req: Request, _ctx: Context) -> Result { 31 | verify_apig_req_from_slack(&req); 32 | 33 | let command_event = req 34 | .payload::() 35 | .expect("unable to deserialize") 36 | .expect("no body provided"); 37 | 38 | let slack_state = get_or_init_slack_state().await; 39 | let event_finished = handle_slack_command(slack_state, command_event).await; 40 | Ok(event_finished.1) 41 | } 42 | 43 | pub fn verify_apig_req_from_slack(event: &Request) { 44 | let signing_secret = 45 | std::env::var("SLACK_SIGNING_SECRET").expect("No SLACK_SIGNING_SECRET set in env!"); 46 | 47 | let headers = event.headers(); 48 | 49 | let body_as_string = 50 | String::from_utf8(event.body().to_vec()).expect("Unable to convert APIG Event to string"); 51 | 52 | let timestamp = headers[SlackEventSignatureVerifier::SLACK_SIGNED_TIMESTAMP] 53 | .to_str() 54 | .expect("header not a string"); 55 | 56 | let signature = headers[SlackEventSignatureVerifier::SLACK_SIGNED_HASH_HEADER] 57 | .to_str() 58 | .expect("header not a string"); 59 | 60 | SlackEventSignatureVerifier::new(&signing_secret) 61 | .verify(signature, &body_as_string, timestamp) 62 | .expect("Verificaction failed, cannnot trust API Gateway Request is from Slack"); 63 | } 64 | -------------------------------------------------------------------------------- /crates/rec_lambda_events/Cargo.toml: -------------------------------------------------------------------------------- 1 | ["package"] 2 | name = "rec_lambda_events" 3 | version = "0.1.0" 4 | edition = "2018" 5 | authors = ["Saxon Hunt "] 6 | 7 | # rename the binary to just `bootstrap` during terraform deployment. 8 | # if we rename it now, it will clash with other lambas if built in parallel 9 | 10 | [features] 11 | default = ["dynamodb"] 12 | dynamodb = ["receptionist/dynamodb"] 13 | tempdb = ["receptionist/tempdb"] 14 | ansi = ["receptionist/ansi"] 15 | 16 | 17 | [dependencies] 18 | receptionist = { path = "../receptionist", default-features = false} 19 | tokio = { version = "1.17", features = ["full"] } 20 | tracing = "0.1" 21 | tracing-subscriber = { version="0.3", default-features = false, features = ["env-filter", "tracing-log", "smallvec", "fmt"] } 22 | # slack-morphism = { git = "https://github.com/noxasaxon/slack-morphism-rust", branch = "fix-permalink"} 23 | slack-morphism = "0.30" 24 | lambda_http = "0.4" -------------------------------------------------------------------------------- /crates/rec_lambda_events/src/main.rs: -------------------------------------------------------------------------------- 1 | use lambda_http::{ 2 | handler, 3 | lambda_runtime::{self, Context, Error}, 4 | IntoResponse, Request, RequestExt, 5 | }; 6 | use receptionist::{handle_slack_event, SlackEventSignatureVerifier, SlackStateWorkaround}; 7 | use slack_morphism::prelude::SlackPushEvent; 8 | use tokio::sync::OnceCell; 9 | use tracing::debug; 10 | 11 | pub static SLACK_CONFIG: OnceCell = OnceCell::const_new(); 12 | pub async fn get_or_init_slack_state() -> &'static SlackStateWorkaround { 13 | SLACK_CONFIG 14 | .get_or_init(|| async { SlackStateWorkaround::new_from_env() }) 15 | .await 16 | } 17 | 18 | #[tokio::main] 19 | async fn main() -> Result<(), lambda_runtime::Error> { 20 | // You can view the logs emitted by your app in Amazon CloudWatch. 21 | tracing_subscriber::fmt::init(); 22 | debug!("logger has been set up"); 23 | 24 | lambda_runtime::run(handler(events_api_lambda)).await?; 25 | 26 | Ok(()) 27 | } 28 | 29 | async fn events_api_lambda(req: Request, _ctx: Context) -> Result { 30 | verify_apig_req_from_slack(&req); 31 | 32 | let push_event_callback = req 33 | .payload::() 34 | .expect("unable to deserialize") 35 | .expect("no body provided"); 36 | 37 | let slack_state = get_or_init_slack_state().await; 38 | let event_finished = handle_slack_event(slack_state, push_event_callback).await; 39 | 40 | Ok(event_finished.1) 41 | } 42 | 43 | pub fn verify_apig_req_from_slack(event: &Request) { 44 | let signing_secret = 45 | std::env::var("SLACK_SIGNING_SECRET").expect("No SLACK_SIGNING_SECRET set in env!"); 46 | 47 | let headers = event.headers(); 48 | 49 | let body_as_string = 50 | String::from_utf8(event.body().to_vec()).expect("Unable to convert APIG Event to string"); 51 | 52 | let timestamp = headers[SlackEventSignatureVerifier::SLACK_SIGNED_TIMESTAMP] 53 | .to_str() 54 | .expect("header not a string"); 55 | 56 | let signature = headers[SlackEventSignatureVerifier::SLACK_SIGNED_HASH_HEADER] 57 | .to_str() 58 | .expect("header not a string"); 59 | 60 | SlackEventSignatureVerifier::new(&signing_secret) 61 | .verify(signature, &body_as_string, timestamp) 62 | .expect("Verificaction failed, cannnot trust API Gateway Request is from Slack"); 63 | } 64 | -------------------------------------------------------------------------------- /crates/rec_lambda_interactions/Cargo.toml: -------------------------------------------------------------------------------- 1 | ["package"] 2 | name = "rec_lambda_interactions" 3 | version = "0.1.0" 4 | edition = "2018" 5 | authors = ["Saxon Hunt "] 6 | 7 | # rename the binary to just `bootstrap` during terraform deployment. 8 | # if we rename it now, it will clash with other lambas if built in parallel 9 | 10 | [features] 11 | default = ["dynamodb"] 12 | dynamodb = ["receptionist/dynamodb"] 13 | tempdb = ["receptionist/tempdb"] 14 | ansi = ["receptionist/ansi"] 15 | 16 | 17 | [dependencies] 18 | receptionist = { path = "../receptionist", default-features = false} 19 | tokio = { version = "1.17", features = ["full"] } 20 | tracing = "0.1" 21 | tracing-subscriber = { version="0.3", default-features = false, features = ["env-filter", "tracing-log", "smallvec", "fmt"] } 22 | # slack-morphism = { git = "https://github.com/noxasaxon/slack-morphism-rust", branch = "fix-permalink"} 23 | slack-morphism = "0.30" 24 | lambda_http = "0.4" -------------------------------------------------------------------------------- /crates/rec_lambda_interactions/src/main.rs: -------------------------------------------------------------------------------- 1 | use lambda_http::{ 2 | handler, 3 | lambda_runtime::{self, Context, Error}, 4 | Body, IntoResponse, Request, RequestExt, Response, 5 | }; 6 | use receptionist::{ 7 | handle_slack_interaction, SlackEventSignatureVerifier, SlackInteractionWrapper, 8 | SlackStateWorkaround, 9 | }; 10 | use tokio::sync::OnceCell; 11 | use tracing::debug; 12 | 13 | pub static SLACK_CONFIG: OnceCell = OnceCell::const_new(); 14 | pub async fn get_or_init_slack_state() -> &'static SlackStateWorkaround { 15 | SLACK_CONFIG 16 | .get_or_init(|| async { SlackStateWorkaround::new_from_env() }) 17 | .await 18 | } 19 | 20 | #[tokio::main] 21 | async fn main() -> Result<(), lambda_runtime::Error> { 22 | // You can view the logs emitted by your app in Amazon CloudWatch. 23 | tracing_subscriber::fmt::init(); 24 | debug!("logger has been set up"); 25 | 26 | lambda_runtime::run(handler(interactions_api_lambda)).await?; 27 | 28 | Ok(()) 29 | } 30 | 31 | async fn interactions_api_lambda(req: Request, _ctx: Context) -> Result { 32 | verify_apig_req_from_slack(&req); 33 | 34 | let interaction_event_wrapper = req 35 | .payload::() 36 | .expect("unable to deserialize") 37 | .expect("no body provided"); 38 | 39 | let slack_state = get_or_init_slack_state().await; 40 | let (status, value) = handle_slack_interaction(slack_state, interaction_event_wrapper).await; 41 | 42 | if value.is_string() && value.as_str().unwrap().is_empty() { 43 | // need to send the NO_CONTENT status code in order to close the modal (*shake fist*). 44 | Ok(Response::builder() 45 | .status(status) 46 | .body(Body::Empty) 47 | .unwrap()) 48 | } else { 49 | // modal validation failed, display the error in the modal 50 | Ok(value.into_response()) 51 | } 52 | } 53 | 54 | pub fn verify_apig_req_from_slack(event: &Request) { 55 | let signing_secret = 56 | std::env::var("SLACK_SIGNING_SECRET").expect("No SLACK_SIGNING_SECRET set in env!"); 57 | 58 | let headers = event.headers(); 59 | 60 | let body_as_string = 61 | String::from_utf8(event.body().to_vec()).expect("Unable to convert APIG Event to string"); 62 | 63 | let timestamp = headers[SlackEventSignatureVerifier::SLACK_SIGNED_TIMESTAMP] 64 | .to_str() 65 | .expect("header not a string"); 66 | 67 | let signature = headers[SlackEventSignatureVerifier::SLACK_SIGNED_HASH_HEADER] 68 | .to_str() 69 | .expect("header not a string"); 70 | 71 | SlackEventSignatureVerifier::new(&signing_secret) 72 | .verify(signature, &body_as_string, timestamp) 73 | .expect("Verificaction failed, cannnot trust API Gateway Request is from Slack"); 74 | } 75 | -------------------------------------------------------------------------------- /crates/rec_server/Cargo.toml: -------------------------------------------------------------------------------- 1 | ["package"] 2 | name = "rec_server" 3 | version = "0.1.0" 4 | edition = "2018" 5 | authors = ["Saxon Hunt "] 6 | 7 | [features] 8 | default = ["dynamodb"] 9 | dynamodb = ["receptionist/dynamodb"] 10 | tempdb = ["receptionist/tempdb"] 11 | ansi = ["receptionist/ansi"] 12 | 13 | 14 | [dependencies] 15 | receptionist = { path = "../receptionist", default-features = false} 16 | axum = "0.4" 17 | tokio = { version = "1.17", features = ["full"] } 18 | tracing = "0.1" 19 | tracing-subscriber = { version="0.3", default-features = false, features = ["env-filter", "tracing-log", "smallvec", "fmt"] } 20 | tower-http = {version = "0.2", features=["trace"]} 21 | anyhow = "1.0" 22 | 23 | 24 | [dev-dependencies] 25 | # testcontainers = "0.12" 26 | testcontainers = { git= "https://github.com/testcontainers/testcontainers-rs", rev="bec5196f120c112da696be7c9053f63d5811e8c6"} 27 | reqwest = {version = "0.11", features = ["blocking"] } -------------------------------------------------------------------------------- /crates/rec_server/src/main.rs: -------------------------------------------------------------------------------- 1 | #[cfg(all(feature = "tempdb", feature = "dynamodb"))] 2 | compile_error!("cannot enable multiple db features"); 3 | 4 | /// Starts a Receptionist Webserver to process incoming Slack events, commands, and interactions. 5 | use axum::{ 6 | routing::{get, post}, 7 | AddExtensionLayer, Router, 8 | }; 9 | #[cfg(feature = "tempdb")] 10 | use receptionist::get_or_init_mem_db; 11 | 12 | use receptionist::{ 13 | axum_handler_handle_slack_commands_api, axum_handler_slack_events_api, 14 | axum_handler_slack_interactions_api, config::get_or_init_app_config, setup_slack, 15 | verification::SlackRequestVerifier, ServiceBuilder, SlackEventSignatureVerifier, 16 | }; 17 | use std::env; 18 | use tower_http::trace::TraceLayer; 19 | use tracing::info; 20 | 21 | #[tokio::main] 22 | async fn main() { 23 | setup_tracing(); 24 | info!("starting server.."); 25 | 26 | // SETUP SHARED BOT RESPONSES CACHE 27 | let _config = get_or_init_app_config().await.clone(); 28 | 29 | #[cfg(feature = "tempdb")] 30 | get_or_init_mem_db().await; 31 | 32 | // create_response(mock_receptionist_response()).await.unwrap(); 33 | 34 | let slack_arc = setup_slack(); 35 | 36 | // group slack routes into a separate Router so we can use basepath `/slack` & apply slack auth middleware 37 | let slack_api_router = Router::new() 38 | .route("/events", post(axum_handler_slack_events_api)) 39 | .route("/interaction", post(axum_handler_slack_interactions_api)) 40 | .route("/commands", post(axum_handler_handle_slack_commands_api)) 41 | .layer(ServiceBuilder::new().layer_fn(|inner| { 42 | SlackRequestVerifier { 43 | inner, 44 | verifier: SlackEventSignatureVerifier::new( 45 | &env::var("SLACK_SIGNING_SECRET") 46 | .expect("Provide signing secret env var SLACK_SIGNING_SECRET"), 47 | ), 48 | } 49 | })); 50 | 51 | let app = Router::new() 52 | .nest("/slack", slack_api_router) 53 | .route("/", get(|| async { "Hello, World!" })) 54 | .layer(TraceLayer::new_for_http()) 55 | .layer(AddExtensionLayer::new(slack_arc)); 56 | 57 | // .layer(AddExtensionLayer::new(app_responses_cache)); 58 | 59 | let host_address = env::var("HOST_ADDRESS").unwrap_or_else(|_| "0.0.0.0:3000".to_string()); 60 | tracing::debug!("listening on {}", &host_address); 61 | // run it with hyper 62 | axum::Server::bind(&host_address.parse().unwrap()) 63 | .serve(app.into_make_service()) 64 | .await 65 | .unwrap(); 66 | } 67 | 68 | fn setup_tracing() { 69 | // Set the RUST_LOG, if it hasn't been explicitly defined 70 | if std::env::var_os("RUST_LOG").is_none() { 71 | // std::env::set_var("RUST_LOG", "receptionist_bot_rs=debug,tower_http=debug") 72 | std::env::set_var( 73 | "RUST_LOG", 74 | "receptionist_bot_rs=trace,tower_http=trace,receptionist=trace", 75 | ) 76 | } 77 | tracing_subscriber::fmt::init(); 78 | } 79 | -------------------------------------------------------------------------------- /crates/rec_server/tests/integration.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Result}; 2 | use axum::http::Uri; 3 | use receptionist::cloudformation::deploy_mock_receptionist_stack; 4 | use receptionist::config::ReceptionistAppConfig; 5 | use receptionist::{ 6 | create_response, delete_response, get_response_by_id, get_responses_for_collaborator, 7 | }; 8 | use receptionist::{ 9 | get_or_init_dynamo_client, get_responses_for_listener, mock_receptionist_response, 10 | wait_for_table, TABLE_NAME, 11 | }; 12 | 13 | use std::collections::HashMap; 14 | use std::env; 15 | use std::process::{Child, Command}; 16 | use std::thread::sleep; 17 | use std::time::Duration; 18 | use testcontainers::clients::Cli; 19 | use testcontainers::core::WaitFor; 20 | use testcontainers::{Image, ImageArgs}; 21 | 22 | struct LocalstackDynamo { 23 | env_vars: HashMap, 24 | } 25 | 26 | impl Default for LocalstackDynamo { 27 | fn default() -> Self { 28 | let mut env_vars: HashMap = HashMap::new(); 29 | env_vars.insert("SERVICES".to_string(), "dynamodb".to_string()); 30 | env_vars.insert("DEFAULT_REGION".to_string(), "us-east-1".to_string()); 31 | 32 | Self { 33 | env_vars: Default::default(), 34 | } 35 | } 36 | } 37 | 38 | #[derive(Debug, Clone, Default)] 39 | struct LocalstackDynamoImageArgs {} 40 | 41 | impl ImageArgs for LocalstackDynamoImageArgs { 42 | fn into_iterator(self) -> Box> { 43 | Box::new(Vec::default().into_iter()) 44 | } 45 | } 46 | 47 | impl Image for LocalstackDynamo { 48 | type Args = LocalstackDynamoImageArgs; 49 | 50 | fn name(&self) -> String { 51 | "localstack/localstack".to_string() 52 | } 53 | 54 | fn tag(&self) -> String { 55 | "0.13.0.8".to_string() 56 | } 57 | 58 | fn ready_conditions(&self) -> Vec { 59 | vec![WaitFor::seconds(10)] 60 | } 61 | 62 | fn expose_ports(&self) -> Vec { 63 | vec![4566, 4571] 64 | } 65 | 66 | fn env_vars(&self) -> Box + '_> { 67 | Box::new(self.env_vars.iter()) 68 | } 69 | 70 | // fn volumes(&self) -> Box + '_> { 71 | // Box::new(std::iter::empty()) 72 | // } 73 | 74 | // fn entrypoint(&self) -> Option { 75 | // None 76 | // } 77 | 78 | // fn exec_after_start(&self, cs: testcontainers::core::ContainerState) -> Vec { 79 | // Default::default() 80 | // } 81 | } 82 | 83 | pub async fn setup_mock_dynamo_docker() -> Uri { 84 | let client = Cli::default(); 85 | let _docker_host = match env::var("DOCKER_HOST") { 86 | Ok(host_string) => { 87 | dbg!(&host_string); 88 | match host_string.parse::() { 89 | // is there a way out of this without changing to String? 90 | Ok(ok) => ok.host().unwrap().to_owned(), 91 | Err(_) => "0.0.0.0".to_owned(), 92 | } 93 | } 94 | Err(_) => "0.0.0.0".to_owned(), 95 | }; 96 | 97 | let container = client.run(LocalstackDynamo::default()); 98 | 99 | container.start(); 100 | 101 | let localstack_port = container.get_host_port(4566); 102 | let override_url = "localhost".to_string() + ":" + &localstack_port.to_string(); 103 | 104 | let uri = Uri::builder() 105 | .scheme("http") 106 | .authority(override_url) 107 | .path_and_query("") 108 | .build() 109 | .unwrap(); 110 | 111 | wait_for_localstack_container(uri.to_string()) 112 | .await 113 | .unwrap(); 114 | 115 | uri 116 | } 117 | 118 | async fn wait_for_localstack_container(container_url: String) -> Result<()> { 119 | let mut request_count = 0; 120 | let healthcheck_url = container_url + "health"; 121 | 122 | loop { 123 | let client = reqwest::get(&healthcheck_url).await; 124 | 125 | match client { 126 | Ok(ok) => { 127 | if ok.status().eq(&reqwest::StatusCode::from_u16(200).unwrap()) { 128 | println!("succeeded starting dynamo container"); 129 | return Ok(()); 130 | } 131 | } 132 | Err(e) => { 133 | if request_count >= 60 { 134 | bail!("unable to connect to container {}", e); 135 | } 136 | request_count += 1; 137 | std::thread::sleep(std::time::Duration::from_secs(1)) 138 | } 139 | } 140 | } 141 | } 142 | 143 | fn start_real_server(aws_endpoint_url: Option) -> Child { 144 | let mut cmd = Command::new("target/debug/receptionist-bot-rs"); 145 | 146 | if let Some(aws_url) = aws_endpoint_url { 147 | cmd.arg("--aws-endpoint-url").arg(aws_url); 148 | } 149 | 150 | let child = cmd.spawn().expect("Failed to start receptionist service"); 151 | sleep(Duration::from_secs(10)); 152 | 153 | child 154 | } 155 | 156 | fn kill_server(mut child_process: Child) { 157 | dbg!(&child_process.stdout); 158 | child_process 159 | .kill() 160 | .expect("Test APP process did not terminate properly"); 161 | } 162 | 163 | #[tokio::test] 164 | async fn tester_2_electric_boogaloo() { 165 | // create container (it will automatically be killed when dropped from memory) 166 | let client = Cli::default(); 167 | let container = client.run(LocalstackDynamo::default()); 168 | container.start(); 169 | 170 | let localstack_port = container.get_host_port(4566); 171 | let override_url = "localhost".to_string() + ":" + &localstack_port.to_string(); 172 | 173 | let uri = Uri::builder() 174 | .scheme("http") 175 | .authority(override_url) 176 | .path_and_query("") 177 | .build() 178 | .unwrap(); 179 | 180 | // healthcheck that container is running 181 | wait_for_localstack_container(uri.to_string()) 182 | .await 183 | .expect("unable to reach container"); 184 | 185 | ReceptionistAppConfig::set_mock_env_vars(uri.to_string()); 186 | 187 | // println!("big sleep"); 188 | // std::thread::sleep(std::time::Duration::from_secs(2000)); 189 | deploy_mock_receptionist_stack(uri.to_string()) 190 | .await 191 | .unwrap(); 192 | 193 | wait_for_table("table_name", &uri.to_string()).await; 194 | 195 | let mock_1 = mock_receptionist_response(); 196 | let mock_2 = mock_receptionist_response(); 197 | let mock_3 = mock_receptionist_response(); 198 | 199 | create_response(mock_1.clone()).await.unwrap(); 200 | create_response(mock_2.clone()).await.unwrap(); 201 | create_response(mock_3.clone()).await.unwrap(); 202 | 203 | let query_result = get_responses_for_listener(mock_1.clone().listener) 204 | .await 205 | .unwrap(); 206 | 207 | assert_eq!(query_result.len(), 3); 208 | 209 | let found_response = get_response_by_id(&mock_1.id).await.unwrap(); 210 | 211 | assert_eq!(mock_1.clone(), found_response); 212 | 213 | let _resp = delete_response(mock_1.clone()).await.unwrap(); 214 | 215 | let query_result = get_responses_for_listener(mock_1.clone().listener) 216 | .await 217 | .unwrap(); 218 | 219 | assert_eq!(query_result.len(), 2); 220 | 221 | let client = get_or_init_dynamo_client().await; 222 | 223 | let scan_result = client.scan().table_name(TABLE_NAME).send().await.unwrap(); 224 | 225 | dbg!(scan_result); 226 | 227 | let collab = mock_1.collaborators.first().unwrap(); 228 | dbg!(&collab); 229 | 230 | let result = get_responses_for_collaborator(collab).await.unwrap(); 231 | 232 | assert_eq!(result.len(), 2); 233 | } 234 | -------------------------------------------------------------------------------- /crates/rec_server/tests/mocks/mock_receptionist_responses.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "96tcFMjyfag_JJ2gAhmJA", 4 | "listener_type": "slack_channel", 5 | "channel_id": "G01EVFPD63V", 6 | "conditions": [ 7 | { 8 | "type": "for_message", 9 | "criteria": { 10 | "type": "match_regex", 11 | "value": "regex" 12 | } 13 | } 14 | ], 15 | "actions": [ 16 | { 17 | "type": "for_message", 18 | "value": { 19 | "type": "channel_message", 20 | "value": "someone matched with regex!" 21 | } 22 | } 23 | ], 24 | "collaborators": [ 25 | "UK81X0AJZ" 26 | ] 27 | }, 28 | { 29 | "id": "TB6v76uR0YDfeQAvidBu-", 30 | "listener_type": "slack_channel", 31 | "channel_id": "G01EVFPD63V", 32 | "conditions": [ 33 | { 34 | "type": "for_message", 35 | "criteria": { 36 | "type": "match_phrase", 37 | "value": "matching against this phrase" 38 | } 39 | } 40 | ], 41 | "actions": [ 42 | { 43 | "type": "for_message", 44 | "value": { 45 | "type": "threaded_message", 46 | "value": "posting this msg to thread" 47 | } 48 | } 49 | ], 50 | "collaborators": [ 51 | "UK81X0AJZ" 52 | ] 53 | }, 54 | { 55 | "id": "QTjU4drQzC6XCtvz6S9I6", 56 | "listener_type": "slack_channel", 57 | "channel_id": "G01EVFPD63V", 58 | "conditions": [ 59 | { 60 | "type": "for_message", 61 | "criteria": { 62 | "type": "match_phrase", 63 | "value": "page someone" 64 | } 65 | } 66 | ], 67 | "actions": [ 68 | { 69 | "type": "for_message", 70 | "value": { 71 | "type": "msg_oncall_in_thread", 72 | "value": { 73 | "escalation_policy_id": "PLMIEBZ", 74 | "message": "you got paged!" 75 | } 76 | } 77 | } 78 | ], 79 | "collaborators": [ 80 | "UK81X0AJZ" 81 | ] 82 | }, 83 | { 84 | "id": "F8NBRPeNeaMnE4ncGvpBs", 85 | "listener_type": "slack_channel", 86 | "channel_id": "G01EVFPD63V", 87 | "conditions": [ 88 | { 89 | "type": "for_message", 90 | "criteria": { 91 | "type": "match_phrase", 92 | "value": "rust" 93 | } 94 | } 95 | ], 96 | "actions": [ 97 | { 98 | "type": "for_message", 99 | "value": { 100 | "type": "attach_emoji", 101 | "value": "thumbsup" 102 | } 103 | } 104 | ], 105 | "collaborators": [ 106 | "some_slack_id" 107 | ] 108 | } 109 | ] -------------------------------------------------------------------------------- /crates/receptionist/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "receptionist" 3 | version = "0.1.0" 4 | edition = "2021" 5 | rust-version = "1.58" 6 | authors = ["Saxon Hunt "] 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | [features] 10 | default = ["dynamodb"] 11 | dynamodb = [] 12 | tempdb = [] 13 | ansi = ["tracing-subscriber/ansi"] 14 | 15 | 16 | [dependencies] 17 | axum = "0.4" 18 | tokio = { version = "1.17", features = ["sync"]} 19 | slack-morphism = "0.30" 20 | slack-morphism-models = "0.30" 21 | slack-morphism-hyper = "0.30" 22 | # slack-morphism = { git = "https://github.com/noxasaxon/slack-morphism-rust", branch = "fix-permalink"} 23 | # slack-morphism-models = { git = "https://github.com/noxasaxon/slack-morphism-rust", branch = "fix-permalink"} 24 | # slack-morphism-hyper = { git = "https://github.com/noxasaxon/slack-morphism-rust", branch = "fix-permalink"} 25 | serde = "1.0" 26 | serde_json = "1.0" 27 | strum = {version="0.23", features=["derive"]} 28 | tracing = "0.1" 29 | tracing-subscriber = { version="0.3", default-features=false, features = ["env-filter", "tracing-log", "smallvec", "fmt"] } 30 | 31 | regex = "1.5" 32 | nanoid = "0.4" 33 | anyhow = "1.0" 34 | 35 | 36 | aws-config = {version = "0.4", features=["rustls"]} 37 | aws-types = {version = "0.4"} 38 | aws-sdk-dynamodb = {version = "0.4", features=["rustls"]} 39 | aws-sdk-cloudformation = {version = "0.4", features=["rustls"]} 40 | serde_dynamo = { version = "3.0.0-alpha", features = ["aws-sdk-dynamodb+0_4"] } 41 | 42 | # slack verification middleware 43 | hyper = { version = "0.14" } 44 | # hyper-tls = {version = "0.5"} 45 | hyper-rustls = {version = "0.23", features = ["webpki-roots", "rustls-native-certs"]} 46 | rustls = "0.20" 47 | # webpki-roots = "0.22" 48 | 49 | tower = "0.4" 50 | 51 | dotenv = "0.15" 52 | arguably = "2.0" 53 | 54 | derive-alias = "0.1.0" 55 | macro_rules_attribute = "0.1.2" -------------------------------------------------------------------------------- /crates/receptionist/src/config.rs: -------------------------------------------------------------------------------- 1 | use crate::PagerDuty; 2 | use arguably::ArgParser; 3 | use aws_sdk_dynamodb::Credentials; 4 | use dotenv::dotenv; 5 | use tokio::sync::OnceCell; 6 | 7 | pub static APP_CONFIG: OnceCell = OnceCell::const_new(); 8 | pub async fn get_or_init_app_config() -> &'static ReceptionistAppConfig { 9 | APP_CONFIG 10 | .get_or_init(|| async { ReceptionistAppConfig::new() }) 11 | .await 12 | } 13 | 14 | const CLI_OPTION_AWS_URL: &str = "aws-endpoint-url"; 15 | const ENV_FLAG_AWS_ENDPOINT_URL: &str = "AWS_ENDPOINT_URL"; 16 | const ENV_FLAG_AWS_FAKE_CREDS: &str = "AWS_FAKE_CREDS"; 17 | pub const ENV_OPTION_PD_KEY: &str = "PAGERDUTY_TOKEN"; 18 | pub const ENV_OPTION_PD_BASE_URL: &str = "PAGERDUTY_BASE_URL"; 19 | 20 | #[derive(Clone)] 21 | /// can load a .env file to the environment and parse cli args to build the app config 22 | pub struct ReceptionistAppConfig { 23 | pub aws_override_url: Option, 24 | pub aws_fake_creds: Option, 25 | /// if no pagerduty configuration, remove pagerduty actions from Response Creator modal 26 | pub pagerduty_config: Option, 27 | } 28 | 29 | impl ReceptionistAppConfig { 30 | /// load a .env file to the environment and parse cli args to build the app config 31 | /// Supported .env strings: 32 | /// 33 | /// AWS_ENDPOINT_URL 34 | /// PAGERDUTY_TOKEN 35 | /// PAGERDUTY_BASE_URL 36 | /// 37 | /// Supported .env boolean flags: 38 | /// 39 | /// AWS_FAKE_CREDS 40 | /// 41 | pub fn new() -> Self { 42 | dotenv().ok(); 43 | 44 | let mut parser = ArgParser::new() 45 | .option(CLI_OPTION_AWS_URL, "") 46 | .flag("fake") 47 | .helptext(format!( 48 | "Usage: An alternate aws url can be provided via the cli arg `--{CLI_OPTION_AWS_URL}` or by setting \ 49 | the environment variable `{ENV_FLAG_AWS_ENDPOINT_URL}`\n Fake AWS Creds can automatticaly be applied if either the flag --fake is present or env var {ENV_FLAG_AWS_FAKE_CREDS} is true " 50 | )); 51 | 52 | if let Err(e) = parser.parse() { 53 | e.exit(); 54 | } 55 | 56 | let aws_override_url = if parser.found(CLI_OPTION_AWS_URL) { 57 | Some(parser.value(CLI_OPTION_AWS_URL)) 58 | } else { 59 | std::env::var(ENV_FLAG_AWS_ENDPOINT_URL).ok() 60 | }; 61 | 62 | let aws_fake_creds = 63 | if parser.found("fake") || std::env::var(ENV_FLAG_AWS_FAKE_CREDS).is_ok() { 64 | Some(Credentials::new("test", "test", None, None, "test")) 65 | } else { 66 | None 67 | }; 68 | 69 | let pagerduty_config = std::env::var(ENV_OPTION_PD_KEY).map_or(None, |pd_key| { 70 | Some(PagerDuty::new( 71 | pd_key, 72 | std::env::var(ENV_OPTION_PD_BASE_URL).ok(), 73 | )) 74 | }); 75 | 76 | Self { 77 | aws_override_url, 78 | aws_fake_creds, 79 | pagerduty_config, 80 | } 81 | } 82 | 83 | pub fn set_mock_env_vars(aws_url: String) { 84 | std::env::set_var(ENV_FLAG_AWS_ENDPOINT_URL, aws_url); 85 | std::env::set_var(ENV_FLAG_AWS_FAKE_CREDS, "TRUE"); 86 | } 87 | } 88 | 89 | impl Default for ReceptionistAppConfig { 90 | fn default() -> Self { 91 | Self::new() 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /crates/receptionist/src/database/cloudformation.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use aws_sdk_cloudformation::{ 3 | output::CreateStackOutput, Client, Config, Credentials, Endpoint, Region, 4 | }; 5 | 6 | pub fn setup_cf_client( 7 | override_region: Option, 8 | override_url: Option, 9 | override_credentials: Option, 10 | ) -> Client { 11 | // let mut shared_config = aws_config::load_from_env().await; 12 | let mut new_config = Config::builder(); 13 | 14 | if let Some(creds) = override_credentials { 15 | new_config = new_config.credentials_provider(creds); 16 | } 17 | 18 | new_config = new_config.region(Region::new( 19 | override_region.unwrap_or_else(|| "us-east-1".to_string()), 20 | )); 21 | 22 | if let Some(url_override_string) = override_url { 23 | let url_override = 24 | Endpoint::immutable(url_override_string.parse().expect("Failed to parse URI")); 25 | new_config = new_config.endpoint_resolver(url_override); 26 | }; 27 | // let credentials = Credentials::new("test", "test", None, None, "yaboy"); 28 | 29 | Client::from_conf(new_config.build()) 30 | } 31 | 32 | pub async fn deploy_receptionist_stack(cf_client: Client) -> Result { 33 | let template_location = "../receptionist/src/database/dynamo_cf_template.json"; 34 | let template_body = std::fs::read_to_string(template_location).expect("File not found"); 35 | let create_stack_result = cf_client 36 | .create_stack() 37 | .set_stack_name(Some("receptionist-bot-supporting-infra".into())) 38 | .set_template_body(Some(template_body)) 39 | .send() 40 | .await; 41 | 42 | create_stack_result.map_err(|e| anyhow!("Unable to create stack: {}", e)) 43 | } 44 | 45 | pub async fn deploy_mock_receptionist_stack(local_url: String) -> Result { 46 | let fake_creds = Some(Credentials::new("test", "test", None, None, "test")); 47 | let client = setup_cf_client(None, Some(local_url), fake_creds); 48 | 49 | let res = deploy_receptionist_stack(client.clone()).await; 50 | 51 | // dbg!(client.list_stacks().send().await?); 52 | 53 | res 54 | } 55 | -------------------------------------------------------------------------------- /crates/receptionist/src/database/dynamo_cf_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "AWSTemplateFormatVersion": "2010-09-09", 3 | "Resources": { 4 | "receptionist_bot": { 5 | "Type": "AWS::DynamoDB::Table", 6 | "Properties": { 7 | "KeySchema": [ 8 | { 9 | "AttributeName": "pk", 10 | "KeyType": "HASH" 11 | }, 12 | { 13 | "AttributeName": "sk", 14 | "KeyType": "RANGE" 15 | } 16 | ], 17 | "AttributeDefinitions": [ 18 | { 19 | "AttributeName": "pk", 20 | "AttributeType": "S" 21 | }, 22 | { 23 | "AttributeName": "sk", 24 | "AttributeType": "S" 25 | } 26 | ], 27 | "GlobalSecondaryIndexes": [ 28 | { 29 | "IndexName": "InvertedIndex", 30 | "KeySchema": [ 31 | { 32 | "AttributeName": "sk", 33 | "KeyType": "HASH" 34 | }, 35 | { 36 | "AttributeName": "pk", 37 | "KeyType": "RANGE" 38 | } 39 | ], 40 | "Projection": { 41 | "ProjectionType": "ALL" 42 | }, 43 | "ProvisionedThroughput": { 44 | "ReadCapacityUnits": 1, 45 | "WriteCapacityUnits": 1 46 | } 47 | } 48 | ], 49 | "BillingMode": "PROVISIONED", 50 | "TableName": "receptionist_bot", 51 | "ProvisionedThroughput": { 52 | "ReadCapacityUnits": 1, 53 | "WriteCapacityUnits": 1 54 | } 55 | } 56 | }, 57 | "Tablereceptionist_botReadCapacityScalableTarget": { 58 | "Type": "AWS::ApplicationAutoScaling::ScalableTarget", 59 | "DependsOn": "receptionist_bot", 60 | "Properties": { 61 | "ServiceNamespace": "dynamodb", 62 | "ResourceId": "table/receptionist_bot", 63 | "ScalableDimension": "dynamodb:table:ReadCapacityUnits", 64 | "MinCapacity": 1, 65 | "MaxCapacity": 10, 66 | "RoleARN": { 67 | "Fn::Sub": "arn:aws:iam::${AWS::AccountId}:role/aws-service-role/dynamodb.application-autoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_DynamoDBTable" 68 | } 69 | } 70 | }, 71 | "Tablereceptionist_botReadCapacityScalingPolicy": { 72 | "Type": "AWS::ApplicationAutoScaling::ScalingPolicy", 73 | "DependsOn": "Tablereceptionist_botReadCapacityScalableTarget", 74 | "Properties": { 75 | "ServiceNamespace": "dynamodb", 76 | "ResourceId": "table/receptionist_bot", 77 | "ScalableDimension": "dynamodb:table:ReadCapacityUnits", 78 | "PolicyName": "receptionist_bot-read-capacity-scaling-policy", 79 | "PolicyType": "TargetTrackingScaling", 80 | "TargetTrackingScalingPolicyConfiguration": { 81 | "PredefinedMetricSpecification": { 82 | "PredefinedMetricType": "DynamoDBReadCapacityUtilization" 83 | }, 84 | "ScaleOutCooldown": 60, 85 | "ScaleInCooldown": 60, 86 | "TargetValue": 70 87 | } 88 | } 89 | }, 90 | "Tablereceptionist_botWriteCapacityScalableTarget": { 91 | "Type": "AWS::ApplicationAutoScaling::ScalableTarget", 92 | "DependsOn": "receptionist_bot", 93 | "Properties": { 94 | "ServiceNamespace": "dynamodb", 95 | "ResourceId": "table/receptionist_bot", 96 | "ScalableDimension": "dynamodb:table:WriteCapacityUnits", 97 | "MinCapacity": 1, 98 | "MaxCapacity": 10, 99 | "RoleARN": { 100 | "Fn::Sub": "arn:aws:iam::${AWS::AccountId}:role/aws-service-role/dynamodb.application-autoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_DynamoDBTable" 101 | } 102 | } 103 | }, 104 | "Tablereceptionist_botWriteCapacityScalingPolicy": { 105 | "Type": "AWS::ApplicationAutoScaling::ScalingPolicy", 106 | "DependsOn": "Tablereceptionist_botWriteCapacityScalableTarget", 107 | "Properties": { 108 | "ServiceNamespace": "dynamodb", 109 | "ResourceId": "table/receptionist_bot", 110 | "ScalableDimension": "dynamodb:table:WriteCapacityUnits", 111 | "PolicyName": "receptionist_bot-write-capacity-scaling-policy", 112 | "PolicyType": "TargetTrackingScaling", 113 | "TargetTrackingScalingPolicyConfiguration": { 114 | "PredefinedMetricSpecification": { 115 | "PredefinedMetricType": "DynamoDBWriteCapacityUtilization" 116 | }, 117 | "ScaleOutCooldown": 60, 118 | "ScaleInCooldown": 60, 119 | "TargetValue": 70 120 | } 121 | } 122 | }, 123 | "Tablereceptionist_botIndexInvertedIndexReadCapacityScalableTarget": { 124 | "Type": "AWS::ApplicationAutoScaling::ScalableTarget", 125 | "DependsOn": "receptionist_bot", 126 | "Properties": { 127 | "ServiceNamespace": "dynamodb", 128 | "ResourceId": "table/receptionist_bot/index/InvertedIndex", 129 | "ScalableDimension": "dynamodb:index:ReadCapacityUnits", 130 | "MinCapacity": 1, 131 | "MaxCapacity": 10, 132 | "RoleARN": { 133 | "Fn::Sub": "arn:aws:iam::${AWS::AccountId}:role/aws-service-role/dynamodb.application-autoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_DynamoDBTable" 134 | } 135 | } 136 | }, 137 | "Tablereceptionist_botIndexInvertedIndexReadCapacityScalingPolicy": { 138 | "Type": "AWS::ApplicationAutoScaling::ScalingPolicy", 139 | "DependsOn": "Tablereceptionist_botIndexInvertedIndexReadCapacityScalableTarget", 140 | "Properties": { 141 | "ServiceNamespace": "dynamodb", 142 | "ResourceId": "table/receptionist_bot/index/InvertedIndex", 143 | "ScalableDimension": "dynamodb:index:ReadCapacityUnits", 144 | "PolicyName": "receptionist_bot-index-InvertedIndex-read-capacity-scaling-policy", 145 | "PolicyType": "TargetTrackingScaling", 146 | "TargetTrackingScalingPolicyConfiguration": { 147 | "PredefinedMetricSpecification": { 148 | "PredefinedMetricType": "DynamoDBReadCapacityUtilization" 149 | }, 150 | "ScaleOutCooldown": 60, 151 | "ScaleInCooldown": 60, 152 | "TargetValue": 70 153 | } 154 | } 155 | }, 156 | "Tablereceptionist_botIndexInvertedIndexWriteCapacityScalableTarget": { 157 | "Type": "AWS::ApplicationAutoScaling::ScalableTarget", 158 | "DependsOn": "receptionist_bot", 159 | "Properties": { 160 | "ServiceNamespace": "dynamodb", 161 | "ResourceId": "table/receptionist_bot/index/InvertedIndex", 162 | "ScalableDimension": "dynamodb:index:WriteCapacityUnits", 163 | "MinCapacity": 1, 164 | "MaxCapacity": 10, 165 | "RoleARN": { 166 | "Fn::Sub": "arn:aws:iam::${AWS::AccountId}:role/aws-service-role/dynamodb.application-autoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_DynamoDBTable" 167 | } 168 | } 169 | }, 170 | "Tablereceptionist_botIndexInvertedIndexWriteCapacityScalingPolicy": { 171 | "Type": "AWS::ApplicationAutoScaling::ScalingPolicy", 172 | "DependsOn": "Tablereceptionist_botIndexInvertedIndexWriteCapacityScalableTarget", 173 | "Properties": { 174 | "ServiceNamespace": "dynamodb", 175 | "ResourceId": "table/receptionist_bot/index/InvertedIndex", 176 | "ScalableDimension": "dynamodb:index:WriteCapacityUnits", 177 | "PolicyName": "receptionist_bot-index-InvertedIndex-write-capacity-scaling-policy", 178 | "PolicyType": "TargetTrackingScaling", 179 | "TargetTrackingScalingPolicyConfiguration": { 180 | "PredefinedMetricSpecification": { 181 | "PredefinedMetricType": "DynamoDBWriteCapacityUtilization" 182 | }, 183 | "ScaleOutCooldown": 60, 184 | "ScaleInCooldown": 60, 185 | "TargetValue": 70 186 | } 187 | } 188 | } 189 | } 190 | } -------------------------------------------------------------------------------- /crates/receptionist/src/database/in_mem_testdb.rs: -------------------------------------------------------------------------------- 1 | use crate::write_serde_struct_to_file; 2 | use crate::ReceptionistListener; 3 | use crate::ReceptionistResponse; 4 | use anyhow::{bail, Result}; 5 | use std::collections::HashMap; 6 | use tokio::sync::OnceCell; 7 | use tokio::sync::RwLock; 8 | use tokio::sync::RwLockReadGuard; 9 | 10 | pub static IN_MEM_DB: OnceCell>> = 11 | OnceCell::const_new(); 12 | pub async fn get_or_init_mem_db() -> &'static RwLock> { 13 | IN_MEM_DB 14 | .get_or_init(|| async { 15 | let hash_map: HashMap = HashMap::new(); 16 | RwLock::new(hash_map) 17 | }) 18 | .await 19 | } 20 | 21 | pub fn save_db_to_json(temp_db: RwLockReadGuard>) { 22 | let all_responses_as_vec: Vec<&ReceptionistResponse> = 23 | temp_db.iter().map(|(_k, v)| v).collect(); 24 | 25 | write_serde_struct_to_file("all_responses.json", all_responses_as_vec) 26 | } 27 | 28 | pub async fn create_response(rec_response: ReceptionistResponse) -> Result<()> { 29 | let db_lock = get_or_init_mem_db().await; 30 | 31 | let mut all_responses = db_lock.write().await; 32 | 33 | if all_responses.contains_key(&rec_response.id) { 34 | bail!("ID already exists: {}", rec_response.id) 35 | } else { 36 | all_responses.insert(rec_response.id.to_owned(), rec_response); 37 | } 38 | 39 | // let all_responses_read_only = all_responses.downgrade(); 40 | // save_db_to_json(all_responses_read_only); 41 | Ok(()) 42 | } 43 | 44 | pub async fn get_responses_for_listener( 45 | listener: ReceptionistListener, 46 | ) -> Result> { 47 | let db_key = match listener { 48 | ReceptionistListener::SlackChannel { channel_id } => channel_id, 49 | }; 50 | 51 | let db_lock = get_or_init_mem_db().await; 52 | 53 | let all_responses = db_lock.read().await; 54 | 55 | Ok(all_responses 56 | .values() 57 | .filter(|response| response.listener.matches_slack_channel_id(&db_key)) 58 | .map(|r| r.to_owned()) 59 | .collect()) 60 | } 61 | 62 | pub async fn get_response_by_id(response_id: &str) -> Result { 63 | let db_lock = get_or_init_mem_db().await; 64 | 65 | let all_responses = db_lock.read().await; 66 | 67 | match all_responses.get(response_id) { 68 | Some(response) => Ok(response.to_owned()), 69 | None => bail!("no response found for that id"), 70 | } 71 | } 72 | 73 | pub async fn update_response(response: ReceptionistResponse) -> Result<()> { 74 | let db_lock = get_or_init_mem_db().await; 75 | 76 | let mut all_responses = db_lock.write().await; 77 | 78 | all_responses.insert(response.id.to_owned(), response); 79 | 80 | Ok(()) 81 | } 82 | 83 | pub async fn delete_response(response: ReceptionistResponse) -> Result<()> { 84 | let db_lock = get_or_init_mem_db().await; 85 | 86 | let mut all_responses = db_lock.write().await; 87 | 88 | all_responses.remove(&response.id); 89 | 90 | Ok(()) 91 | } 92 | 93 | pub async fn get_responses_for_collaborator(user_id: &str) -> Result> { 94 | let db_lock = get_or_init_mem_db().await; 95 | 96 | let all_responses = db_lock.read().await; 97 | 98 | Ok(all_responses 99 | .values() 100 | .filter(|response| response.collaborators.contains(&user_id.to_owned())) 101 | .map(|r| r.to_owned()) 102 | .collect()) 103 | } 104 | -------------------------------------------------------------------------------- /crates/receptionist/src/database/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod cloudformation; 2 | 3 | #[cfg(feature = "dynamodb")] 4 | mod dynamodb; 5 | #[cfg(feature = "dynamodb")] 6 | pub use dynamodb::*; 7 | 8 | #[cfg(feature = "tempdb")] 9 | mod in_mem_testdb; 10 | #[cfg(feature = "tempdb")] 11 | pub use in_mem_testdb::*; 12 | -------------------------------------------------------------------------------- /crates/receptionist/src/lib.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Twilio Inc. 2 | 3 | #[cfg(all(feature = "tempdb", feature = "dynamodb"))] 4 | compile_error!("cannot enable multiple db features"); 5 | 6 | #[macro_use] 7 | extern crate macro_rules_attribute; 8 | 9 | pub mod config; 10 | mod database; 11 | mod manager_ui; 12 | mod pagerduty; 13 | mod response; 14 | mod response2; 15 | mod slack; 16 | mod utils; 17 | 18 | pub use database::*; 19 | pub use manager_ui::*; 20 | pub use pagerduty::client::PagerDuty; 21 | pub use response::*; 22 | pub use slack::*; 23 | pub use tower::ServiceBuilder; 24 | pub use utils::*; 25 | -------------------------------------------------------------------------------- /crates/receptionist/src/manager_ui/block_actions.rs: -------------------------------------------------------------------------------- 1 | use super::BlockSectionRouter; 2 | #[cfg(any(feature = "tempdb", feature = "dynamodb"))] 3 | use crate::database::get_response_by_id; 4 | use crate::{ 5 | manager_ui::{select_mode, MetaForManagerView}, 6 | SlackStateWorkaround, 7 | }; 8 | use anyhow::{anyhow, bail, Context, Result}; 9 | use slack_morphism::prelude::*; 10 | 11 | pub async fn process_action_event( 12 | actions_event: SlackInteractionBlockActionsEvent, 13 | slack: &SlackStateWorkaround, 14 | ) -> Result<()> { 15 | let slack_view = &actions_event 16 | .view 17 | .ok_or_else(|| anyhow!("Not a view, message block events are unimplemented"))?; 18 | 19 | match slack_view { 20 | SlackView::Home(_) => bail!("Home view actions are unimplemented"), 21 | SlackView::Modal(modal) => { 22 | let view_id = match actions_event.container { 23 | SlackInteractionActionContainer::View(container) => container.view_id, 24 | _ => bail!("invalid state, message events not supported from within a modal"), 25 | }; 26 | 27 | let metadata_str = modal 28 | .private_metadata 29 | .as_deref() 30 | .ok_or_else(|| anyhow!("no private_metadata field in view"))?; 31 | 32 | let mut private_metadata: MetaForManagerView = serde_json::from_str(metadata_str) 33 | .with_context(|| format!("invalid private metadata: {metadata_str}"))?; 34 | 35 | for action in actions_event 36 | .actions 37 | .ok_or_else(|| anyhow!("No actions in action event"))? 38 | { 39 | let (route, index_result) = 40 | BlockSectionRouter::from_action_id_with_index(&action.action_id) 41 | .ok_or_else(|| anyhow!("block action did not match any router ids"))?; 42 | 43 | // Process any Block actions as the input changes (before final submission) 44 | // Handling this isn't necessary for most event types, just when the form needs to be updated 45 | // Example: when a Condition Type or Action Type changes and you need to render new fields 46 | match route { 47 | BlockSectionRouter::ManagerModeSelection => { 48 | let selected_item = action.selected_option.ok_or_else(|| { 49 | anyhow!("No selected item for manager mode selection") 50 | })?; 51 | 52 | let new_metadata = select_mode(&selected_item.value, &private_metadata)?; 53 | 54 | slack 55 | .update_manager_modal_view(view_id.to_owned(), &new_metadata) 56 | .await? 57 | } 58 | BlockSectionRouter::ResponseSelection => { 59 | let selected_item = action 60 | .selected_option 61 | .ok_or_else(|| anyhow!("No selection for response"))?; 62 | 63 | private_metadata.response = 64 | Some(get_response_by_id(&selected_item.value).await?); 65 | 66 | slack 67 | .update_manager_modal_view(view_id.to_owned(), &private_metadata) 68 | .await? 69 | } 70 | BlockSectionRouter::ConditionTypeSelected => { 71 | let mut response = private_metadata 72 | .response 73 | .ok_or_else(|| anyhow!("No Response in view metadata"))?; 74 | 75 | let action_value = action 76 | .selected_option 77 | .ok_or_else(|| anyhow!("no option selected"))? 78 | .value; 79 | 80 | response.update_condition_type(&action_value, index_result?)?; 81 | 82 | private_metadata.response = Some(response); 83 | 84 | slack 85 | .update_manager_modal_view(view_id.to_owned(), &private_metadata) 86 | .await? 87 | } 88 | BlockSectionRouter::ActionTypeSelected => { 89 | let mut response = private_metadata 90 | .response 91 | .ok_or_else(|| anyhow!("No Response in view metadata"))?; 92 | 93 | let rec_action = response 94 | .actions 95 | .get_mut(index_result?) 96 | .ok_or_else(|| anyhow!("action not found"))?; 97 | 98 | rec_action.update_action_type_from_action_info(action)?; 99 | private_metadata.response = Some(response); 100 | 101 | slack 102 | .update_manager_modal_view(view_id.to_owned(), &private_metadata) 103 | .await? 104 | } 105 | BlockSectionRouter::CollaboratorSelection => { 106 | todo!() 107 | } 108 | BlockSectionRouter::ListenerChannelSelected => { 109 | todo!() 110 | } 111 | BlockSectionRouter::MessageConditionValueInput => { 112 | todo!() 113 | } 114 | BlockSectionRouter::AttachEmojiInput => { 115 | todo!() 116 | } 117 | BlockSectionRouter::ReplyThreadedMsgInput => { 118 | todo!() 119 | } 120 | BlockSectionRouter::PostChannelMsgInput => { 121 | todo!() 122 | } 123 | BlockSectionRouter::PDEscalationPolicyInput => todo!(), 124 | BlockSectionRouter::PDThreadedMsgInput => todo!(), 125 | BlockSectionRouter::FwdMsgToChanChannelInput => todo!(), 126 | BlockSectionRouter::FwdMsgToChanMsgContextInput => todo!(), 127 | } 128 | } 129 | Ok(()) 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /crates/receptionist/src/manager_ui/mod.rs: -------------------------------------------------------------------------------- 1 | mod block_actions; 2 | mod router; 3 | mod submission; 4 | mod utils; 5 | // mod state_machine; 6 | 7 | pub use block_actions::*; 8 | pub use router::*; 9 | pub use submission::*; 10 | pub use utils::*; 11 | -------------------------------------------------------------------------------- /crates/receptionist/src/manager_ui/router.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Context, Result}; 2 | use slack_morphism::prelude::*; 3 | use strum::{EnumDiscriminants, EnumIter, EnumString, IntoEnumIterator}; 4 | 5 | /// Route nested action strings to their appropriate handler 6 | #[derive(Debug, EnumDiscriminants, EnumString, EnumIter, strum::Display)] 7 | #[strum(serialize_all = "kebab_case")] 8 | pub enum BlockSectionRouter { 9 | /// single inputs section 10 | ManagerModeSelection, 11 | ResponseSelection, 12 | CollaboratorSelection, 13 | 14 | // Listener Section 15 | ListenerChannelSelected, 16 | 17 | // Condition Section 18 | ConditionTypeSelected, 19 | MessageConditionValueInput, 20 | 21 | // Action Section 22 | ActionTypeSelected, 23 | AttachEmojiInput, 24 | ReplyThreadedMsgInput, 25 | PostChannelMsgInput, 26 | PDEscalationPolicyInput, 27 | PDThreadedMsgInput, 28 | FwdMsgToChanChannelInput, 29 | FwdMsgToChanMsgContextInput, 30 | } 31 | 32 | impl BlockSectionRouter { 33 | fn index_delimiter() -> &'static str { 34 | "_IDX_" 35 | } 36 | 37 | pub fn find_route(action_id: &str) -> Option { 38 | for variant in Self::iter() { 39 | if action_id.starts_with(&variant.to_string()) { 40 | return Some(variant); 41 | } 42 | } 43 | None 44 | } 45 | 46 | pub fn from_action_id(action_id: &SlackActionId) -> Option { 47 | let action_ref = action_id.as_ref(); 48 | for variant in Self::iter() { 49 | if action_ref.starts_with(&variant.to_string()) { 50 | return Some(variant); 51 | } 52 | } 53 | None 54 | } 55 | 56 | pub fn from_action_id_with_index(action_id: &SlackActionId) -> Option<(Self, Result)> { 57 | let action_ref = action_id.as_ref(); 58 | for variant in Self::iter() { 59 | if action_ref.starts_with(&variant.to_string()) { 60 | let index_result = Self::get_index_from_action_id_str(action_ref); 61 | return Some((variant, index_result)); 62 | } 63 | } 64 | None 65 | } 66 | 67 | pub fn from_string_with_index(action_id_str: &str) -> Option<(Self, Result)> { 68 | for variant in Self::iter() { 69 | if action_id_str.starts_with(&variant.to_string()) { 70 | let index_result = Self::get_index_from_action_id_str(action_id_str); 71 | return Some((variant, index_result)); 72 | } 73 | } 74 | None 75 | } 76 | 77 | pub fn to_string_with_index(&self, index: Option) -> String { 78 | self.to_string() + Self::index_delimiter() + &index.unwrap_or_default().to_string() 79 | } 80 | 81 | pub fn to_action_id(&self, index: Option) -> SlackActionId { 82 | self.to_string_with_index(index).into() 83 | } 84 | 85 | pub fn to_block_id(&self, index: Option) -> SlackBlockId { 86 | format!("BLOCK-{}", self.to_string_with_index(index)).into() 87 | } 88 | 89 | pub fn get_index_from_action_id_str(action_id: &str) -> Result { 90 | let (_prefix, suffix) = action_id 91 | .split_once(Self::index_delimiter()) 92 | .ok_or_else(|| anyhow!("index delimiter not found in action_id"))?; 93 | 94 | suffix 95 | .parse::() 96 | .with_context(|| "failed to parse index from action id") 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /crates/receptionist/src/manager_ui/state_machine.rs: -------------------------------------------------------------------------------- 1 | /// scratch pad for possible implementation of state machine for UI flow 2 | /// If implemented, should probably try to use typestate-rs library 3 | /// (as of 12/1/2021, all state machine libs have been investigated and typestate-rs looks most promising for xstate-like usage) 4 | 5 | pub enum ResponseEditorStates { 6 | NoDataToEdit, 7 | EditingListener, 8 | EditingConditions, 9 | EditingActions, 10 | EditingCollaborators, 11 | PreSubmitConfirmation, 12 | Submitted, 13 | } 14 | 15 | impl ResponseEditorStates { 16 | pub fn next_state(&self) -> ResponseEditorStates { 17 | match self { 18 | ResponseEditorStates::NoDataToEdit => ResponseEditorStates::EditingListener, 19 | ResponseEditorStates::EditingListener => ResponseEditorStates::EditingConditions, 20 | ResponseEditorStates::EditingConditions => ResponseEditorStates::EditingActions, 21 | ResponseEditorStates::EditingActions => ResponseEditorStates::EditingCollaborators, 22 | ResponseEditorStates::EditingCollaborators => { 23 | ResponseEditorStates::PreSubmitConfirmation 24 | } 25 | ResponseEditorStates::PreSubmitConfirmation => ResponseEditorStates::Submitted, 26 | ResponseEditorStates::Submitted => ResponseEditorStates::Submitted, 27 | } 28 | } 29 | 30 | pub fn reset(&self) -> ResponseEditorStates { 31 | return ResponseEditorStates::NoDataToEdit; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /crates/receptionist/src/manager_ui/utils.rs: -------------------------------------------------------------------------------- 1 | #[cfg(any(feature = "tempdb", feature = "dynamodb"))] 2 | use crate::database::get_responses_for_collaborator; 3 | use crate::{BlockSectionRouter, ReceptionistResponse}; 4 | use anyhow::{bail, Result}; 5 | use serde::{Deserialize, Serialize}; 6 | use serde_json::to_string; 7 | use slack_morphism::prelude::*; 8 | use std::str::FromStr; 9 | use strum::{Display, EnumDiscriminants, EnumIter, EnumString, IntoEnumIterator}; 10 | 11 | #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] 12 | pub struct MetaForManagerView { 13 | pub user_id: String, 14 | pub current_mode: ManagerViewModes, 15 | pub response: Option, 16 | } 17 | 18 | impl MetaForManagerView { 19 | pub fn new(current_mode: ManagerViewModes, user_id: String) -> Self { 20 | let response = match current_mode { 21 | ManagerViewModes::Home => None, 22 | ManagerViewModes::CreateResponse => Some(ReceptionistResponse::default()), 23 | ManagerViewModes::EditResponse => None, 24 | ManagerViewModes::DeleteResponse => None, 25 | }; 26 | 27 | Self { 28 | current_mode, 29 | response, 30 | user_id, 31 | } 32 | } 33 | } 34 | 35 | impl Default for MetaForManagerView { 36 | fn default() -> Self { 37 | Self { 38 | current_mode: ManagerViewModes::Home, 39 | response: None, 40 | user_id: "".to_string(), 41 | } 42 | } 43 | } 44 | 45 | pub fn select_mode( 46 | mode_str_value: &str, 47 | metadata: &MetaForManagerView, 48 | ) -> Result { 49 | if let Ok(mode) = ManagerViewModes::from_str(mode_str_value) { 50 | Ok(MetaForManagerView::new(mode, metadata.user_id.to_owned())) 51 | } else { 52 | bail!("unable to select mode"); 53 | } 54 | } 55 | 56 | fn manager_view_wrapper(blocks: Vec, meta: &MetaForManagerView) -> SlackView { 57 | SlackView::Modal( 58 | SlackModalView::new("Receptionist Manager".into(), blocks) 59 | .opt_submit(Some("Submit".into())) 60 | .opt_close(Some("Close Manager".into())) 61 | .with_private_metadata(to_string(&meta).expect("unable to serialize private meta")), 62 | ) 63 | } 64 | 65 | pub async fn new_manager_view(meta: &MetaForManagerView) -> SlackView { 66 | let mut blocks: Vec = meta.current_mode.to_editor_blocks(); 67 | 68 | let extra_blocks = match &meta.current_mode { 69 | ManagerViewModes::Home => Vec::default(), 70 | ManagerViewModes::CreateResponse => { 71 | if let Some(response) = &meta.response { 72 | response.to_editor_blocks() 73 | } else { 74 | ReceptionistResponse::default().to_editor_blocks() 75 | } 76 | } 77 | ManagerViewModes::EditResponse => { 78 | let mut editing_blocks = response_selector_blocks(&meta.user_id).await; 79 | if let Some(response) = &meta.response { 80 | editing_blocks.extend(response.to_editor_blocks()) 81 | } 82 | editing_blocks 83 | } 84 | ManagerViewModes::DeleteResponse => response_selector_blocks(&meta.user_id).await, 85 | }; 86 | 87 | blocks.extend(extra_blocks); 88 | manager_view_wrapper(blocks, meta) 89 | } 90 | 91 | async fn response_selector_blocks(user_id: &str) -> Vec { 92 | let responses = get_responses_for_collaborator(user_id) 93 | .await 94 | .expect("error getting responses"); 95 | 96 | if responses.is_empty() { 97 | return slack_blocks![some_into(SlackSectionBlock::new().with_text(pt!( 98 | "You are not collaborator on any responses, please create a new response or ask another user to add you to an existing response." 99 | )))]; 100 | } 101 | 102 | let options: Vec> = responses 103 | .iter() 104 | .map(|res| res.to_response_choice_item()) 105 | .collect(); 106 | 107 | let static_selector = SlackBlockStaticSelectElement::new( 108 | BlockSectionRouter::ResponseSelection.to_action_id(None), 109 | pt!("Select one of your existing Responses"), 110 | ) 111 | .with_options(options); 112 | 113 | slack_blocks![ 114 | some_into( 115 | SlackInputBlock::new( 116 | SlackBlockPlainTextOnly::from("Select one of your existing Responses"), 117 | SlackInputBlockElement::StaticSelect(static_selector) 118 | ) 119 | .with_dispatch_action(true) 120 | .without_optional() 121 | .with_block_id(BlockSectionRouter::ResponseSelection.to_block_id(None)) 122 | ), 123 | some_into(SlackDividerBlock::new()) 124 | ] 125 | } 126 | 127 | #[derive( 128 | EnumDiscriminants, 129 | EnumIter, 130 | Display, 131 | EnumString, 132 | PartialEq, 133 | Debug, 134 | Serialize, 135 | Deserialize, 136 | Clone, 137 | )] 138 | #[strum_discriminants(derive(EnumIter))] 139 | #[strum(serialize_all = "kebab_case")] 140 | pub enum ManagerViewModes { 141 | Home, 142 | CreateResponse, 143 | EditResponse, 144 | DeleteResponse, 145 | } 146 | 147 | impl Default for ManagerViewModes { 148 | fn default() -> Self { 149 | Self::Home 150 | } 151 | } 152 | 153 | impl ManagerViewModes { 154 | fn to_choice_item(&self) -> SlackBlockChoiceItem { 155 | let description = match &self { 156 | ManagerViewModes::Home => "Management Home", 157 | ManagerViewModes::CreateResponse => "Create a Receptionist Response", 158 | ManagerViewModes::EditResponse => "Edit an existing Response", 159 | ManagerViewModes::DeleteResponse => "Delete an existing Response", 160 | }; 161 | 162 | SlackBlockChoiceItem::new(pt!(description), self.to_string()) 163 | } 164 | 165 | fn to_editor_blocks(&self) -> Vec { 166 | let options: Vec> = ManagerViewModes::iter() 167 | .map(|management_type| management_type.to_choice_item()) 168 | .collect(); 169 | 170 | let static_selector = SlackBlockStaticSelectElement::new( 171 | BlockSectionRouter::ManagerModeSelection.to_action_id(None), 172 | pt!("Select a function"), 173 | ) 174 | .with_options(options) 175 | .with_initial_option(self.to_choice_item()); 176 | 177 | slack_blocks![ 178 | some_into( 179 | SlackInputBlock::new( 180 | SlackBlockPlainTextOnly::from("What would you like to do?"), 181 | SlackInputBlockElement::StaticSelect(static_selector) 182 | ) 183 | .with_dispatch_action(true) 184 | .without_optional() 185 | .with_block_id(BlockSectionRouter::ManagerModeSelection.to_block_id(None)) 186 | ), 187 | some_into(SlackDividerBlock::new()) 188 | ] 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /crates/receptionist/src/pagerduty/client.rs: -------------------------------------------------------------------------------- 1 | use crate::pagerduty::models::OncallList; 2 | use anyhow::{anyhow, Result}; 3 | use hyper::client::{Client, HttpConnector}; 4 | use hyper::header::AUTHORIZATION; 5 | use hyper::{Body, Request, Uri}; 6 | use hyper_rustls::{ConfigBuilderExt, HttpsConnector, HttpsConnectorBuilder}; 7 | use serde_json::from_slice; 8 | 9 | const DEFAULT_PD_URL: &str = "https://api.pagerduty.com"; 10 | 11 | #[derive(Debug, Clone)] 12 | pub struct PagerDuty { 13 | auth: String, 14 | base_url: String, 15 | client: Client>, 16 | } 17 | 18 | impl PagerDuty { 19 | pub async fn get_oncalls(&self, escalation_policy: String) -> Result { 20 | let resource = self.base_url.clone() 21 | + "/oncalls?earliest=true&include[]=users&escalation_policy_ids[]=" 22 | + escalation_policy.as_str(); 23 | 24 | let uri: Uri = resource 25 | .parse() 26 | .map_err(|e| anyhow!("invalid url schema: {} {}", resource, e))?; 27 | 28 | let authorization_token = "Token token=".to_owned() + self.auth.as_str(); 29 | let request = Request::builder() 30 | .header(AUTHORIZATION, authorization_token) 31 | .uri(uri) 32 | .body(Body::empty())?; 33 | 34 | let response = self.client.request(request).await?; 35 | let bytes = hyper::body::to_bytes(response.into_body()).await?; 36 | 37 | let mut oncalls_list: OncallList = from_slice(bytes.as_ref())?; 38 | 39 | oncalls_list 40 | .oncalls 41 | .sort_by(|a, b| a.escalation_level.cmp(&b.escalation_level)); 42 | 43 | Ok(oncalls_list) 44 | } 45 | } 46 | 47 | impl PagerDuty { 48 | pub fn new(auth: String, base_url: Option) -> Self { 49 | // let https_connector = HttpsConnector::new(); 50 | 51 | // let mut root_store = rustls::RootCertStore::empty(); 52 | // root_store.add_server_trust_anchors(webpki_roots::TLS_SERVER_ROOTS.0.iter().map(|ta| { 53 | // rustls::OwnedTrustAnchor::from_subject_spki_name_constraints( 54 | // ta.subject, 55 | // ta.spki, 56 | // ta.name_constraints, 57 | // ) 58 | // })); 59 | let https = HttpsConnectorBuilder::new() 60 | .with_tls_config( 61 | rustls::ClientConfig::builder() 62 | .with_safe_defaults() 63 | // .with_native_roots() 64 | // .with_root_certificates(root_store) 65 | .with_native_roots() 66 | // .with_webpki_roots() 67 | .with_no_client_auth(), 68 | ) 69 | .https_or_http() 70 | .enable_http1() 71 | .build(); 72 | let client = Client::builder().build::<_, Body>(https); 73 | 74 | Self { 75 | auth, 76 | base_url: base_url.unwrap_or_else(|| DEFAULT_PD_URL.to_string()), 77 | client, 78 | } 79 | } 80 | } 81 | 82 | #[cfg(test)] 83 | mod tests { 84 | use super::*; 85 | 86 | #[tokio::test] 87 | #[ignore] 88 | async fn should_get_oncalls() { 89 | dotenv::dotenv().ok(); 90 | let pd = PagerDuty::new( 91 | std::env::var("PAGERDUTY_TOKEN") 92 | .expect("Missing PAGERDUTY_TOKEN environment variable. Add it to .env file."), 93 | std::env::var("PAGERDUTY_BASE_URL").ok(), 94 | ); 95 | 96 | // let resp = pd.get_oncalls(String::from("PS32312")).await; 97 | let resp = pd.get_oncalls(String::from("PLMIEBZ")).await; 98 | 99 | match resp { 100 | Ok(ok) => { 101 | assert!(!ok.oncalls.is_empty()) 102 | } 103 | Err(ko) => { 104 | dbg!(ko); 105 | assert!(false, "Unexpected failure") 106 | } 107 | } 108 | assert!(true); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /crates/receptionist/src/pagerduty/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod client; 2 | pub mod models; 3 | -------------------------------------------------------------------------------- /crates/receptionist/src/pagerduty/models.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Serialize, Deserialize, Debug, PartialEq)] 4 | pub struct OncallList { 5 | pub oncalls: Vec, 6 | } 7 | 8 | #[derive(Serialize, Deserialize, Debug, PartialEq)] 9 | pub struct OncallInstance { 10 | escalation_policy: EscalationPolicy, 11 | pub user: User, 12 | schedule: Option, 13 | pub escalation_level: u8, 14 | start: Option, 15 | end: Option, 16 | } 17 | 18 | #[derive(Serialize, Deserialize, Debug, PartialEq)] 19 | struct EscalationPolicy {} 20 | 21 | #[derive(Serialize, Deserialize, Debug, PartialEq)] 22 | pub struct User { 23 | pub name: String, 24 | pub email: String, 25 | pub id: String, 26 | #[serde(rename = "type")] 27 | pub user_type: String, 28 | } 29 | 30 | #[derive(Serialize, Deserialize, Debug, PartialEq)] 31 | struct Schedule {} 32 | 33 | #[cfg(test)] 34 | mod tests { 35 | use super::*; 36 | use serde_json::{from_value, json, to_value}; 37 | 38 | #[test] 39 | fn should_serialize_oncall_list() { 40 | let oncalls_list_json = json!( 41 | {"oncalls" : [{ 42 | "escalation_policy": {}, 43 | "user": { 44 | "name": "receptionist bot", 45 | "email": "bot@receptionist.com", 46 | "id": "PS12345", 47 | "type": "admin" 48 | }, 49 | "schedule": {}, 50 | "escalation_level": 1, 51 | "start": "any date string", 52 | "end": "any date string", 53 | }]} 54 | ); 55 | 56 | let oncalls_list: OncallList = from_value(oncalls_list_json.clone()).unwrap(); 57 | 58 | assert_eq!(to_value(oncalls_list).unwrap(), oncalls_list_json); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /crates/receptionist/src/response/conditions.rs: -------------------------------------------------------------------------------- 1 | use crate::{BlockSectionRouter, ReceptionistListener, SlackBlockValidationError}; 2 | use anyhow::{anyhow, bail, Result}; 3 | use regex::Regex; 4 | use serde::{Deserialize, Serialize}; 5 | use slack_morphism::prelude::*; 6 | use std::str::FromStr; 7 | use strum::{EnumDiscriminants, EnumIter, EnumString, IntoEnumIterator}; 8 | 9 | #[derive(Debug, Serialize, Deserialize, PartialEq, EnumDiscriminants, Clone)] 10 | #[strum_discriminants(derive(EnumIter))] 11 | #[serde(rename_all = "snake_case", tag = "type", content = "criteria")] 12 | #[strum(serialize_all = "kebab_case")] 13 | pub enum ReceptionistCondition { 14 | ForMessage(MessageCondition), 15 | } 16 | 17 | impl ReceptionistCondition { 18 | pub fn is_valid(&self) -> bool { 19 | match self { 20 | ReceptionistCondition::ForMessage(message_condition) => message_condition.is_valid(), 21 | } 22 | } 23 | 24 | pub fn default_from_listener(listener: &ReceptionistListener) -> Self { 25 | match listener { 26 | ReceptionistListener::SlackChannel { .. } => { 27 | Self::ForMessage(MessageCondition::MatchPhrase("".into())) 28 | } 29 | } 30 | } 31 | 32 | pub fn default_blocks( 33 | listener: &ReceptionistListener, 34 | index: Option, 35 | ) -> Vec { 36 | Self::default_from_listener(listener).to_editor_blocks(index) 37 | } 38 | 39 | pub fn to_editor_blocks(&self, index: Option) -> Vec { 40 | match self { 41 | ReceptionistCondition::ForMessage(message_condition) => { 42 | message_condition.to_editor_blocks(index) 43 | } 44 | } 45 | } 46 | 47 | pub fn message_phrase(phrase: &str) -> Self { 48 | Self::ForMessage(MessageCondition::MatchPhrase(phrase.to_string())) 49 | } 50 | 51 | pub fn iter_discriminants() -> ReceptionistConditionDiscriminantsIter { 52 | ReceptionistConditionDiscriminants::iter() 53 | } 54 | 55 | pub fn update_condition_type_from_action_info( 56 | &mut self, 57 | action: SlackInteractionActionInfo, 58 | ) -> Result<()> { 59 | let action_value = action 60 | .selected_option 61 | .ok_or_else(|| anyhow!("no option selected"))? 62 | .value; 63 | self.update_condition_type(&action_value)?; 64 | Ok(()) 65 | } 66 | 67 | pub fn update_condition_type(&mut self, type_str: &str) -> Result<()> { 68 | match self { 69 | Self::ForMessage(message_condition) => { 70 | let mut new_variant = MessageCondition::from_str(type_str)?; 71 | match message_condition { 72 | MessageCondition::MatchPhrase(cur_str) => { 73 | new_variant.update_string(std::mem::take(cur_str)) 74 | } 75 | MessageCondition::MatchRegex(cur_str) => { 76 | new_variant.update_string(std::mem::take(cur_str)) 77 | } 78 | }; 79 | *message_condition = new_variant; 80 | } 81 | } 82 | Ok(()) 83 | } 84 | 85 | pub fn update_message_condition_string(&mut self, new_str: String) -> Result<()> { 86 | match self { 87 | ReceptionistCondition::ForMessage(message_condition) => { 88 | message_condition.update_string(new_str); 89 | } 90 | #[allow(unreachable_patterns)] 91 | _ => bail!("Not a message condition"), 92 | }; 93 | Ok(()) 94 | } 95 | 96 | pub fn validate(&self, index: Option) -> Option { 97 | match self { 98 | ReceptionistCondition::ForMessage(msg_condition) => match msg_condition { 99 | MessageCondition::MatchPhrase(phrase) => { 100 | if phrase.is_empty() { 101 | Some(SlackBlockValidationError { 102 | block_id: BlockSectionRouter::MessageConditionValueInput 103 | .to_block_id(index), 104 | error_message: "input field is empty".to_string(), 105 | }) 106 | } else { 107 | None 108 | } 109 | } 110 | MessageCondition::MatchRegex(re_str) => match Regex::new(re_str) { 111 | Ok(_) => None, 112 | Err(re_err) => Some(SlackBlockValidationError { 113 | block_id: BlockSectionRouter::MessageConditionValueInput.to_block_id(index), 114 | error_message: re_err.to_string(), 115 | }), 116 | }, 117 | }, 118 | } 119 | } 120 | } 121 | 122 | #[derive(Debug, Serialize, Deserialize, PartialEq, EnumIter, strum::Display, Clone, EnumString)] 123 | #[serde(rename_all = "snake_case", tag = "type", content = "value")] 124 | #[strum(serialize_all = "kebab_case")] 125 | pub enum MessageCondition { 126 | MatchPhrase(String), 127 | MatchRegex(String), 128 | } 129 | 130 | impl MessageCondition { 131 | pub fn is_valid(&self) -> bool { 132 | match self { 133 | MessageCondition::MatchPhrase(s) => !s.is_empty(), 134 | MessageCondition::MatchRegex(s) => !s.is_empty() && Regex::new(s).is_ok(), 135 | } 136 | } 137 | 138 | pub fn update_string(&mut self, new_string: String) { 139 | *self = match self { 140 | Self::MatchPhrase(_current) => Self::MatchPhrase(new_string), 141 | Self::MatchRegex(_current) => Self::MatchRegex(new_string), 142 | }; 143 | } 144 | 145 | pub fn to_choice_item(&self) -> SlackBlockChoiceItem { 146 | SlackBlockChoiceItem::new(pt!(self.to_description()), self.to_string()) 147 | } 148 | 149 | fn to_description(&self) -> &str { 150 | match &self { 151 | MessageCondition::MatchPhrase(_) => "Phrase Match", 152 | MessageCondition::MatchRegex(_) => "Regex Match", 153 | } 154 | } 155 | 156 | pub fn to_choice_items() -> Vec> { 157 | Self::iter() 158 | .map(|variant| variant.to_choice_item()) 159 | .collect() 160 | } 161 | 162 | fn to_type_selector_blocks(&self, index: Option) -> Vec { 163 | slack_blocks![some_into( 164 | SlackSectionBlock::new() 165 | .with_text(md!(":clipboard: Select a match condition type")) 166 | .with_accessory(SlackSectionBlockElement::StaticSelect( 167 | SlackBlockStaticSelectElement::new( 168 | BlockSectionRouter::ConditionTypeSelected.to_action_id(index), 169 | pt!("select matching Type") 170 | ) 171 | .with_options(Self::to_choice_items()) 172 | .with_initial_option(self.to_choice_item()) 173 | )) 174 | .with_block_id(BlockSectionRouter::ConditionTypeSelected.to_block_id(index),) 175 | )] 176 | } 177 | 178 | fn to_value_input_blocks(&self, index: Option) -> Vec { 179 | match self { 180 | MessageCondition::MatchPhrase(phrase) => { 181 | let input_element = SlackBlockPlainTextInputElement::new( 182 | BlockSectionRouter::MessageConditionValueInput.to_action_id(index), 183 | pt!("Phrase to match against"), 184 | ); 185 | 186 | let input_element = if phrase.is_empty() { 187 | input_element 188 | } else { 189 | input_element.with_initial_value(phrase.to_owned()) 190 | }; 191 | 192 | slack_blocks![some_into( 193 | SlackInputBlock::new( 194 | pt!("Message contains this phrase:"), 195 | SlackInputBlockElement::PlainTextInput(input_element) 196 | ) 197 | .with_block_id( 198 | BlockSectionRouter::MessageConditionValueInput.to_block_id(index) 199 | ) 200 | )] 201 | } 202 | 203 | MessageCondition::MatchRegex(regex_str) => { 204 | let input_element = SlackBlockPlainTextInputElement::new( 205 | BlockSectionRouter::MessageConditionValueInput.to_action_id(index), 206 | pt!("Regex pattern to match against"), 207 | ); 208 | 209 | let input_element = if regex_str.is_empty() { 210 | input_element 211 | } else { 212 | input_element.with_initial_value(regex_str.to_owned()) 213 | }; 214 | 215 | let context: SlackContextBlockElement = 216 | md!("_Tip:_ Use regex101.com to validate your syntax first :writing_hand:"); 217 | 218 | slack_blocks![ 219 | some_into( 220 | SlackInputBlock::new( 221 | pt!("Message contains a match to this Regex pattern:"), 222 | SlackInputBlockElement::PlainTextInput(input_element) 223 | ) 224 | .with_block_id( 225 | BlockSectionRouter::MessageConditionValueInput.to_block_id(index) 226 | ) 227 | ), 228 | some_into(SlackContextBlock::new(vec![context])) 229 | ] 230 | } 231 | } 232 | } 233 | 234 | pub fn to_editor_blocks(&self, index: Option) -> Vec { 235 | [ 236 | self.to_type_selector_blocks(index), 237 | self.to_value_input_blocks(index), 238 | vec![SlackDividerBlock::new().into()], 239 | ] 240 | .concat() 241 | } 242 | 243 | pub fn should_trigger(&self, message: &str) -> bool { 244 | match &self { 245 | MessageCondition::MatchPhrase(phrase) => { 246 | let re = Regex::new(format!("\\b{phrase}\\b").as_str()) 247 | .expect("Unable to compile regex for search phrase"); 248 | re.is_match(message) 249 | } 250 | MessageCondition::MatchRegex(reg) => { 251 | let re = Regex::new(reg) 252 | .expect("Unable to compile regex for custom regex pattern search phrase"); 253 | re.is_match(message) 254 | } 255 | } 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /crates/receptionist/src/response/listeners.rs: -------------------------------------------------------------------------------- 1 | use crate::{BlockSectionRouter, SlackBlockValidationError}; 2 | use serde::{Deserialize, Serialize}; 3 | use slack_morphism::prelude::*; 4 | use strum::{EnumIter, EnumString}; 5 | 6 | #[derive(Debug, Serialize, Deserialize, EnumString, PartialEq, EnumIter, Clone, strum::Display)] 7 | #[serde(tag = "listener_type", rename_all = "snake_case")] 8 | #[strum(serialize_all = "kebab_case")] 9 | pub enum ReceptionistListener { 10 | SlackChannel { channel_id: String }, 11 | // SlackCommandKeyword { command: String, keyword: String }, 12 | } 13 | 14 | impl Default for ReceptionistListener { 15 | fn default() -> Self { 16 | Self::SlackChannel { 17 | channel_id: "".into(), 18 | } 19 | } 20 | } 21 | 22 | impl ReceptionistListener { 23 | pub fn matches_slack_channel_id(&self, incoming_channel: &str) -> bool { 24 | match self { 25 | ReceptionistListener::SlackChannel { channel_id } => channel_id == incoming_channel, 26 | } 27 | } 28 | 29 | pub fn validate(&self) -> Option { 30 | match self { 31 | ReceptionistListener::SlackChannel { channel_id } => { 32 | if channel_id.is_empty() { 33 | Some(SlackBlockValidationError { 34 | block_id: BlockSectionRouter::ListenerChannelSelected.to_block_id(None), 35 | error_message: "No channel selected".to_string(), 36 | }) 37 | } else { 38 | None 39 | } 40 | } 41 | } 42 | } 43 | 44 | pub fn default_blocks() -> Vec { 45 | slack_blocks![ 46 | some_into( 47 | SlackSectionBlock::new() 48 | .with_text(md!(":slack: Select a Channel")) 49 | .with_accessory(SlackSectionBlockElement::ConversationsSelect( 50 | SlackBlockConversationsSelectElement::new( 51 | BlockSectionRouter::ListenerChannelSelected.to_action_id(None), 52 | pt!("#my-channel"), 53 | ), 54 | )) 55 | .with_block_id(BlockSectionRouter::ListenerChannelSelected.to_block_id(None)) 56 | ), 57 | some_into(SlackDividerBlock::new()) 58 | ] 59 | } 60 | 61 | pub fn to_editor_blocks(&self) -> Vec { 62 | match self { 63 | ReceptionistListener::SlackChannel { channel_id } => { 64 | let conversations_select_element = SlackBlockConversationsSelectElement::new( 65 | BlockSectionRouter::ListenerChannelSelected.to_action_id(None), 66 | pt!("#my-channel"), 67 | ); 68 | 69 | let conversations_select_element = if !channel_id.is_empty() { 70 | conversations_select_element.with_initial_conversation(channel_id.into()) 71 | } else { 72 | conversations_select_element 73 | }; 74 | 75 | slack_blocks![ 76 | some_into( 77 | SlackSectionBlock::new() 78 | .with_text(md!( 79 | ":slack: Select a Channel :point_right:" 80 | )) 81 | .with_accessory(SlackSectionBlockElement::ConversationsSelect( 82 | conversations_select_element 83 | )) 84 | .with_block_id( 85 | BlockSectionRouter::ListenerChannelSelected.to_block_id(None) 86 | ) 87 | ), 88 | some_into(SlackDividerBlock::new()) 89 | ] 90 | } 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /crates/receptionist/src/response/mod.rs: -------------------------------------------------------------------------------- 1 | mod actions; 2 | mod conditions; 3 | mod listeners; 4 | mod responses; 5 | mod utils; 6 | 7 | pub use actions::{MessageAction, ReceptionistAction}; 8 | pub use conditions::{MessageCondition, ReceptionistCondition}; 9 | pub use listeners::ReceptionistListener; 10 | pub use responses::*; 11 | -------------------------------------------------------------------------------- /crates/receptionist/src/response/responses.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | add_emoji_colons, BlockSectionRouter, MessageAction, ReceptionistAction, ReceptionistCondition, 3 | ReceptionistListener, SlackBlockValidationError, 4 | }; 5 | use anyhow::{anyhow, bail, Result}; 6 | use nanoid::nanoid; 7 | use serde::{Deserialize, Serialize}; 8 | use slack_morphism::prelude::*; 9 | 10 | #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] 11 | pub struct ReceptionistResponse { 12 | pub id: String, 13 | #[serde(flatten)] 14 | pub listener: ReceptionistListener, 15 | pub conditions: Vec, 16 | pub actions: Vec, 17 | pub collaborators: Vec, 18 | } 19 | 20 | impl Default for ReceptionistResponse { 21 | fn default() -> Self { 22 | let listener = ReceptionistListener::default(); 23 | Self { 24 | id: Self::new_id(), 25 | actions: vec![ReceptionistAction::default_from_listener(&listener)], 26 | conditions: vec![ReceptionistCondition::default_from_listener(&listener)], 27 | collaborators: vec![], 28 | listener, 29 | } 30 | } 31 | } 32 | 33 | impl ReceptionistResponse { 34 | fn new_id() -> String { 35 | nanoid!() 36 | } 37 | 38 | pub fn new( 39 | collaborators: Vec, 40 | listener: ReceptionistListener, 41 | actions: Vec, 42 | conditions: Vec, 43 | ) -> Self { 44 | Self { 45 | id: Self::new_id(), 46 | listener, 47 | collaborators, 48 | actions, 49 | conditions, 50 | } 51 | } 52 | 53 | /// Check if any of this responses trigger conditions are met. 54 | /// conditions are not paired with a specific action, any trigger will fire all actions 55 | pub fn check_for_match(&self, message: &str) -> bool { 56 | for match_obj in &self.conditions { 57 | match &match_obj { 58 | ReceptionistCondition::ForMessage(msg_trigger) => { 59 | if msg_trigger.should_trigger(message) { 60 | return true; 61 | } 62 | } 63 | } 64 | } 65 | false 66 | } 67 | 68 | pub fn get_action_mut(&mut self, index: usize) -> Result<&mut ReceptionistAction> { 69 | self.actions 70 | .get_mut(index) 71 | .ok_or_else(|| anyhow!("action not found")) 72 | } 73 | 74 | pub fn to_editor_blocks(&self) -> Vec { 75 | let listener_blocks = self.listener.to_editor_blocks(); 76 | 77 | let conditions_blocks: Vec = self 78 | .conditions 79 | .iter() 80 | .enumerate() 81 | .flat_map(|(index, condition)| condition.to_editor_blocks(Some(index))) 82 | .collect(); 83 | 84 | let actions_blocks: Vec = self 85 | .actions 86 | .iter() 87 | .enumerate() 88 | .flat_map(|(index, action)| action.to_editor_blocks(Some(index))) 89 | .collect(); 90 | 91 | // TODO collaborator blocks 92 | 93 | [ 94 | self.build_collaborators_editor_blocks(), 95 | listener_blocks, 96 | conditions_blocks, 97 | actions_blocks, 98 | ] 99 | .concat() 100 | } 101 | 102 | pub fn update_condition_type(&mut self, type_str: &str, index: usize) -> Result<()> { 103 | let condition = self 104 | .conditions 105 | .get_mut(index) 106 | .ok_or_else(|| anyhow!("condition not found"))?; 107 | 108 | condition.update_condition_type(type_str) 109 | } 110 | 111 | pub fn update_action_type(&mut self, type_str: &str, index: usize) -> Result<()> { 112 | let action = self 113 | .actions 114 | .get_mut(index) 115 | .ok_or_else(|| anyhow!("condition not found"))?; 116 | 117 | action.update_action_type(type_str) 118 | } 119 | 120 | pub fn update_slack_channel(&mut self, conversation_id: String) -> Result<()> { 121 | match &self.listener { 122 | ReceptionistListener::SlackChannel { .. } => { 123 | self.listener = ReceptionistListener::SlackChannel { 124 | channel_id: conversation_id, 125 | }; 126 | Ok(()) 127 | } 128 | #[allow(unreachable_patterns)] 129 | _ => bail!("Not a slack channel listener"), 130 | } 131 | } 132 | 133 | pub fn update_message_condition_string(&mut self, new_str: String, index: usize) -> Result<()> { 134 | let condition = self 135 | .conditions 136 | .get_mut(index) 137 | .ok_or_else(|| anyhow!("condition not found"))?; 138 | 139 | condition.update_message_condition_string(new_str) 140 | } 141 | 142 | pub fn validate(&self) -> Option> { 143 | let mut validation_errors: Vec = Vec::default(); 144 | 145 | if let Some(validation_err) = self.listener.validate() { 146 | validation_errors.push(validation_err) 147 | } 148 | 149 | for (index, condition) in self.conditions.iter().enumerate() { 150 | if let Some(validation_err) = condition.validate(Some(index)) { 151 | validation_errors.push(validation_err) 152 | } 153 | } 154 | 155 | for (index, action) in self.actions.iter().enumerate() { 156 | if let Some(validation_err) = action.validate(Some(index)) { 157 | validation_errors.push(validation_err) 158 | } 159 | } 160 | 161 | // not necessary because collaborators will never be empty ? 162 | // if self.collaborators.is_empty() { 163 | // validation_errors.push(SlackBlockValidationError { 164 | // block_id: BlockSectionRouter::CollaboratorSelection.to_block_id(None), 165 | // error_message: "empty".to_string(), 166 | // }) 167 | // } 168 | if !validation_errors.is_empty() { 169 | Some(validation_errors) 170 | } else { 171 | None 172 | } 173 | } 174 | 175 | fn build_collaborators_editor_blocks(&self) -> Vec { 176 | let multi_users_select_element = SlackBlockMultiUsersSelectElement::new( 177 | BlockSectionRouter::CollaboratorSelection.to_action_id(None), 178 | pt!("Select Collaborators"), 179 | ); 180 | 181 | slack_blocks![ 182 | some_into( 183 | SlackSectionBlock::new() 184 | .with_text(md!( 185 | ":busts_in_silhouette: Users that can edit this Response" 186 | )) 187 | .with_accessory(SlackSectionBlockElement::MultiUsersSelect( 188 | multi_users_select_element 189 | )) 190 | .with_block_id(BlockSectionRouter::CollaboratorSelection.to_block_id(None)) 191 | ), 192 | some_into(SlackDividerBlock::new()) 193 | ] 194 | } 195 | 196 | /// Displays info about this entire Response on a single line in a "dropdown" selection box 197 | pub fn to_response_choice_item(&self) -> SlackBlockChoiceItem { 198 | let listener = match &self.listener { 199 | ReceptionistListener::SlackChannel { channel_id } => format!("#<#{channel_id}>"), 200 | }; 201 | 202 | let actions: String = self 203 | .actions 204 | .iter() 205 | .map(|action| match action { 206 | ReceptionistAction::ForMessage(msg_act) => match msg_act { 207 | MessageAction::AttachEmoji(emoji) => add_emoji_colons(emoji), 208 | MessageAction::ThreadedMessage(msg) => msg.to_owned(), 209 | MessageAction::ChannelMessage(msg) => msg.to_owned(), 210 | MessageAction::MsgOncallInThread { 211 | escalation_policy_id, 212 | message, 213 | } => format!( 214 | "Tag Oncall: {escalation_policy_id} - {}..", 215 | message.chars().take(10).collect::() 216 | ), 217 | MessageAction::ForwardMessageToChannel { 218 | channel, 219 | msg_context, 220 | } => format!( 221 | "Fwd Message: {channel} - {}..", 222 | msg_context.chars().take(10).collect::() 223 | ), 224 | }, 225 | }) 226 | .collect(); 227 | 228 | let full_text = [listener, actions].join(" | "); 229 | 230 | SlackBlockChoiceItem::new(pt!(full_text), self.id.to_owned()) 231 | } 232 | } 233 | 234 | pub fn mock_receptionist_response() -> ReceptionistResponse { 235 | ReceptionistResponse::new( 236 | vec!["some_slack_id".into()], 237 | ReceptionistListener::SlackChannel { 238 | channel_id: std::env::var("TEST_CHANNEL_ID") 239 | .unwrap_or_else(|_err| "".to_string()), 240 | }, 241 | vec![ReceptionistAction::ForMessage(MessageAction::AttachEmoji( 242 | "thumbsup".to_string(), 243 | ))], 244 | vec![ReceptionistCondition::message_phrase("rust")], 245 | ) 246 | } 247 | 248 | #[cfg(test)] 249 | mod tests { 250 | use super::*; 251 | use serde_json::{from_str, to_string_pretty}; 252 | 253 | #[test] 254 | fn test_serde_enums() { 255 | let action_with_enum = mock_receptionist_response(); 256 | 257 | let as_string = to_string_pretty(&action_with_enum); 258 | assert!(as_string.is_ok()); 259 | let as_string = as_string.unwrap(); 260 | print!("\n{}\n\n", &as_string); 261 | 262 | let deserialized = from_str::(&as_string); 263 | assert!(deserialized.is_ok()); 264 | let deserialized = deserialized.unwrap(); 265 | 266 | assert_eq!(&action_with_enum, &deserialized); 267 | 268 | let back_to_string = to_string_pretty(&deserialized); 269 | assert!(back_to_string.is_ok()); 270 | let back_to_string = back_to_string.unwrap(); 271 | 272 | assert_eq!(back_to_string, as_string); 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /crates/receptionist/src/response/utils.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use crate::BlockSectionRouter; 4 | use slack_morphism::prelude::*; 5 | use strum::IntoEnumIterator; 6 | 7 | pub fn slack_plain_text_input_block_for_view( 8 | router_variant: BlockSectionRouter, 9 | index: Option, 10 | existing_value: String, 11 | placeholder: &str, 12 | label: &str, 13 | ) -> Vec { 14 | let input_element = 15 | SlackBlockPlainTextInputElement::new(router_variant.to_action_id(index), pt!(placeholder)); 16 | let input_element = if existing_value.is_empty() { 17 | input_element 18 | } else { 19 | input_element.with_initial_value(existing_value) 20 | }; 21 | 22 | slack_blocks![some_into( 23 | SlackInputBlock::new( 24 | pt!(label), 25 | SlackInputBlockElement::PlainTextInput(input_element) 26 | ) 27 | .with_block_id(router_variant.to_block_id(index),) 28 | )] 29 | } 30 | -------------------------------------------------------------------------------- /crates/receptionist/src/response2/actions.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self}; 2 | 3 | // use derive_alias::derive_alias; 4 | use serde::{Deserialize, Serialize}; 5 | use slack_morphism::prelude::*; 6 | use strum::{Display, EnumDiscriminants, EnumIter, EnumString, IntoEnumIterator}; 7 | 8 | // Generates a macro (`derive_cmp`) that will attach the listed derives to a given item 9 | derive_alias! { 10 | #[derive(EnumUtils!)] = #[derive(strum::Display, strum::EnumDiscriminants, strum::EnumIter, strum::EnumString, Debug, serde::Serialize, serde::Deserialize, PartialEq)]; 11 | #[derive(Serde!)] = #[derive(serde::Serialize, serde::Deserialize)]; 12 | } 13 | 14 | pub trait SlackEditor: fmt::Display + IntoEnumIterator { 15 | fn to_description(&self) -> &str; 16 | 17 | fn to_type_selector_blocks(&self, index: Option) -> Vec; 18 | 19 | fn to_value_input_blocks(&self, index: Option) -> Vec; 20 | 21 | fn to_editor_blocks(&self, index: Option) -> Vec; 22 | 23 | fn to_choice_item(&self) -> SlackBlockChoiceItem { 24 | SlackBlockChoiceItem::new(pt!(self.to_description()), self.to_string()) 25 | } 26 | 27 | fn to_choice_items() -> Vec> { 28 | Self::iter() 29 | .map(|variant| variant.to_choice_item()) 30 | .collect() 31 | } 32 | } 33 | 34 | pub trait ForListenerEvent { 35 | fn listeners(&self) -> Vec; 36 | } 37 | 38 | #[derive(EnumUtils!)] 39 | pub enum ListenerEvent { 40 | SlackChannelMessage, 41 | SlackSlashCommand, 42 | } 43 | 44 | #[derive(EnumUtils!)] 45 | pub enum Action { 46 | AttachEmoji(String), 47 | /// Post message in thread of the triggered message 48 | ThreadedMessage(String), 49 | // /// Send message to same channel that triggered message 50 | // ChannelMessage(String), 51 | // MsgOncallInThread { 52 | // escalation_policy_id: String, 53 | // message: String, 54 | // }, 55 | // /// Forward the triggered message to a different channel 56 | // ForwardMessageToChannel { 57 | // channel: String, 58 | // msg_context: String, 59 | // }, 60 | } 61 | 62 | impl SlackEditor for Action { 63 | fn to_description(&self) -> &str { 64 | match self { 65 | Action::AttachEmoji(_) => "Attach Emoji to Message", 66 | Action::ThreadedMessage(_) => "Reply with Threaded Message", 67 | // Action::ChannelMessage(_) => "Post Message to Same Channel", 68 | // Action::MsgOncallInThread { .. } => "Tag OnCall User in Thread", 69 | // Action::ForwardMessageToChannel { .. } => { 70 | // "Forward detected message to a different channel" 71 | } 72 | } 73 | 74 | fn to_type_selector_blocks(&self, index: Option) -> Vec { 75 | todo!() 76 | } 77 | 78 | fn to_value_input_blocks(&self, index: Option) -> Vec { 79 | todo!() 80 | } 81 | 82 | fn to_editor_blocks(&self, index: Option) -> Vec { 83 | todo!() 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /crates/receptionist/src/response2/mod.rs: -------------------------------------------------------------------------------- 1 | mod actions; 2 | // mod conditions; 3 | // mod listeners; 4 | // mod responses; 5 | // mod utils; 6 | 7 | // pub use actions::{MessageAction, ReceptionistAction}; 8 | // pub use conditions::{MessageCondition, ReceptionistCondition}; 9 | // pub use listeners::ReceptionistListener; 10 | // pub use responses::*; 11 | -------------------------------------------------------------------------------- /crates/receptionist/src/slack/api_calls.rs: -------------------------------------------------------------------------------- 1 | /// These should be merged upstream to slack-morphism if possible 2 | /// 3 | use serde::{Deserialize, Serialize}; 4 | use slack_morphism::{ClientResult, SlackClientSession}; 5 | use slack_morphism_hyper::SlackClientHyperHttpsConnector; 6 | use slack_morphism_models::{SlackChannelId, SlackTs}; 7 | 8 | pub async fn reactions_add( 9 | slack_session: &SlackClientSession<'_, SlackClientHyperHttpsConnector>, 10 | channel: &str, 11 | timestamp: &str, 12 | name: &str, 13 | ) -> ClientResult { 14 | slack_session 15 | .http_session_api 16 | .http_post( 17 | "reactions.add", 18 | &SlackApiReactionsAddRequest { 19 | channel: channel.to_owned().into(), 20 | name: name.to_owned(), 21 | timestamp: timestamp.to_owned().into(), 22 | }, 23 | None, 24 | ) 25 | .await 26 | } 27 | 28 | // #[skip_serializing_none] 29 | #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] 30 | pub struct SlackApiReactionsAddRequest { 31 | pub channel: SlackChannelId, 32 | pub timestamp: SlackTs, 33 | pub name: String, 34 | } 35 | 36 | // #[skip_serializing_none] 37 | #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] 38 | pub struct SlackApiReactionsAddResponse {} 39 | -------------------------------------------------------------------------------- /crates/receptionist/src/slack/commands_api.rs: -------------------------------------------------------------------------------- 1 | use super::SlackStateWorkaround; 2 | use crate::manager_ui::{new_manager_view, ManagerViewModes, MetaForManagerView}; 3 | use axum::{ 4 | extract::{Extension, Form}, 5 | http::StatusCode, 6 | response::IntoResponse, 7 | Json, 8 | }; 9 | use serde_json::{to_value, Value}; 10 | use slack_morphism::prelude::*; 11 | use std::sync::Arc; 12 | use tracing::error; 13 | 14 | pub async fn axum_handler_handle_slack_commands_api( 15 | Extension(slack_state): Extension>, 16 | Form(payload): Form, 17 | ) -> impl IntoResponse { 18 | let response = handle_slack_command(&*slack_state, payload).await; 19 | (response.0, Json(response.1)) 20 | } 21 | 22 | pub async fn handle_slack_command( 23 | slack_state: &SlackStateWorkaround, 24 | payload: SlackCommandEvent, 25 | ) -> (StatusCode, Value) { 26 | let view = new_manager_view(&MetaForManagerView::new( 27 | ManagerViewModes::Home, 28 | payload.user_id.to_string(), 29 | )) 30 | .await; 31 | 32 | if let Err(message) = slack_state 33 | .open_session() 34 | .views_open(&SlackApiViewsOpenRequest { 35 | trigger_id: payload.trigger_id, 36 | view, 37 | }) 38 | .await 39 | { 40 | error!("{}", message); 41 | } 42 | 43 | (StatusCode::OK, to_value("test").unwrap()) 44 | } 45 | -------------------------------------------------------------------------------- /crates/receptionist/src/slack/interaction_api.rs: -------------------------------------------------------------------------------- 1 | use super::SlackStateWorkaround; 2 | use crate::{process_action_event, process_submission_event}; 3 | use axum::{ 4 | extract::{Extension, Form}, 5 | http::StatusCode, 6 | response::IntoResponse, 7 | Json, 8 | }; 9 | use serde::{Deserialize, Serialize}; 10 | use serde_json::{from_str, Value}; 11 | use slack_morphism::prelude::*; 12 | use std::sync::Arc; 13 | use tracing::error; 14 | 15 | /// To `ack` the event, Slack needs empty content or a 204 status code like (StatusCode::OK, "") 16 | pub async fn axum_handler_slack_interactions_api( 17 | Extension(slack_state): Extension>, 18 | Form(body): Form, 19 | ) -> impl IntoResponse { 20 | let response = handle_slack_interaction(&*slack_state, body).await; 21 | (response.0, Json(response.1)) 22 | } 23 | 24 | #[derive(Serialize, Deserialize, Debug)] 25 | pub struct SlackInteractionWrapper { 26 | // payload: SlackInteractionEvent, // but as a Form 27 | payload: String, 28 | } 29 | 30 | pub async fn handle_slack_interaction( 31 | slack_state: &SlackStateWorkaround, 32 | payload: SlackInteractionWrapper, 33 | ) -> (StatusCode, Value) { 34 | if let Ok(interaction_event) = from_str::(&payload.payload) { 35 | match interaction_event { 36 | SlackInteractionEvent::BlockActions(block_action_event) => { 37 | if let Err(result) = process_action_event(block_action_event, slack_state).await { 38 | error!("error: {}", result); 39 | } 40 | } 41 | SlackInteractionEvent::ViewSubmission(view_submission_event) => { 42 | match process_submission_event(view_submission_event).await { 43 | Ok(opt) => { 44 | if let Some(slack_err) = opt { 45 | return (StatusCode::OK, serde_json::to_value(slack_err).unwrap()); 46 | } 47 | } 48 | Err(e) => { 49 | error!("error: {}", &e); 50 | } 51 | } 52 | } 53 | _ => todo!("Other interaction events are not implemented"), 54 | } 55 | 56 | (StatusCode::NO_CONTENT, serde_json::to_value("").unwrap()) 57 | } else { 58 | error!("Interaction event `payload` key is not valid json or does not deserialize to existing struct"); 59 | error!("{:?}", &payload); 60 | 61 | ( 62 | StatusCode::INTERNAL_SERVER_ERROR, 63 | serde_json::to_value("").unwrap(), 64 | ) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /crates/receptionist/src/slack/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod api_calls; 2 | pub mod commands_api; 3 | pub mod events_api; 4 | pub mod interaction_api; 5 | pub mod state_values; 6 | pub mod utils; 7 | pub mod verification; 8 | 9 | pub use commands_api::{axum_handler_handle_slack_commands_api, handle_slack_command}; 10 | pub use events_api::{axum_handler_slack_events_api, handle_slack_event}; 11 | pub use interaction_api::{ 12 | axum_handler_slack_interactions_api, handle_slack_interaction, SlackInteractionWrapper, 13 | }; 14 | pub use slack_morphism::signature_verifier::SlackEventSignatureVerifier; 15 | pub use state_values::*; 16 | pub use utils::*; 17 | -------------------------------------------------------------------------------- /crates/receptionist/src/slack/state_values.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Result}; 2 | use serde::{Deserialize, Serialize}; 3 | use slack_morphism::prelude::*; 4 | use std::collections::HashMap; 5 | 6 | #[derive(Serialize, Deserialize, Debug)] 7 | pub struct CustomSlackViewState(HashMap); 8 | 9 | #[derive(Serialize, Deserialize, Debug)] 10 | pub struct ViewBlockState(HashMap); 11 | 12 | #[derive(Serialize, Deserialize, Debug)] 13 | #[serde(tag = "type", rename_all = "snake_case")] 14 | pub enum ViewBlockStateType { 15 | ConversationsSelect { 16 | selected_conversation: Option, 17 | }, 18 | StaticSelect { 19 | selected_option: StaticSelectSelectedOptionValueState, 20 | }, 21 | PlainTextInput { 22 | value: String, 23 | }, 24 | MultiUsersSelect { 25 | selected_users: Vec, 26 | }, 27 | } 28 | 29 | impl ViewBlockStateType { 30 | pub fn get_value_from_static_select(&self) -> Result { 31 | if let ViewBlockStateType::StaticSelect { selected_option } = self { 32 | Ok(selected_option.value.to_owned()) 33 | } else { 34 | bail!("block is not static_select") 35 | } 36 | } 37 | 38 | pub fn get_conversation_select_value(&self) -> Result> { 39 | match self { 40 | ViewBlockStateType::ConversationsSelect { 41 | selected_conversation, 42 | } => Ok(selected_conversation.to_owned()), 43 | _ => bail!("block is not conversation_select"), 44 | } 45 | } 46 | 47 | pub fn get_plain_text_value(&self) -> Result { 48 | match self { 49 | ViewBlockStateType::PlainTextInput { value } => Ok(value.to_owned()), 50 | _ => bail!("block is not a plain text input"), 51 | } 52 | } 53 | 54 | pub fn get_multi_users_select_value(&self) -> Result> { 55 | match self { 56 | ViewBlockStateType::MultiUsersSelect { selected_users } => { 57 | Ok(selected_users.to_owned()) 58 | } 59 | _ => bail!("block is not a multi_users_select input"), 60 | } 61 | } 62 | } 63 | 64 | #[derive(Serialize, Deserialize, Debug)] 65 | pub struct StaticSelectSelectedOptionValueState { 66 | pub text: serde_json::Value, 67 | pub value: String, 68 | } 69 | 70 | #[cfg(test)] 71 | mod tests { 72 | use super::*; 73 | use serde_json::{from_value, json}; 74 | 75 | #[test] 76 | fn test_custom_slack_value_types() { 77 | let test = json!({ 78 | "BLOCK-channel-select_IDX_0": { 79 | "channel-select_IDX_0": { 80 | "selected_conversation": "G01EVFPD63V", 81 | "type": "conversations_select", 82 | } 83 | }}); 84 | 85 | let test_2 = json!({ 86 | "selected_conversation": "G01EVFPD63V", 87 | "type": "conversations_select", 88 | }); 89 | 90 | let _view_block_state_type: ViewBlockStateType = from_value(test_2).unwrap(); 91 | let _custom_slack_view_state_wrapper: CustomSlackViewState = from_value(test).unwrap(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /crates/receptionist/src/slack/utils.rs: -------------------------------------------------------------------------------- 1 | use crate::{new_manager_view, MetaForManagerView}; 2 | use anyhow::{anyhow, Result}; 3 | use serde::{Deserialize, Serialize}; 4 | use slack_morphism::prelude::*; 5 | use slack_morphism_hyper::{ 6 | SlackClientHyperConnector, SlackClientHyperHttpsConnector, SlackHyperClient, 7 | }; 8 | use std::{collections::HashMap, env, sync::Arc}; 9 | 10 | /// Helper for slack token->client persistence 11 | pub struct SlackStateWorkaround { 12 | slack_client: SlackHyperClient, 13 | bot_token: SlackApiToken, 14 | } 15 | 16 | impl SlackStateWorkaround { 17 | pub fn new_from_env() -> Self { 18 | SlackStateWorkaround { 19 | bot_token: SlackApiToken::new( 20 | std::env::var("SLACK_BOT_TOKEN") 21 | .unwrap_or_else(|_| "".to_string()) 22 | .into(), 23 | ), 24 | slack_client: SlackClient::new(SlackClientHyperConnector::new()), 25 | } 26 | } 27 | 28 | pub fn open_session(&self) -> SlackClientSession { 29 | self.slack_client.open_session(&self.bot_token) 30 | } 31 | 32 | pub async fn update_manager_modal_view( 33 | &self, 34 | view_id: SlackViewId, 35 | private_metadata: &MetaForManagerView, 36 | ) -> Result<()> { 37 | let view_update_request = 38 | SlackApiViewsUpdateRequest::new(new_manager_view(private_metadata).await) 39 | .with_view_id(view_id); 40 | 41 | self.open_session() 42 | .views_update(&view_update_request) 43 | .await 44 | .map_err(|slack_err| { 45 | anyhow!( 46 | "Error updating existing view with meta. Error: {} | Meta: {:?}", 47 | slack_err, 48 | &private_metadata 49 | ) 50 | })?; 51 | 52 | Ok(()) 53 | } 54 | } 55 | 56 | pub fn setup_slack() -> Arc { 57 | // SETUP SHARED SLACK CLIENT 58 | let slack_bot_token = SlackApiToken::new( 59 | env::var("SLACK_BOT_TOKEN") 60 | .unwrap_or_else(|_| " bool { 74 | false 75 | } 76 | 77 | fn is_threaded(&self) -> bool { 78 | false 79 | } 80 | 81 | fn is_hidden(&self) -> bool { 82 | false 83 | } 84 | } 85 | 86 | impl MessageHelpers for SlackMessageEvent { 87 | fn is_bot_message(&self) -> bool { 88 | matches!(self.subtype, Some(SlackMessageEventType::BotMessage)) 89 | || self.sender.bot_id.is_some() 90 | } 91 | 92 | fn is_threaded(&self) -> bool { 93 | self.origin.thread_ts.is_some() 94 | } 95 | 96 | fn is_hidden(&self) -> bool { 97 | self.hidden.is_some() 98 | } 99 | } 100 | 101 | #[derive(Serialize, Deserialize, Debug)] 102 | #[serde(tag = "response_action", rename_all = "snake_case")] 103 | pub enum SlackResponseAction { 104 | /// HashMap error_message> 105 | Errors { errors: HashMap }, 106 | // Update, 107 | } 108 | 109 | impl SlackResponseAction { 110 | pub fn from_validation_errors(errors: Vec) -> Self { 111 | let mut error_map: HashMap = HashMap::new(); 112 | 113 | for e in errors { 114 | error_map.insert(e.block_id.to_string(), e.error_message); 115 | } 116 | 117 | SlackResponseAction::Errors { errors: error_map } 118 | } 119 | } 120 | 121 | #[derive(Serialize, Deserialize, Debug)] 122 | pub struct SlackBlockValidationError { 123 | pub block_id: SlackBlockId, 124 | pub error_message: String, 125 | } 126 | 127 | /// # Examples 128 | /// ```rust 129 | /// use receptionist::remove_emoji_colons; 130 | /// assert_eq!("rust", remove_emoji_colons(":rust:")); 131 | /// assert_eq!("rust", remove_emoji_colons("rust")) 132 | /// ``` 133 | pub fn remove_emoji_colons(emoji_name: &str) -> String { 134 | emoji_name.replace(":", "") 135 | } 136 | 137 | /// # Examples 138 | /// ```rust 139 | /// use receptionist::add_emoji_colons; 140 | /// assert_eq!(":rust:", add_emoji_colons(":rust:")); 141 | /// assert_eq!(":rust:", add_emoji_colons(":rust")); 142 | /// assert_eq!(":rust:", add_emoji_colons("rust:")); 143 | /// assert_eq!(":rust:", add_emoji_colons("rust")); 144 | /// ``` 145 | pub fn add_emoji_colons(emoji_name: &str) -> String { 146 | match emoji_name.as_bytes() { 147 | [b':', .., b':'] => emoji_name.to_string(), 148 | [b':', ..] => format!("{emoji_name}:"), 149 | [.., b':'] => format!(":{emoji_name}"), 150 | [..] => format!(":{emoji_name}:"), 151 | } 152 | } 153 | 154 | pub fn render_channel_id(channel_id: &str) -> String { 155 | format!("<#{channel_id}>") 156 | } 157 | 158 | pub fn render_user_id(user_id: &str) -> String { 159 | format!("<@{user_id}>") 160 | } 161 | 162 | pub fn render_url_with_text(link_text: &str, url: &str) -> String { 163 | format!("<{url}|{link_text}>") 164 | } 165 | 166 | pub fn format_forwarded_message( 167 | origin_channel_id: &str, 168 | origin_user_id: &str, 169 | msg_permalink: &str, 170 | additional_msg_context: &str, 171 | ) -> String { 172 | let user = render_user_id(origin_user_id); 173 | let origin = render_channel_id(origin_channel_id); 174 | let msg_link = render_url_with_text("this message", msg_permalink); 175 | 176 | format!("{user} just sent {msg_link} to {origin}. \n _Context_: {additional_msg_context}") 177 | } 178 | 179 | pub fn get_sender(sender: &SlackMessageSender) -> String { 180 | if sender.user.is_some() { 181 | sender.user.clone().unwrap().to_string() 182 | } else if sender.bot_id.is_some() { 183 | sender.bot_id.clone().unwrap().to_string() 184 | } else { 185 | sender 186 | .username 187 | .clone() 188 | .unwrap_or_else(|| String::from("unknown user")) 189 | } 190 | } 191 | 192 | #[cfg(test)] 193 | mod tests { 194 | use super::*; 195 | 196 | #[test] 197 | fn test_remove_emoji_colons() { 198 | assert_eq!("rust", remove_emoji_colons(":rust:")); 199 | assert_eq!("rust", remove_emoji_colons("rust")) 200 | } 201 | 202 | #[test] 203 | fn test_add_emoji_colons() { 204 | assert_eq!(":rust:", add_emoji_colons(":rust:")); 205 | assert_eq!(":rust:", add_emoji_colons(":rust")); 206 | assert_eq!(":rust:", add_emoji_colons("rust:")); 207 | assert_eq!(":rust:", add_emoji_colons("rust")); 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /crates/receptionist/src/slack/verification.rs: -------------------------------------------------------------------------------- 1 | //! Slack Verification Service Middleware for Tower/Axum webserver ecosystem 2 | use axum::{ 3 | body::{Body, BoxBody}, 4 | http::{Request, Response, StatusCode}, 5 | response::IntoResponse, 6 | }; 7 | use slack_morphism::signature_verifier::SlackEventSignatureVerifier; 8 | use std::{convert::Infallible, future::Future, pin::Pin}; 9 | use tower::Service; 10 | 11 | /// This can be used in an Axum layer like so: 12 | /// ```should_panic 13 | /// use axum::{ 14 | /// routing::{get, post}, 15 | /// AddExtensionLayer, Router, 16 | /// }; 17 | /// use slack_morphism::signature_verifier::SlackEventSignatureVerifier; 18 | /// use receptionist::verification::SlackRequestVerifier; 19 | /// use receptionist::{axum_handler_slack_events_api, axum_handler_slack_interactions_api, axum_handler_handle_slack_commands_api}; 20 | /// use std::env; 21 | /// use tower::ServiceBuilder; 22 | /// 23 | /// let slack_api_router = Router::new() 24 | /// .route("/events", post(axum_handler_slack_events_api)) 25 | /// .route("/interaction", post(axum_handler_slack_interactions_api)) 26 | /// .route("/commands", post(axum_handler_handle_slack_commands_api)) 27 | /// .layer(ServiceBuilder::new().layer_fn(|inner| { 28 | /// SlackRequestVerifier { 29 | /// inner, 30 | /// verifier: SlackEventSignatureVerifier::new( 31 | /// &env::var("SLACK_SIGNING_SECRET") 32 | /// .expect("Provide signing secret env var SLACK_SIGNING_SECRET"), 33 | /// ), 34 | /// } 35 | /// })); 36 | /// ``` 37 | #[derive(Clone)] 38 | pub struct SlackRequestVerifier { 39 | pub inner: S, 40 | pub verifier: SlackEventSignatureVerifier, 41 | } 42 | 43 | impl Service> for SlackRequestVerifier 44 | where 45 | S: Service, Error = Infallible> + Clone + Send + 'static, 46 | S::Response: IntoResponse, 47 | S::Future: Send + 'static, 48 | { 49 | type Response = Response; 50 | type Error = Infallible; 51 | type Future = Pin> + Send>>; 52 | 53 | fn poll_ready( 54 | &mut self, 55 | cx: &mut std::task::Context<'_>, 56 | ) -> std::task::Poll> { 57 | self.inner.poll_ready(cx) 58 | } 59 | 60 | fn call(&mut self, req: Request) -> Self::Future { 61 | let mut inner = self.inner.clone(); 62 | let verifier = self.verifier.clone(); 63 | 64 | Box::pin(async move { 65 | let (parts, body) = req.into_parts(); 66 | 67 | let hash = match parts 68 | .headers 69 | .get(SlackEventSignatureVerifier::SLACK_SIGNED_HASH_HEADER) 70 | { 71 | Some(hash_header) => match hash_header.to_str() { 72 | Ok(hash_str) => hash_str, 73 | Err(_) => return Ok(StatusCode::UNAUTHORIZED.into_response()), 74 | }, 75 | None => return Ok(StatusCode::UNAUTHORIZED.into_response()), 76 | }; 77 | 78 | let ts = match parts 79 | .headers 80 | .get(SlackEventSignatureVerifier::SLACK_SIGNED_TIMESTAMP) 81 | { 82 | Some(ts_header) => match ts_header.to_str() { 83 | Ok(ts_str) => ts_str, 84 | Err(_) => return Ok(StatusCode::UNAUTHORIZED.into_response()), 85 | }, 86 | None => return Ok(StatusCode::UNAUTHORIZED.into_response()), 87 | }; 88 | 89 | let body_bytes = if let Ok(bytes) = hyper::body::to_bytes(body).await { 90 | bytes 91 | } else { 92 | return Ok(StatusCode::BAD_REQUEST.into_response()); 93 | }; 94 | 95 | let body_as_str = match std::str::from_utf8(body_bytes.as_ref()) { 96 | Ok(byte_str) => byte_str, 97 | Err(_) => return Ok(StatusCode::BAD_REQUEST.into_response()), 98 | }; 99 | 100 | // check if the request is valid 101 | match verifier.verify(hash, body_as_str, ts) { 102 | Ok(_) => { 103 | let req = Request::from_parts(parts, Body::from(body_bytes)); 104 | inner.call(req).await.map(|res| res.into_response()) 105 | } 106 | Err(_) => Ok(StatusCode::UNAUTHORIZED.into_response()), 107 | } 108 | }) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /crates/receptionist/src/utils.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | use serde_json::to_writer_pretty; 3 | use std::{fs::File, path::Path}; 4 | 5 | /// used during development to capture/inspect for mocking 6 | pub fn write_serde_struct_to_file>(path: P, obj: impl Serialize) { 7 | to_writer_pretty(&File::create(path).expect("unable to create file"), &obj) 8 | .expect("unable to write to file") 9 | } 10 | -------------------------------------------------------------------------------- /crates/receptionist/tests/generate_previews.rs: -------------------------------------------------------------------------------- 1 | use receptionist::{ 2 | write_serde_struct_to_file, MessageAction, MessageCondition, ReceptionistAction, 3 | ReceptionistCondition, ReceptionistResponse, 4 | }; 5 | use serde::{Deserialize, Serialize}; 6 | use slack_morphism::prelude::*; 7 | use std::path::{Path, PathBuf}; 8 | 9 | const GENERATED_BLOCKS_DIR: &str = "tests/generated_blocks"; 10 | #[derive(Serialize, Deserialize)] 11 | struct BlockKitTemplate { 12 | blocks: Vec, 13 | } 14 | 15 | impl From for BlockKitTemplate { 16 | fn from(rec_response: ReceptionistResponse) -> Self { 17 | Self { 18 | blocks: rec_response.to_editor_blocks(), 19 | } 20 | } 21 | } 22 | 23 | fn build_generated_path(file_name: &str) -> PathBuf { 24 | let formatted_file = if file_name.ends_with(".json") { 25 | file_name.to_string() 26 | } else { 27 | format!("{file_name}.json") 28 | }; 29 | 30 | Path::new(GENERATED_BLOCKS_DIR).join(formatted_file) 31 | } 32 | 33 | fn write_preview_file(name: &str, rec_response: ReceptionistResponse) { 34 | write_serde_struct_to_file( 35 | build_generated_path(&format!("{name}.json")), 36 | BlockKitTemplate::from(rec_response), 37 | ) 38 | } 39 | 40 | #[test] 41 | fn gen_action_attach_emoji() { 42 | let mut rec_response = ReceptionistResponse::default(); 43 | 44 | let action = rec_response.actions.first_mut().unwrap(); 45 | *action = ReceptionistAction::ForMessage(MessageAction::AttachEmoji(":thumbsup:".into())); 46 | 47 | write_preview_file("attach_emoji", rec_response) 48 | } 49 | 50 | #[test] 51 | fn gen_action_tag_oncall() { 52 | let mut rec_response = ReceptionistResponse::default(); 53 | 54 | let action = rec_response.actions.first_mut().unwrap(); 55 | *action = ReceptionistAction::ForMessage(MessageAction::MsgOncallInThread { 56 | escalation_policy_id: "some_id".into(), 57 | message: "some_message".into(), 58 | }); 59 | 60 | write_preview_file("tag_oncall_in_thread", rec_response) 61 | } 62 | 63 | #[test] 64 | fn gen_action_forward_msg_to_channel() { 65 | let mut rec_response = ReceptionistResponse::default(); 66 | 67 | let action = rec_response.actions.first_mut().unwrap(); 68 | *action = ReceptionistAction::ForMessage(MessageAction::ForwardMessageToChannel { 69 | channel: "".into(), 70 | msg_context: "some_message".into(), 71 | }); 72 | 73 | write_preview_file("fwd_msg_to_channel", rec_response) 74 | } 75 | 76 | #[test] 77 | fn gen_condition_match_regex() { 78 | let mut rec_response = ReceptionistResponse::default(); 79 | 80 | let condition = rec_response.conditions.first_mut().unwrap(); 81 | *condition = 82 | ReceptionistCondition::ForMessage(MessageCondition::MatchRegex("".into())); 83 | 84 | write_preview_file("match_regex", rec_response) 85 | } 86 | -------------------------------------------------------------------------------- /crates/receptionist/tests/generated_blocks/attach_emoji.json: -------------------------------------------------------------------------------- 1 | { 2 | "blocks": [ 3 | { 4 | "type": "section", 5 | "block_id": "BLOCK-collaborator-selection_IDX_0", 6 | "text": { 7 | "type": "mrkdwn", 8 | "text": ":busts_in_silhouette: Users that can edit this Response" 9 | }, 10 | "accessory": { 11 | "type": "multi_users_select", 12 | "action_id": "collaborator-selection_IDX_0", 13 | "placeholder": { 14 | "type": "plain_text", 15 | "text": "Select Collaborators" 16 | } 17 | } 18 | }, 19 | { 20 | "type": "divider" 21 | }, 22 | { 23 | "type": "section", 24 | "block_id": "BLOCK-listener-channel-selected_IDX_0", 25 | "text": { 26 | "type": "mrkdwn", 27 | "text": ":slack: Select a Channel :point_right:" 28 | }, 29 | "accessory": { 30 | "type": "conversations_select", 31 | "action_id": "listener-channel-selected_IDX_0", 32 | "placeholder": { 33 | "type": "plain_text", 34 | "text": "#my-channel" 35 | } 36 | } 37 | }, 38 | { 39 | "type": "divider" 40 | }, 41 | { 42 | "type": "section", 43 | "block_id": "BLOCK-condition-type-selected_IDX_0", 44 | "text": { 45 | "type": "mrkdwn", 46 | "text": ":clipboard: Select a match condition type" 47 | }, 48 | "accessory": { 49 | "type": "static_select", 50 | "action_id": "condition-type-selected_IDX_0", 51 | "placeholder": { 52 | "type": "plain_text", 53 | "text": "select matching Type" 54 | }, 55 | "options": [ 56 | { 57 | "text": { 58 | "type": "plain_text", 59 | "text": "Phrase Match" 60 | }, 61 | "value": "match-phrase" 62 | }, 63 | { 64 | "text": { 65 | "type": "plain_text", 66 | "text": "Regex Match" 67 | }, 68 | "value": "match-regex" 69 | } 70 | ], 71 | "initial_option": { 72 | "text": { 73 | "type": "plain_text", 74 | "text": "Phrase Match" 75 | }, 76 | "value": "match-phrase" 77 | } 78 | } 79 | }, 80 | { 81 | "type": "input", 82 | "block_id": "BLOCK-message-condition-value-input_IDX_0", 83 | "label": { 84 | "type": "plain_text", 85 | "text": "Message contains this phrase:" 86 | }, 87 | "element": { 88 | "type": "plain_text_input", 89 | "action_id": "message-condition-value-input_IDX_0", 90 | "placeholder": { 91 | "type": "plain_text", 92 | "text": "Phrase to match against" 93 | } 94 | } 95 | }, 96 | { 97 | "type": "divider" 98 | }, 99 | { 100 | "type": "section", 101 | "block_id": "BLOCK-action-type-selected_IDX_0", 102 | "text": { 103 | "type": "mrkdwn", 104 | "text": ":building_construction: Select an Action to do if conditions are met" 105 | }, 106 | "accessory": { 107 | "type": "static_select", 108 | "action_id": "action-type-selected_IDX_0", 109 | "placeholder": { 110 | "type": "plain_text", 111 | "text": "select action Type" 112 | }, 113 | "options": [ 114 | { 115 | "text": { 116 | "type": "plain_text", 117 | "text": "Attach Emoji to Message" 118 | }, 119 | "value": "attach-emoji" 120 | }, 121 | { 122 | "text": { 123 | "type": "plain_text", 124 | "text": "Reply with Threaded Message" 125 | }, 126 | "value": "threaded-message" 127 | }, 128 | { 129 | "text": { 130 | "type": "plain_text", 131 | "text": "Post Message to Same Channel" 132 | }, 133 | "value": "channel-message" 134 | }, 135 | { 136 | "text": { 137 | "type": "plain_text", 138 | "text": "Tag OnCall User in Thread" 139 | }, 140 | "value": "msg-oncall-in-thread" 141 | }, 142 | { 143 | "text": { 144 | "type": "plain_text", 145 | "text": "Forward detected message to a different channel" 146 | }, 147 | "value": "forward-message-to-channel" 148 | } 149 | ], 150 | "initial_option": { 151 | "text": { 152 | "type": "plain_text", 153 | "text": "Attach Emoji to Message" 154 | }, 155 | "value": "attach-emoji" 156 | } 157 | } 158 | }, 159 | { 160 | "type": "input", 161 | "block_id": "BLOCK-attach-emoji-input_IDX_0", 162 | "label": { 163 | "type": "plain_text", 164 | "text": "Choose an emoji (can also trigger Slack Workflows)" 165 | }, 166 | "element": { 167 | "type": "plain_text_input", 168 | "action_id": "attach-emoji-input_IDX_0", 169 | "placeholder": { 170 | "type": "plain_text", 171 | "text": "my-emoji" 172 | }, 173 | "initial_value": ":thumbsup:" 174 | } 175 | }, 176 | { 177 | "type": "divider" 178 | } 179 | ] 180 | } -------------------------------------------------------------------------------- /crates/receptionist/tests/generated_blocks/fwd_msg_to_channel.json: -------------------------------------------------------------------------------- 1 | { 2 | "blocks": [ 3 | { 4 | "type": "section", 5 | "block_id": "BLOCK-collaborator-selection_IDX_0", 6 | "text": { 7 | "type": "mrkdwn", 8 | "text": ":busts_in_silhouette: Users that can edit this Response" 9 | }, 10 | "accessory": { 11 | "type": "multi_users_select", 12 | "action_id": "collaborator-selection_IDX_0", 13 | "placeholder": { 14 | "type": "plain_text", 15 | "text": "Select Collaborators" 16 | } 17 | } 18 | }, 19 | { 20 | "type": "divider" 21 | }, 22 | { 23 | "type": "section", 24 | "block_id": "BLOCK-listener-channel-selected_IDX_0", 25 | "text": { 26 | "type": "mrkdwn", 27 | "text": ":slack: Select a Channel :point_right:" 28 | }, 29 | "accessory": { 30 | "type": "conversations_select", 31 | "action_id": "listener-channel-selected_IDX_0", 32 | "placeholder": { 33 | "type": "plain_text", 34 | "text": "#my-channel" 35 | } 36 | } 37 | }, 38 | { 39 | "type": "divider" 40 | }, 41 | { 42 | "type": "section", 43 | "block_id": "BLOCK-condition-type-selected_IDX_0", 44 | "text": { 45 | "type": "mrkdwn", 46 | "text": ":clipboard: Select a match condition type" 47 | }, 48 | "accessory": { 49 | "type": "static_select", 50 | "action_id": "condition-type-selected_IDX_0", 51 | "placeholder": { 52 | "type": "plain_text", 53 | "text": "select matching Type" 54 | }, 55 | "options": [ 56 | { 57 | "text": { 58 | "type": "plain_text", 59 | "text": "Phrase Match" 60 | }, 61 | "value": "match-phrase" 62 | }, 63 | { 64 | "text": { 65 | "type": "plain_text", 66 | "text": "Regex Match" 67 | }, 68 | "value": "match-regex" 69 | } 70 | ], 71 | "initial_option": { 72 | "text": { 73 | "type": "plain_text", 74 | "text": "Phrase Match" 75 | }, 76 | "value": "match-phrase" 77 | } 78 | } 79 | }, 80 | { 81 | "type": "input", 82 | "block_id": "BLOCK-message-condition-value-input_IDX_0", 83 | "label": { 84 | "type": "plain_text", 85 | "text": "Message contains this phrase:" 86 | }, 87 | "element": { 88 | "type": "plain_text_input", 89 | "action_id": "message-condition-value-input_IDX_0", 90 | "placeholder": { 91 | "type": "plain_text", 92 | "text": "Phrase to match against" 93 | } 94 | } 95 | }, 96 | { 97 | "type": "divider" 98 | }, 99 | { 100 | "type": "section", 101 | "block_id": "BLOCK-action-type-selected_IDX_0", 102 | "text": { 103 | "type": "mrkdwn", 104 | "text": ":building_construction: Select an Action to do if conditions are met" 105 | }, 106 | "accessory": { 107 | "type": "static_select", 108 | "action_id": "action-type-selected_IDX_0", 109 | "placeholder": { 110 | "type": "plain_text", 111 | "text": "select action Type" 112 | }, 113 | "options": [ 114 | { 115 | "text": { 116 | "type": "plain_text", 117 | "text": "Attach Emoji to Message" 118 | }, 119 | "value": "attach-emoji" 120 | }, 121 | { 122 | "text": { 123 | "type": "plain_text", 124 | "text": "Reply with Threaded Message" 125 | }, 126 | "value": "threaded-message" 127 | }, 128 | { 129 | "text": { 130 | "type": "plain_text", 131 | "text": "Post Message to Same Channel" 132 | }, 133 | "value": "channel-message" 134 | }, 135 | { 136 | "text": { 137 | "type": "plain_text", 138 | "text": "Tag OnCall User in Thread" 139 | }, 140 | "value": "msg-oncall-in-thread" 141 | }, 142 | { 143 | "text": { 144 | "type": "plain_text", 145 | "text": "Forward detected message to a different channel" 146 | }, 147 | "value": "forward-message-to-channel" 148 | } 149 | ], 150 | "initial_option": { 151 | "text": { 152 | "type": "plain_text", 153 | "text": "Forward detected message to a different channel" 154 | }, 155 | "value": "forward-message-to-channel" 156 | } 157 | } 158 | }, 159 | { 160 | "type": "section", 161 | "block_id": "BLOCK-fwd-msg-to-chan-channel-input_IDX_0", 162 | "text": { 163 | "type": "mrkdwn", 164 | "text": ":envelope_with_arrow: Select a Channel to forward this message to" 165 | }, 166 | "accessory": { 167 | "type": "conversations_select", 168 | "action_id": "fwd-msg-to-chan-channel-input_IDX_0", 169 | "placeholder": { 170 | "type": "plain_text", 171 | "text": "#my-channel" 172 | } 173 | } 174 | }, 175 | { 176 | "type": "divider" 177 | }, 178 | { 179 | "type": "input", 180 | "block_id": "BLOCK-fwd-msg-to-chan-msg-context-input_IDX_0", 181 | "label": { 182 | "type": "plain_text", 183 | "text": "Add some context for why this message is being forwarded" 184 | }, 185 | "element": { 186 | "type": "plain_text_input", 187 | "action_id": "fwd-msg-to-chan-msg-context-input_IDX_0", 188 | "placeholder": { 189 | "type": "plain_text", 190 | "text": "Context about what this message is" 191 | }, 192 | "initial_value": "some_message" 193 | } 194 | }, 195 | { 196 | "type": "divider" 197 | } 198 | ] 199 | } -------------------------------------------------------------------------------- /crates/receptionist/tests/generated_blocks/match_regex.json: -------------------------------------------------------------------------------- 1 | { 2 | "blocks": [ 3 | { 4 | "type": "section", 5 | "block_id": "BLOCK-collaborator-selection_IDX_0", 6 | "text": { 7 | "type": "mrkdwn", 8 | "text": ":busts_in_silhouette: Users that can edit this Response" 9 | }, 10 | "accessory": { 11 | "type": "multi_users_select", 12 | "action_id": "collaborator-selection_IDX_0", 13 | "placeholder": { 14 | "type": "plain_text", 15 | "text": "Select Collaborators" 16 | } 17 | } 18 | }, 19 | { 20 | "type": "divider" 21 | }, 22 | { 23 | "type": "section", 24 | "block_id": "BLOCK-listener-channel-selected_IDX_0", 25 | "text": { 26 | "type": "mrkdwn", 27 | "text": ":slack: Select a Channel :point_right:" 28 | }, 29 | "accessory": { 30 | "type": "conversations_select", 31 | "action_id": "listener-channel-selected_IDX_0", 32 | "placeholder": { 33 | "type": "plain_text", 34 | "text": "#my-channel" 35 | } 36 | } 37 | }, 38 | { 39 | "type": "divider" 40 | }, 41 | { 42 | "type": "section", 43 | "block_id": "BLOCK-condition-type-selected_IDX_0", 44 | "text": { 45 | "type": "mrkdwn", 46 | "text": ":clipboard: Select a match condition type" 47 | }, 48 | "accessory": { 49 | "type": "static_select", 50 | "action_id": "condition-type-selected_IDX_0", 51 | "placeholder": { 52 | "type": "plain_text", 53 | "text": "select matching Type" 54 | }, 55 | "options": [ 56 | { 57 | "text": { 58 | "type": "plain_text", 59 | "text": "Phrase Match" 60 | }, 61 | "value": "match-phrase" 62 | }, 63 | { 64 | "text": { 65 | "type": "plain_text", 66 | "text": "Regex Match" 67 | }, 68 | "value": "match-regex" 69 | } 70 | ], 71 | "initial_option": { 72 | "text": { 73 | "type": "plain_text", 74 | "text": "Regex Match" 75 | }, 76 | "value": "match-regex" 77 | } 78 | } 79 | }, 80 | { 81 | "type": "input", 82 | "block_id": "BLOCK-message-condition-value-input_IDX_0", 83 | "label": { 84 | "type": "plain_text", 85 | "text": "Message contains a match to this Regex pattern:" 86 | }, 87 | "element": { 88 | "type": "plain_text_input", 89 | "action_id": "message-condition-value-input_IDX_0", 90 | "placeholder": { 91 | "type": "plain_text", 92 | "text": "Regex pattern to match against" 93 | }, 94 | "initial_value": "" 95 | } 96 | }, 97 | { 98 | "type": "context", 99 | "elements": [ 100 | { 101 | "type": "mrkdwn", 102 | "text": "_Tip:_ Use regex101.com to validate your syntax first :writing_hand:" 103 | } 104 | ] 105 | }, 106 | { 107 | "type": "divider" 108 | }, 109 | { 110 | "type": "section", 111 | "block_id": "BLOCK-action-type-selected_IDX_0", 112 | "text": { 113 | "type": "mrkdwn", 114 | "text": ":building_construction: Select an Action to do if conditions are met" 115 | }, 116 | "accessory": { 117 | "type": "static_select", 118 | "action_id": "action-type-selected_IDX_0", 119 | "placeholder": { 120 | "type": "plain_text", 121 | "text": "select action Type" 122 | }, 123 | "options": [ 124 | { 125 | "text": { 126 | "type": "plain_text", 127 | "text": "Attach Emoji to Message" 128 | }, 129 | "value": "attach-emoji" 130 | }, 131 | { 132 | "text": { 133 | "type": "plain_text", 134 | "text": "Reply with Threaded Message" 135 | }, 136 | "value": "threaded-message" 137 | }, 138 | { 139 | "text": { 140 | "type": "plain_text", 141 | "text": "Post Message to Same Channel" 142 | }, 143 | "value": "channel-message" 144 | }, 145 | { 146 | "text": { 147 | "type": "plain_text", 148 | "text": "Tag OnCall User in Thread" 149 | }, 150 | "value": "msg-oncall-in-thread" 151 | }, 152 | { 153 | "text": { 154 | "type": "plain_text", 155 | "text": "Forward detected message to a different channel" 156 | }, 157 | "value": "forward-message-to-channel" 158 | } 159 | ], 160 | "initial_option": { 161 | "text": { 162 | "type": "plain_text", 163 | "text": "Attach Emoji to Message" 164 | }, 165 | "value": "attach-emoji" 166 | } 167 | } 168 | }, 169 | { 170 | "type": "input", 171 | "block_id": "BLOCK-attach-emoji-input_IDX_0", 172 | "label": { 173 | "type": "plain_text", 174 | "text": "Choose an emoji (can also trigger Slack Workflows)" 175 | }, 176 | "element": { 177 | "type": "plain_text_input", 178 | "action_id": "attach-emoji-input_IDX_0", 179 | "placeholder": { 180 | "type": "plain_text", 181 | "text": "my-emoji" 182 | } 183 | } 184 | }, 185 | { 186 | "type": "divider" 187 | } 188 | ] 189 | } -------------------------------------------------------------------------------- /crates/receptionist/tests/generated_blocks/tag_oncall_in_thread.json: -------------------------------------------------------------------------------- 1 | { 2 | "blocks": [ 3 | { 4 | "type": "section", 5 | "block_id": "BLOCK-collaborator-selection_IDX_0", 6 | "text": { 7 | "type": "mrkdwn", 8 | "text": ":busts_in_silhouette: Users that can edit this Response" 9 | }, 10 | "accessory": { 11 | "type": "multi_users_select", 12 | "action_id": "collaborator-selection_IDX_0", 13 | "placeholder": { 14 | "type": "plain_text", 15 | "text": "Select Collaborators" 16 | } 17 | } 18 | }, 19 | { 20 | "type": "divider" 21 | }, 22 | { 23 | "type": "section", 24 | "block_id": "BLOCK-listener-channel-selected_IDX_0", 25 | "text": { 26 | "type": "mrkdwn", 27 | "text": ":slack: Select a Channel :point_right:" 28 | }, 29 | "accessory": { 30 | "type": "conversations_select", 31 | "action_id": "listener-channel-selected_IDX_0", 32 | "placeholder": { 33 | "type": "plain_text", 34 | "text": "#my-channel" 35 | } 36 | } 37 | }, 38 | { 39 | "type": "divider" 40 | }, 41 | { 42 | "type": "section", 43 | "block_id": "BLOCK-condition-type-selected_IDX_0", 44 | "text": { 45 | "type": "mrkdwn", 46 | "text": ":clipboard: Select a match condition type" 47 | }, 48 | "accessory": { 49 | "type": "static_select", 50 | "action_id": "condition-type-selected_IDX_0", 51 | "placeholder": { 52 | "type": "plain_text", 53 | "text": "select matching Type" 54 | }, 55 | "options": [ 56 | { 57 | "text": { 58 | "type": "plain_text", 59 | "text": "Phrase Match" 60 | }, 61 | "value": "match-phrase" 62 | }, 63 | { 64 | "text": { 65 | "type": "plain_text", 66 | "text": "Regex Match" 67 | }, 68 | "value": "match-regex" 69 | } 70 | ], 71 | "initial_option": { 72 | "text": { 73 | "type": "plain_text", 74 | "text": "Phrase Match" 75 | }, 76 | "value": "match-phrase" 77 | } 78 | } 79 | }, 80 | { 81 | "type": "input", 82 | "block_id": "BLOCK-message-condition-value-input_IDX_0", 83 | "label": { 84 | "type": "plain_text", 85 | "text": "Message contains this phrase:" 86 | }, 87 | "element": { 88 | "type": "plain_text_input", 89 | "action_id": "message-condition-value-input_IDX_0", 90 | "placeholder": { 91 | "type": "plain_text", 92 | "text": "Phrase to match against" 93 | } 94 | } 95 | }, 96 | { 97 | "type": "divider" 98 | }, 99 | { 100 | "type": "section", 101 | "block_id": "BLOCK-action-type-selected_IDX_0", 102 | "text": { 103 | "type": "mrkdwn", 104 | "text": ":building_construction: Select an Action to do if conditions are met" 105 | }, 106 | "accessory": { 107 | "type": "static_select", 108 | "action_id": "action-type-selected_IDX_0", 109 | "placeholder": { 110 | "type": "plain_text", 111 | "text": "select action Type" 112 | }, 113 | "options": [ 114 | { 115 | "text": { 116 | "type": "plain_text", 117 | "text": "Attach Emoji to Message" 118 | }, 119 | "value": "attach-emoji" 120 | }, 121 | { 122 | "text": { 123 | "type": "plain_text", 124 | "text": "Reply with Threaded Message" 125 | }, 126 | "value": "threaded-message" 127 | }, 128 | { 129 | "text": { 130 | "type": "plain_text", 131 | "text": "Post Message to Same Channel" 132 | }, 133 | "value": "channel-message" 134 | }, 135 | { 136 | "text": { 137 | "type": "plain_text", 138 | "text": "Tag OnCall User in Thread" 139 | }, 140 | "value": "msg-oncall-in-thread" 141 | }, 142 | { 143 | "text": { 144 | "type": "plain_text", 145 | "text": "Forward detected message to a different channel" 146 | }, 147 | "value": "forward-message-to-channel" 148 | } 149 | ], 150 | "initial_option": { 151 | "text": { 152 | "type": "plain_text", 153 | "text": "Tag OnCall User in Thread" 154 | }, 155 | "value": "msg-oncall-in-thread" 156 | } 157 | } 158 | }, 159 | { 160 | "type": "input", 161 | "block_id": "BLOCK-pd-escalation-policy-input_IDX_0", 162 | "label": { 163 | "type": "plain_text", 164 | "text": "Enter the escalation policy to query" 165 | }, 166 | "element": { 167 | "type": "plain_text_input", 168 | "action_id": "pd-escalation-policy-input_IDX_0", 169 | "placeholder": { 170 | "type": "plain_text", 171 | "text": "Pxxxxxx" 172 | }, 173 | "initial_value": "some_id" 174 | } 175 | }, 176 | { 177 | "type": "input", 178 | "block_id": "BLOCK-pd-threaded-msg-input_IDX_0", 179 | "label": { 180 | "type": "plain_text", 181 | "text": "Enter the message to provide in thread with the tagged user" 182 | }, 183 | "element": { 184 | "type": "plain_text_input", 185 | "action_id": "pd-threaded-msg-input_IDX_0", 186 | "placeholder": { 187 | "type": "plain_text", 188 | "text": "is oncall and will handle this." 189 | }, 190 | "initial_value": "some_message" 191 | } 192 | }, 193 | { 194 | "type": "divider" 195 | } 196 | ] 197 | } -------------------------------------------------------------------------------- /crates/xtask/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "xtask" 3 | version = "0.1.0" 4 | edition = "2021" 5 | rust-version = "1.58" 6 | authors = ["Saxon Hunt "] 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [dependencies] -------------------------------------------------------------------------------- /crates/xtask/src/main.rs: -------------------------------------------------------------------------------- 1 | /// https://github.com/matklad/cargo-xtask/ 2 | /// 3 | use std::{ 4 | env, fs, 5 | path::{Path, PathBuf}, 6 | process::Command, 7 | thread, 8 | }; 9 | 10 | type DynError = Box; 11 | 12 | // commands 13 | const CMD_BUILD_SERVER_IMAGE: &str = "build-server-image"; 14 | const CMD_PUSH_IMAGE_AWS: &str = "push-image-aws"; 15 | const CMD_BUILD_LAMBDA_EVENTS: &str = "build-lambda-events"; 16 | const CMD_BUILD_LAMBDA_INTERACTIONS: &str = "build-lambda-interactions"; 17 | const CMD_BUILD_LAMBDA_COMMANDS: &str = "build-lambda-commands"; 18 | const CMD_BUILD_LAMBDA_ALL: &str = "build-lambda-all"; 19 | 20 | // lambda package names 21 | const LAMBDA_EVENTS: &str = "rec_lambda_events"; 22 | const LAMBDA_INTERACTIONS: &str = "rec_lambda_interactions"; 23 | const LAMBDA_COMMANDS: &str = "rec_lambda_commands"; 24 | 25 | fn main() { 26 | if let Err(e) = try_main() { 27 | eprintln!("{e}"); 28 | std::process::exit(-1); 29 | } 30 | } 31 | 32 | fn try_main() -> Result<(), DynError> { 33 | let task = env::args().nth(1); 34 | match task.as_deref() { 35 | Some(c) if c == CMD_BUILD_LAMBDA_EVENTS => prep_lambda_for_terraform(LAMBDA_EVENTS)?, 36 | Some(c) if c == CMD_BUILD_LAMBDA_COMMANDS => prep_lambda_for_terraform(LAMBDA_COMMANDS)?, 37 | Some(c) if c == CMD_BUILD_LAMBDA_INTERACTIONS => { 38 | prep_lambda_for_terraform(LAMBDA_INTERACTIONS)? 39 | } 40 | Some(c) if c == CMD_BUILD_LAMBDA_ALL => { 41 | let thread_handles = 42 | [LAMBDA_EVENTS, LAMBDA_COMMANDS, LAMBDA_INTERACTIONS].map(|pkg_name| { 43 | thread::spawn(|| { 44 | prep_lambda_for_terraform(pkg_name) 45 | .expect("failed to prep lambda: {pkg_name}") 46 | }) 47 | }); 48 | 49 | let mut errors = Vec::new(); 50 | for t in thread_handles { 51 | if let Err(msg) = t.join() { 52 | errors.push(msg); 53 | } 54 | } 55 | 56 | if !errors.is_empty() { 57 | return Err(format!("{:?}", errors).into()); 58 | } 59 | } 60 | _ => print_help(), 61 | } 62 | Ok(()) 63 | } 64 | 65 | fn print_help() { 66 | eprintln!( 67 | "\nTasks: 68 | {CMD_BUILD_SERVER_IMAGE} `docker build` the Receptionist webserver 69 | {CMD_PUSH_IMAGE_AWS} `docker push` the built image to AWS ECR 70 | -- 71 | {CMD_BUILD_LAMBDA_EVENTS} cross-compile [events] lambda binary 72 | {CMD_BUILD_LAMBDA_COMMANDS} cross-compile [commands] lambda binary 73 | {CMD_BUILD_LAMBDA_INTERACTIONS} cross-compile [interactions] lambda binary 74 | {CMD_BUILD_LAMBDA_ALL} cross-compile all lambdas 75 | " 76 | ) 77 | } 78 | 79 | #[allow(dead_code)] 80 | fn cargo() -> String { 81 | env::var("CARGO").unwrap_or_else(|_| "cargo".to_string()) 82 | } 83 | 84 | fn project_root() -> PathBuf { 85 | Path::new(&env!("CARGO_MANIFEST_DIR")) 86 | .ancestors() 87 | // .nth(1) would be `./crates`, .nth(2) is the Root 88 | .nth(2) 89 | .unwrap() 90 | .to_path_buf() 91 | } 92 | 93 | fn build_lambda(package_name: &str) -> Result<(), DynError> { 94 | let status = Command::new("docker") 95 | .current_dir(project_root()) 96 | .args(&[ 97 | "run", 98 | "--rm", 99 | "-t", 100 | "-v", 101 | &format!("{}:/home/rust/src", project_root().display()), 102 | "messense/rust-musl-cross:aarch64-musl", 103 | "cargo", 104 | "build", 105 | &format!("--package={package_name}"), 106 | "--release", 107 | ]) 108 | .status()?; 109 | 110 | if !status.success() { 111 | return Err("cargo build failed".into()); 112 | } 113 | Ok(()) 114 | } 115 | 116 | fn copy_lambda_binary_to_terraform_dir(package_name: &str) -> Result<(), DynError> { 117 | let binary_path = project_root().join(format!( 118 | "target/aarch64-unknown-linux-musl/release/{package_name}" 119 | )); 120 | 121 | let destination_dir = 122 | project_root().join(format!("terraform_aws/serverless/archives/{package_name}")); 123 | 124 | fs::create_dir_all(&destination_dir)?; 125 | fs::copy(&binary_path, destination_dir.join("bootstrap"))?; 126 | 127 | Ok(()) 128 | } 129 | 130 | fn prep_lambda_for_terraform(package_name: &str) -> Result<(), DynError> { 131 | build_lambda(package_name)?; 132 | copy_lambda_binary_to_terraform_dir(package_name)?; 133 | Ok(()) 134 | } 135 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | networks: 4 | local: 5 | 6 | services: 7 | localstack: # https://github.com/localstack/localstack/blob/master/docker-compose.yml 8 | image: "localstack/localstack:0.13.0.8" 9 | networks: 10 | - local 11 | build: 12 | context: . 13 | dockerfile: dockerfiles/Dockerfile.localstack 14 | container_name: localstack 15 | ports: 16 | - "4566:4566" 17 | - "4571:4571" 18 | environment: 19 | - SERVICES=dynamodb 20 | 21 | receptionist: 22 | container_name: receptionist-bot-rs 23 | image: "receptionist:local" 24 | networks: 25 | - local 26 | build: 27 | context: . 28 | dockerfile: dockerfiles/Dockerfile.local 29 | cache_from: 30 | - "receptionist:local" 31 | ports: 32 | - '3000:3000' 33 | env_file: 34 | - .env 35 | depends_on: 36 | - localstack 37 | restart: on-failure 38 | entrypoint: ["rec_server", "--aws-endpoint-url", "http://localstack:4566", "--fake"] -------------------------------------------------------------------------------- /dockerfiles/Dockerfile.aws: -------------------------------------------------------------------------------- 1 | # https://hub.docker.com/_/rust 2 | FROM rust:1.58.1 as file_loader 3 | # RUN rustup target add aarch64-unknown-linux-musl 4 | WORKDIR /usr/src/receptionist_bot 5 | COPY . . 6 | ############ 7 | 8 | FROM rust:1.58.1 as builder 9 | WORKDIR /usr/src/receptionist_bot 10 | COPY --from=file_loader /usr/src/receptionist_bot /usr/src/receptionist_bot 11 | RUN cargo install --path ./crates/rec_server 12 | ############### 13 | 14 | FROM debian:buster-slim 15 | # RUN apt-get update && apt-get install -y extra-runtime-dependencies && rm -rf /var/lib/apt/lists/* 16 | # RUN apt-get update && apt-get install -y libssl-dev && rm -rf /var/lib/apt/lists/* 17 | # RUN apt-get update && rm -rf /var/lib/apt/lists/* 18 | RUN apt-get update 19 | RUN apt install libnss3-tools -y 20 | RUN apt-get install wget -y 21 | 22 | ### setup certificate 23 | ## ARM specific link 24 | RUN wget https://github.com/FiloSottile/mkcert/releases/download/v1.4.3/mkcert-v1.4.3-linux-arm64 25 | RUN cp mkcert-v1.4.3-linux-arm64 /usr/local/bin/mkcert 26 | RUN chmod +x /usr/local/bin/mkcert 27 | RUN mkcert -install 28 | RUN mkcert localhost 127.0.0.1 ::1 29 | 30 | COPY --from=builder /usr/local/cargo/bin/rec_server /usr/local/bin/rec_server 31 | 32 | EXPOSE 3000 33 | 34 | # https://docs.docker.com/engine/reference/builder/#healthcheck 35 | HEALTHCHECK --interval=10s --timeout=3s \ 36 | CMD curl -f http://localhost:3000/ || exit 1 37 | 38 | 39 | CMD ["rec_server"] 40 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile.local: -------------------------------------------------------------------------------- 1 | # https://hub.docker.com/_/rust 2 | FROM rust:1.58.1 as builder 3 | 4 | WORKDIR /usr/src/receptionist_bot 5 | COPY . . 6 | 7 | RUN cargo install --locked --debug --path ./crates/rec_server 8 | 9 | FROM debian:buster-slim 10 | 11 | RUN apt-get update \ 12 | && apt-get install -y ca-certificates tzdata \ 13 | && rm -rf /var/lib/apt/lists/* 14 | 15 | # RUN apt-get update 16 | # RUN apt install libnss3-tools -y 17 | # RUN apt-get install wget -y 18 | 19 | ### setup certificate 20 | ## ARM specific link 21 | ################### 22 | # RUN wget https://github.com/FiloSottile/mkcert/releases/download/v1.4.3/mkcert-v1.4.3-linux-amd64 23 | # RUN cp mkcert-v1.4.3-linux-amd64 /usr/local/bin/mkcert 24 | # RUN chmod +x /usr/local/bin/mkcert 25 | # RUN mkcert -install 26 | # RUN mkcert localhost 127.0.0.1 ::1 27 | 28 | COPY --from=builder /usr/local/cargo/bin/rec_server /usr/local/bin/rec_server 29 | 30 | EXPOSE 3000 31 | 32 | # https://docs.docker.com/engine/reference/builder/#healthcheck 33 | HEALTHCHECK --interval=10s --timeout=3s \ 34 | CMD curl -f http://localhost:3000/ || exit 1 35 | 36 | 37 | CMD ["rec_server"] 38 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile.localstack: -------------------------------------------------------------------------------- 1 | FROM localstack/localstack:0.13.0.8 2 | 3 | 4 | COPY ./dockerfiles/localstack_dynamodb_setup /docker-entrypoint-initaws.d/ 5 | 6 | ENTRYPOINT ["docker-entrypoint.sh"] -------------------------------------------------------------------------------- /dockerfiles/localstack_dynamodb_setup/commands.sh: -------------------------------------------------------------------------------- 1 | #/bin/sh 2 | awslocal dynamodb create-table --cli-input-json file:///docker-entrypoint-initaws.d/mock-ddb-create.json -------------------------------------------------------------------------------- /dockerfiles/localstack_dynamodb_setup/mock-ddb-create.json: -------------------------------------------------------------------------------- 1 | { 2 | "KeySchema": [ 3 | { 4 | "AttributeName": "pk", 5 | "KeyType": "HASH" 6 | }, 7 | { 8 | "AttributeName": "sk", 9 | "KeyType": "RANGE" 10 | } 11 | ], 12 | "AttributeDefinitions": [ 13 | { 14 | "AttributeName": "pk", 15 | "AttributeType": "S" 16 | }, 17 | { 18 | "AttributeName": "sk", 19 | "AttributeType": "S" 20 | } 21 | ], 22 | "GlobalSecondaryIndexes": [ 23 | { 24 | "IndexName": "InvertedIndex", 25 | "KeySchema": [ 26 | { 27 | "AttributeName": "sk", 28 | "KeyType": "HASH" 29 | }, 30 | { 31 | "AttributeName": "pk", 32 | "KeyType": "RANGE" 33 | } 34 | ], 35 | "Projection": { 36 | "ProjectionType": "ALL" 37 | }, 38 | "ProvisionedThroughput": { 39 | "ReadCapacityUnits": 1, 40 | "WriteCapacityUnits": 1 41 | } 42 | } 43 | ], 44 | "BillingMode": "PROVISIONED", 45 | "TableName": "receptionist_bot", 46 | "ProvisionedThroughput": { 47 | "ReadCapacityUnits": 1, 48 | "WriteCapacityUnits": 1 49 | } 50 | } -------------------------------------------------------------------------------- /docs/architecture.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | - [Architecture](#architecture) 4 | - [Background](#background) 5 | - [Receptionist Bot Features vs Other Slack Automation Apps](#receptionist-bot-features-vs-other-slack-automation-apps) 6 | - [Design Philosophy](#design-philosophy) 7 | - [Code Map](#code-map) 8 | - [Components](#components) 9 | - [High-Level Features](#high-level-features) 10 | 11 | ## Background 12 | 13 | The Receptionist Bot is an experimental app designed as a lightweight, scalable Slack workflow automation system after I discovered multiple attempts to solve this problem across various companies with only limited degrees of success. Rather than replacing the first-party Slack Workflows system or becoming an entire wholesale automation platform such as [SOCless](https://github.com/twilio-labs/socless), the Receptionist bot can tap into both (or neither) to provide a full-featured yet easy user interface for Slack automations. 14 | 15 | ### Receptionist Bot Features vs Other Slack Automation Apps 16 | - No-Code interface, easier to use than Slack Workflows 17 | - Even though many users may find it easy, we have seen non-technical users struggle to stop and learn Workflows well enough to implement and maintain a useful automation playbook 18 | - Enhances Slack Workflows 19 | - Many users can write Slack Workflows but quickly run against some core limitations such as its inability to be automatically triggered by messages in a channel 20 | - Receptionist Bot can parse messages for you and trigger any existing Slack Workflow, or you can choose to just use Receptionist Actions instead. 21 | - $ Extremely Cheap, plus you own your data $ 22 | - Receptionist Bot can easily run on the cheapest AWS ec2 (`t4g.nano`) or on free-tier serverless functions, or self-hosted on your own servers. 23 | - Many companies exist to solve this Slack automation problem, but they charge per-user pricing and limit the amount of automations that can run per month. This gets expensive quickly as your company scales. 24 | - Slack data can be extremely sensitive. By running your own app theres no worry of data breaches against 3rd party companies impacting your own business 25 | 26 | ## Design Philosophy 27 | - **Open, Unopinionated Infrastructure** 28 | - Don't lock the app into AWS or cloud technologies 29 | - We have thorough examples for deploying as a standalone server with Docker on AWS, as serverless functions on AWS, and local development (with & without Docker). 30 | - Examples for [GCP](https://cloud.google.com), [fly.io](https://fly.io) & other environments would be welcome. 31 | - Don't lock the app to a specific database type 32 | - Database is selected via `cargo` feature flags. 33 | - If a user would rather use postgres instead of dynamoDB, they can create a postgres option in the `./receptionist/src/database` directory, which only requires writing the ~ 5 CRUD operation functions needed for that new database 34 | - **Write code in a manner that maximizes the benefits of Rust to check errors with the compiler instead of elaborate unit testing** 35 | - Because the Bot aims to provide a first-class dynamic UI inside Slack, we must be careful to ensure that we are programming in a manner that allows the compiler to check our work _before_ we get a runtime error when attempting to deserialize the modal submission. 36 | - Enums are your friend. The Slack modals API requires many constant strings for routing inputs to the correct place, and we need to handle every edge case. By utilizing enums whenever possible we can allow Rust to check our work instead of tedious manual testing. 37 | - **Write code for future extensibility even if its not needed yet** 38 | - **Example**: The `ReceptionistResponse` struct is the complete description of a single automation workflow, and is made up of Listeners, Conditions, and Actions. We know that users will want more types for each of these, so they are all enums. 39 | - **Example**: Even though the app can currently only support one action and one condition per workflow, the data model stores them stored in vectors and tracks their indices during updates to ensure that we do not prevent allowing multiple actions per workflow in the future. 40 | - Use `strum` crate to supercharge enums and reduce repeat code. 41 | 42 | 43 | 44 | ## Code Map 45 | - [`./receptionist`](./receptionist) - Rust library for all common code powering the Receptionist Bot. 46 | - [`receptionist/config`](./receptionist/config) - The bot's configuration struct and initialization code. 47 | - [`receptionist/database`](./receptionist/src/database/mod.rs) - code for all database types. database selection is controlled by cargo feature flags, with all databases using the same function names 48 | - [`receptionist/manager`](./receptionist/src/manager/mod.rs) - code for the Slack modal where users can manage their automations 49 | - [`receptionist/pagerduty`](./receptionist/src/pagerduty/mod.rs) - Pagerduty client to find the oncall user for a specific team 50 | - [`receptionist/response`](./receptionist/src/response/mod.rs) - Core code for the Receptionist's main data model, the Response. Automations are basically all boiled down to a single Response struct. 51 | - [`receptionist/slack`](./receptionist/src/slack/mod.rs) - All Slack code that isnt specific to the design of the Management interface 52 | - [`./rec_server`](./rec_server) - Rust (Axum) Webserver for deploying the Receptionist Bot as a standalone server application. 53 | - `./rec_lambda_commands`, `./rec_lambda_events`, `./rec_lambda_interactions` 54 | - Rust binaries for deploying the Receptionist Bot as serverless Lambda Functions behind an AWS API Gateway. 55 | - Each function covers a specific http route: `/commands`, `/events`, `/interactions` 56 | - [`./terraform_aws`](./terraform_aws) - contains 3 different terraform deployments for the bot 57 | 1. `terraform_aws/remote-state` is required to deploy the bot in either server or serverless mode 58 | 2. `terraform_aws/server` will deploy the bot using ECS on a `t4g.nano` EC2 instance 59 | 3. `terraform_aws/serverless` will deploy the bot as 3 Lambda Functions and an API Gateway 60 | - [`./xtask`](./xtask) - [common use cases typically reserved for makefiles](https://github.com/matklad/cargo-xtask/) 61 | - **To view available commands, run**: 62 | ```sh 63 | cargo xtask help 64 | ``` 65 | 66 | 67 | 68 | ## Components 69 | - DynamoDB is used for persistent app data such as Authentication, Channel Configurations for user-configured actions & conditions, 70 | 71 | 72 | ## High-Level Features 73 | - Process the following Slack Event Subscriptions: 74 | - [ ] `message` : search messages for user-defined Match values and do their configured action 75 | - Users can create a Condition for the bot to search messages for. 76 | - Users can create an Action for the bot to take when any of their Condition criteria is met. 77 | - Supported Match features: 78 | - [ ] Look for a phrase within a message 79 | - [ ] Parse message using a Regex string 80 | - Supported features: 81 | - [ ] React to a message with an emoji (**useful for _triggering_ Slack Workflows**) 82 | - [ ] Find a specific PagerDuty team & tag their oncall user in the thread 83 | - [ ] Find a specific PagerDuty team & page them 84 | - [ ] Ping a specific user with a threaded message (duplicate of Slack workflows) 85 | - [ ] Reply with a predefined message like an FAQ (duplicate of Slack workflows) 86 | - slash command keywords are user configurable via env, uses default if no env set 87 | - [ ] /config, /update, etc 88 | - slash commands 89 | - [ ] /rec-config : CRUD modal for Matches & Actions 90 | - [ ] /rec-list : ephemeral message of all configured Matches & Actions 91 | 92 | -------------------------------------------------------------------------------- /docs/deployments.md: -------------------------------------------------------------------------------- 1 | # Deployments 2 | 3 | - [Deployment to AWS as a standalone server](#deployment-to-aws-as-a-standalone-server) 4 | - [Deployment to AWS as Lambda Functions + API Gateway](#deployment-to-aws-as-lambda-functions--api-gateway) 5 | - [To remove a Terraform deployment](#to-remove-a-terraform-deployment) 6 | 7 | 8 | ### Deployment to AWS as a standalone server 9 | This repo contains a basic deployment setup striving for the cheapest possible server hosted application on AWS. 10 | 11 | With Rust, the resource usage is so low that it can run at high throughput on the smallest & cheapest ec2 instance (t4g.nano) with plenty of headroom. 12 | 13 | ### Prerequisites 14 | - awscli v2 installed 15 | - terraform installed 16 | - docker installed & running 17 | 18 | #### Step 1 - Setup Secrets (already added to `.gitignore`) 19 | 1. Create a `./secrets` directory (this whole directory and sub files are already added to `.gitignore`) 20 | 2. Create a `./secrets/slack_secret` file that only contains your slack signing secret 21 | 3. Create a `./secrets/slack_token` file that only contains your slack bot token (starts with `xoxb-`) 22 | 4. Create a `./secrets/pagerduty_token` file that only contains your pagerduty api token (if you don't have one, you will need to comment out some lines in Terraform OR just put a fake token here) 23 | 24 | 25 | #### Step 2 - Deploy Terraform Remote State Backend 26 | 1. `cd terraform_aws/remote-state` 27 | 2. `terraform init -backend-config="../config/config.s3.tfbackend"` 28 | 3. `terraform plan`, then `terraform apply` and approve 29 | 30 | #### Step 3 - Deploy App with Terraform + Docker + AWScli 31 | **Docker must be running and aws cli (v2) must be installed!** 32 | 1. `cd terraform_aws/server` 33 | 2. `terraform init -backend-config="../config/config.s3.tfbackend"` 34 | 3. `terraform plan`, then `terraform apply` and approve 35 | 36 | #### Step 4 - Create or Update your Bot in Slack 37 | - [Setting up the Slack App](#creating-the-slack-apps-permissions-urls-slash-commands-etc) 38 | - [Interact with the Receptionist Bot](#interact-with-the-receptionist-bot) 39 | 40 | 41 | ### Deployment to AWS as Lambda Functions + API Gateway 42 | If you prefer serverless, we can instead run the app as 3 lambda functions behind API Gateway 43 | 44 | With Rust you get the fastest cold starts possible so there is no risk of Slack Trigger IDs timing out. 45 | 46 | ### Prerequisites 47 | - terraform installed 48 | - docker installed & running 49 | 50 | #### Step 1 - Setup Secrets (already added to `.gitignore`) 51 | 1. Create a `./secrets` directory (this whole directory and sub files are already added to `.gitignore`) 52 | 2. Create a `./secrets/slack_secret` file that only contains your slack signing secret 53 | 3. Create a `./secrets/slack_token` file that only contains your slack bot token (starts with `xoxb-`) 54 | 4. Create a `./secrets/pagerduty_token` file that only contains your pagerduty api token (if you don't have one, you will need to comment out some lines in Terraform OR just put a fake token here) 55 | 56 | #### Step 2 - Ensure lambda target is installed 57 | 1. `rustup target add aarch64-unknown-linux-musl` 58 | 2. (if on MacOS, you will need `musl-gcc`) `brew install filosottile/musl-cross/musl-cross` 59 | 60 | #### Step 3 - Cross Compile Lambdas for Graviton2 61 | 1. Ensure docker is running `docker -v` 62 | 2. `cargo xtask build-lambda-all` 63 | 1. This will build lambda packages in release mode inside a docker image, copy the binaries to the terraform directory, then rename the binaries to `bootstrap` which is required for AWS Lambdas using a custom runtime 64 | 65 | #### Step 4 - Deploy Terraform Remote State Backend 66 | 1. `cd terraform_aws/remote-state` 67 | 2. `terraform init -backend-config="../config/config.s3.tfbackend"` 68 | 3. `terraform plan`, then `terraform apply` and approve 69 | 70 | #### Step 5 - Deploy App with Terraform 71 | 1. `cd terraform_aws/serverless` 72 | 2. `terraform init -backend-config="../config/config.s3.tfbackend"` 73 | 3. `terraform plan`, then `terraform apply` and approve 74 | 75 | #### Step 6 - Create or Update your Bot in Slack 76 | - [Setting up the Slack App](#creating-the-slack-apps-permissions-urls-slash-commands-etc) 77 | - [Interact with the Receptionist Bot](#interact-with-the-receptionist-bot) 78 | 79 | ## To remove a Terraform deployment 80 | 1. change directory to the terraform deployment you want to remove (remove the `remote-state` deployment last!) 81 | 1. `cd terraform_aws/` 82 | 2. `terraform apply -destroy` 83 | 84 | 85 | ## Creating the Slack App & Permissions, URLs, Slash Commands, etc. 86 | The Receptionist bot's Slack configuration is in a single `./manifest.yml` file can be pasted into your Slack App Manifest (either when creating a new app or modifying an existing one). You will just need to replace all instances of `` in the `manifest.yml` with the actual URL of your deployed (or local) application. 87 | 88 | ## Interact with the Receptionist Bot 89 | The app ships with a slash command `/rec-manage` that will display a UI for Creating, Editing, and Deleting Receptionist Workflow Responses -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | 2 | ## Local Development 3 | ### Prerequisites 4 | - ngrok 5 | - docker (optional, used for simple spinup of local development with dynamodb) 6 | 7 | There are two modes of local development: Docker-Compose w/DynamoDB vs. Cargo (temporary DB us) 8 | 9 | #### Step 1 - Setup `.env` File 10 | ``` 11 | SLACK_BOT_TOKEN= 12 | SLACK_SIGNING_SECRET= 13 | PAGERDUTY_TOKEN= (Optional) 14 | ``` 15 | 16 | #### Step 2 - Start the bot (either with docker or cargo) 17 | - To use dynamoDB & docker, run `docker compose up --build` 18 | - To use a hashmap as a temporary database and test without docker `cargo run --bin receptionist_server --features="tempdb, ansi" --no-default-features` 19 | 20 | 21 | #### Step 3 - Start ngrok and connect Slack to it 22 | 1. In a new Terminal, at the ngrok installation directory: `ngrok http 3000` or `./ngrok http --region=us --hostname=.ngrok.io 3000` 23 | 2. Get the https url from ngrok and replace all instances of `` in the `./manifest.yml` 24 | 3. Paste the updated `manifest` in your Slack App @ https://api.slack.com/apps - ([Setting up the Slack App](#creating-the-slack-apps-permissions-urls-slash-commands-etc)) 25 | 4. It may ask you to verify the Event Subscription URL, if your local bot is running this check should pass. 26 | 27 | 28 | ## Creating the Slack App & Permissions, URLs, Slash Commands, etc. 29 | The Receptionist bot's Slack configuration is in a single `./manifest.yml` file can be pasted into your Slack App Manifest (either when creating a new app or modifying an existing one). You will just need to replace all instances of `` in the `manifest.yml` with the actual URL of your deployed (or local) application. 30 | -------------------------------------------------------------------------------- /manager.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio-labs/receptionist-bot-rs/29225c6c94d7986d267b2ee861e94994ca46b7b9/manager.png -------------------------------------------------------------------------------- /manifest.yml: -------------------------------------------------------------------------------- 1 | display_information: 2 | name: Receptionist Bot 3 | features: 4 | bot_user: 5 | display_name: Receptionist Bot 6 | always_online: false 7 | slash_commands: 8 | - command: /rec-manage 9 | url: /slack/commands 10 | description: asdf 11 | should_escape: false 12 | - command: /rec-cmd 13 | url: /slack/commands 14 | description: asdf 15 | should_escape: false 16 | oauth_config: 17 | scopes: 18 | user: 19 | - channels:history 20 | - channels:read 21 | - channels:write 22 | - chat:write 23 | - emoji:read 24 | - mpim:history 25 | - reactions:read 26 | - reactions:write 27 | - usergroups:read 28 | - usergroups:write 29 | - users.profile:read 30 | bot: 31 | - app_mentions:read 32 | - channels:history 33 | - channels:join 34 | - channels:manage 35 | - channels:read 36 | - chat:write 37 | - chat:write.customize 38 | - chat:write.public 39 | - commands 40 | - dnd:read 41 | - emoji:read 42 | - groups:history 43 | - groups:read 44 | - groups:write 45 | - im:history 46 | - im:read 47 | - im:write 48 | - reactions:read 49 | - reactions:write 50 | - team:read 51 | - usergroups:read 52 | - usergroups:write 53 | - users.profile:read 54 | - users:read 55 | - users:write 56 | - workflow.steps:execute 57 | - users:read.email 58 | settings: 59 | event_subscriptions: 60 | request_url: /slack/events 61 | bot_events: 62 | - message.channels 63 | - message.groups 64 | interactivity: 65 | is_enabled: true 66 | request_url: /slack/interaction 67 | org_deploy_enabled: false 68 | socket_mode_enabled: false 69 | token_rotation_enabled: false 70 | -------------------------------------------------------------------------------- /robo-laptop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio-labs/receptionist-bot-rs/29225c6c94d7986d267b2ee861e94994ca46b7b9/robo-laptop.png -------------------------------------------------------------------------------- /terraform_aws/config/config.s3.tfbackend: -------------------------------------------------------------------------------- 1 | bucket = "